feat: refine position roundtable workflow

This commit is contained in:
董宏凯 committed 2026-07-22 22:22:38 +08:00
1 parent 359bd28f2c
commit 13bfa719ee
12 files changed
+1838 -458

No files matched your search

+11 -9
View File
@@ -71,15 +71,17 @@ const DialogOverlay = React.forwardRef<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
@@ -121,13 +121,14 @@ export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
}: {
className?: string;
filepath: string;
threadId: string;
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
hideFileName = false,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
/**
@@ -140,9 +141,10 @@ export function ArtifactFileDetail({
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
* 默认 false,沿用全局脱敏。
*/
disableDesensitize?: boolean;
}) {
*/
disableDesensitize?: boolean;
hideFileName?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
@@ -216,10 +218,11 @@ export function ArtifactFileDetail({
!isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isAutoPreviewLanguage = language === "html" || language === "markdown";
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isSpreadsheetFile) return "XLSX";
@@ -258,28 +261,31 @@ export function ArtifactFileDetail({
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
return;
}
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setShowPreviewSwitchPulse(false);
}, [filepathFromProps]);
useEffect(() => {
if (isPreviewStreaming) {
setViewMode("code");
return;
}
if (isSupportPreview) {
setViewMode("preview");
return;
}
setViewMode("code");
}, [filepathFromProps, isSupportPreview, language, thread.isLoading]);
useEffect(() => {
if (!allowAutoViewSwitch || language !== "html") {
return;
}
setViewMode("code");
}, [isPreviewStreaming, isSupportPreview]);
useEffect(() => {
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
return;
}
if (thread.isLoading) {
setViewMode("code");
@@ -291,9 +297,9 @@ export function ArtifactFileDetail({
const timeoutId = window.setTimeout(() => {
setShowPreviewSwitchPulse(false);
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, language, thread.isLoading]);
return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
const scrollArtifactToBottom = useCallback(
(behavior: ScrollBehavior = "auto") => {
@@ -576,28 +582,30 @@ export function ArtifactFileDetail({
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
</span>
<ArtifactTitle className="min-w-0">
{useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((artifactPath) => (
<SelectItem key={artifactPath} value={artifactPath}>
{getFileName(artifactPath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<div className="truncate px-3">{getFileName(filepath)}</div>
)}
</ArtifactTitle>
{!hideFileName && (
<ArtifactTitle className="min-w-0">
{useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((artifactPath) => (
<SelectItem key={artifactPath} value={artifactPath}>
{getFileName(artifactPath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<div className="truncate px-3">{getFileName(filepath)}</div>
)}
</ArtifactTitle>
)}
</div>
</div>
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
@@ -634,10 +642,10 @@ export function ArtifactFileDetail({
<EyeIcon />
</ToggleGroupItem>
</ToggleGroup>
{isHtmlStreaming && (
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
正在生成,完成后自动预览
{isPreviewStreaming && (
<div className="animate-[sandbox-preview-badge_1.4s_ease-in-out_infinite] text-muted-foreground bg-muted/80 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]">
<span className="bg-primary mr-2 size-1.5 animate-pulse rounded-full" />
正在生成,完成后自动预览
</div>
)}
</div>
@@ -23,28 +23,34 @@ import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
import { SkillInstallDialog } from "./skill-install-dialog";
export function ArtifactFileList({
className,
files,
threadId,
}: {
className?: string;
files: string[];
threadId: string;
}) {
export function ArtifactFileList({
className,
files,
threadId,
onOpenArtifact,
}: {
className?: string;
files: string[];
threadId: string;
onOpenArtifact?: (filepath: string) => void;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installFile, setInstallFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const handleClick = useCallback(
(filepath: string) => {
selectArtifact(filepath);
setOpen(true);
},
[selectArtifact, setOpen],
);
const handleClick = useCallback(
(filepath: string) => {
if (onOpenArtifact) {
onOpenArtifact(filepath);
return;
}
selectArtifact(filepath);
setOpen(true);
},
[onOpenArtifact, selectArtifact, setOpen],
);
const handleInstallSkill = useCallback(
(e: React.MouseEvent, filepath: string) => {
@@ -101,10 +101,11 @@ export function MessageList({
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
}: {
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onOpenArtifact,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -119,11 +120,12 @@ export function MessageList({
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onOpenArtifact?: (filepath: string) => void;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -488,7 +490,11 @@ export function MessageList({
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
+26 -6
View File
@@ -19,12 +19,32 @@ export async function loadArtifactContent({
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
return { content: text, url };
}
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
if (!response.ok) {
let detail = text.trim();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const parsedDetail =
typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
if (parsedDetail) {
detail = parsedDetail;
}
} catch {
// Keep the raw response text as the diagnostic detail.
}
throw new Error(
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
url: urlString,
+22 -20
View File
@@ -1,12 +1,12 @@
import { getBackendBaseURL } from "../config";
import type { AgentThread } from "../threads";
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
@@ -16,22 +16,24 @@ export function urlOfArtifact({
}) {
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
return thread.values.artifacts ?? [];
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
}
+72 -21
View File
@@ -45,11 +45,45 @@ export type ThreadStreamOptions = {
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
/**
* Extra human-context messages submitted before the visible user message.
* They are persisted with `hide_from_ui` so the model receives rich business
* context without dumping long prompts into the conversation surface.
*/
hiddenContext?:
| string
| {
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}
| Array<{
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}>;
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
};
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
const value = options?.hiddenContext;
if (!value) return [];
const items =
typeof value === "string"
? [{ text: value }]
: Array.isArray(value)
? value
: [value];
return items
.map((item) => ({
...item,
text: item.text.trim(),
}))
.filter((item) => item.text.length > 0);
}
function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) {
@@ -965,27 +999,44 @@ export function useThreadStream({
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
],
},
const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({
type: "human" as const,
...(item.name ? { name: item.name } : {}),
content: [
{
type: "text" as const,
text: item.text,
},
],
additional_kwargs: {
...item.additionalKwargs,
hide_from_ui: true,
},
})),
{
type: "human" as const,
content: [
{
type: "text" as const,
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
];
await thread.submit(
{
messages: submittedMessages,
},
{
threadId: threadId,
streamSubgraphs: true,
@@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
export type PositionBuiltinAgentKind = "summary" | "action_plan";
export interface PositionBuiltinAgentSwitchOption {
kind: PositionBuiltinAgentKind;
label: string;
disabled?: boolean;
disabledReason?: string;
}
interface PositionBuiltinAgentTabsProps {
activeKind: PositionBuiltinAgentKind | null;
options: PositionBuiltinAgentSwitchOption[];
isStreaming?: boolean;
onSelect: (kind: PositionBuiltinAgentKind) => void;
}
/**
* The two closing agents are intentionally exposed as full-name buttons
* instead of another dropdown: users can see the whole progression and jump
* between the two stages with one click.
*/
export function PositionBuiltinAgentTabs({
activeKind,
options,
isStreaming,
onSelect,
}: PositionBuiltinAgentTabsProps) {
if (options.length === 0) return null;
return (
<div
className="ml-auto mr-12 flex shrink-0 items-center gap-1.5"
aria-label="收口智能体切换"
title={isStreaming ? "当前智能体正在生成,完成后可切换" : "切换收口智能体"}
>
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
收口智能体
</span>
{options.map((option) => {
const active = activeKind === option.kind;
const disabled = Boolean(option.disabled || (isStreaming && !active));
return (
<button
key={option.kind}
type="button"
disabled={disabled}
aria-pressed={active}
title={disabled ? option.disabledReason || "当前阶段暂不可进入" : option.label}
onClick={() => onSelect(option.kind)}
className={cn(
"inline-flex h-8 min-w-[126px] items-center justify-center rounded-lg border px-2.5 text-xs font-semibold transition",
active
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]",
disabled &&
"cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 opacity-75 hover:border-slate-200 hover:text-slate-400 dark:border-[#2b8ffe]/20 dark:bg-[#082d5f]/55 dark:text-[#7595be]",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,675 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "lucide-react";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
import {
completePositionActionPlan,
completePositionSummary,
preparePositionActionPlan,
preparePositionSummary,
type PositionRoundtableSession,
} from "@/position-roundtable/api/sessions";
import {
initPositionActionPlanThread,
initSummaryThread,
listSessionArtifacts,
} from "@/roundtable-planning/api/multi-agent";
export type PositionBuiltinAgentKind = "summary" | "action_plan";
interface BuiltinArtifact {
name: string;
path: string;
mimeType: string | null;
}
interface PendingBuiltinSubmission {
threadId: string;
agentId: string;
message: PromptInputMessage;
prompt: string;
sourceRevisions: Record<string, number>;
}
interface PositionBuiltinAgentChatProps {
kind: PositionBuiltinAgentKind;
taskId: string;
session: PositionRoundtableSession | null;
allDelivered: boolean;
summaryDone: boolean;
selectedModel: string;
/** Increments only for an explicit flow/dropdown selection that should begin a fresh first turn. */
autoStartRequestKey?: number;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
onSessionUpdate: (session: PositionRoundtableSession) => void;
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
auto?: boolean;
}) => void;
onThreadSnapshot?: (snapshot: {
threadId: string;
thread: BaseStream<AgentThreadState>;
}) => void;
}
const SUMMARY_AGENT_ID = "roundtable-summary";
const ACTION_PLAN_AGENT_ID = "position-action-planner";
function builtinAgentId(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
}
function builtinAgentFallbackName(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? "方案总结智能体" : "行动规划智能体";
}
function builtinTitle(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? "方案总结" : "行动规划";
}
function builtinDescription(kind: PositionBuiltinAgentKind) {
return kind === "summary"
? "汇总全部岗位交付,形成最终方案建议、关键依据和风险提示。"
: "基于方案总结继续拆解行动步骤、责任建议、时间节奏和子任务清单。";
}
function defaultStartText(kind: PositionBuiltinAgentKind) {
return kind === "summary"
? "请基于当前所有岗位交付,开始生成方案总结。"
: "请基于当前方案总结,开始生成行动规划。";
}
function recordString(record: Record<string, unknown> | null | undefined, key: string): string {
const value = record?.[key];
return typeof value === "string" ? value : "";
}
function snapshotStatus(snapshot: Record<string, unknown> | null | undefined) {
const status = recordString(snapshot, "status");
return status || "pending";
}
function snapshotArtifact(
snapshot: Record<string, unknown> | null | undefined,
key: string,
): Record<string, unknown> | null {
const value = snapshot?.[key];
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function artifactName(artifact: Record<string, unknown> | null | undefined): string {
const name = recordString(artifact, "name").trim();
const path = recordString(artifact, "path").trim();
return name || path.split("/").filter(Boolean).at(-1) || "未命名产物";
}
function artifactPath(artifact: Record<string, unknown> | null | undefined): string {
return recordString(artifact, "path").trim();
}
function artifactMimeType(artifact: Record<string, unknown> | null | undefined): string | null {
return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null;
}
function readBuiltinArtifacts(
snapshot: Record<string, unknown> | null | undefined,
): BuiltinArtifact[] {
const seen = new Set<string>();
return ["primary_artifact", "subtasks_artifact"].flatMap((key) => {
const artifact = snapshotArtifact(snapshot, key);
const path = artifactPath(artifact);
if (!path || seen.has(path)) return [];
seen.add(path);
return [
{
path,
name: artifactName(artifact),
mimeType: artifactMimeType(artifact),
},
];
});
}
function artifactDisplayName(pathOrUrl: string): string {
let path = pathOrUrl;
if (pathOrUrl.startsWith("write-file:")) {
try {
path = decodeURIComponent(new URL(pathOrUrl).pathname);
} catch {
path = pathOrUrl;
}
}
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
}
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString();
}
function latestStreamingWriteFile(
messages: AgentThreadState["messages"],
): { path: string; name: string; mimeType: string | null } | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex] as (typeof messages)[number] & {
id?: string;
tool_calls?: Array<{ id?: string; name?: string; args?: Record<string, unknown> }>;
};
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
const rawPath = toolCall.args?.path;
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
const content = toolCall.args?.content;
if (typeof content !== "string" || content.length === 0) continue;
return {
path: buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id),
name: artifactDisplayName(rawPath),
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
};
}
}
return null;
}
function latestAssistantText(messages: AgentThreadState["messages"]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== "ai") continue;
const text = textOfMessage(message)?.trim();
if (text) return text;
}
return "";
}
function manifestFromArtifacts(
files: Awaited<ReturnType<typeof listSessionArtifacts>>["files"],
): Array<Record<string, unknown>> {
return files.map((file) => ({
name: file.name,
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
}));
}
export function PositionBuiltinAgentChat({
kind,
taskId,
session,
allDelivered,
summaryDone,
selectedModel,
autoStartRequestKey,
builtinAgentOptions,
onSelectBuiltinAgent,
onSessionUpdate,
onConversationStart,
onPreviewArtifact,
onThreadSnapshot,
}: PositionBuiltinAgentChatProps) {
const [settings, setSettings] = useLocalSettings();
const fallbackAgentId = builtinAgentId(kind);
const snapshot =
kind === "summary" ? session?.summarySnapshot : session?.actionPlanSnapshot;
const persistedThreadId =
(kind === "summary" ? session?.summaryThreadId : session?.actionPlanThreadId) ||
recordString(snapshot, "thread_id") ||
null;
const snapshotDone = snapshotStatus(snapshot) === "done";
const snapshotStale = snapshotStatus(snapshot) === "stale";
const [assistantId, setAssistantId] = useState(fallbackAgentId);
const { agent } = useAgent(assistantId);
const agentDisplayName = desensitize(agent?.name || builtinAgentFallbackName(kind));
const [boundThreadId, setBoundThreadId] = useState<string | null>(persistedThreadId);
const boundThreadIdRef = useRef<string | null>(persistedThreadId);
const draftThreadIdRef = useRef(uuid());
const pendingRunRef = useRef<{
threadId: string;
sourceRevisions: Record<string, number>;
model?: string;
} | null>(null);
const [pendingSubmission, setPendingSubmission] =
useState<PendingBuiltinSubmission | null>(null);
const pendingSubmissionRef = useRef(false);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
useEffect(() => {
setAssistantId(fallbackAgentId);
setBoundThreadId(persistedThreadId);
boundThreadIdRef.current = persistedThreadId;
draftThreadIdRef.current = uuid();
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
setPendingSubmission(null);
autoPreviewedArtifactKeyRef.current = null;
handledAutoStartRequestRef.current = undefined;
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
const canWrite =
Boolean(session) &&
session?.status !== "archived" &&
allDelivered &&
(kind === "summary" || summaryDone);
const readOnlyHint =
session?.status === "archived"
? "该会话已归档,仅可查看历史对话和产物"
: !allDelivered
? "所有岗位交付完成后,才能进入收口智能体对话"
: kind === "action_plan" && !summaryDone
? "请先进入方案总结智能体并完成有效总结,再继续行动规划"
: "";
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId,
context: {
...settings.context,
agent_id: assistantId,
...(selectedModel ? { model: selectedModel } : {}),
},
threadMetadata: {
thread_type: "position_roundtable_builtin",
taskId,
position_roundtable_task_id: taskId,
position_session_id: session?.id,
position_builtin_kind: kind,
agent_id: assistantId,
},
onStart: (threadId) => {
boundThreadIdRef.current = threadId;
setBoundThreadId(threadId);
},
onFinish: (state) => {
const pending = pendingRunRef.current;
if (!pending || !session) return;
const latestAnswer = latestAssistantText(state.messages ?? []);
if (!latestAnswer) return;
pendingRunRef.current = null;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
try {
const artifacts = await listSessionArtifacts(pending.threadId);
artifactManifest = manifestFromArtifacts(artifacts.files);
} catch {
// Keep the text turn usable even if the artifact endpoint is briefly
// unavailable; the completion API will surface a clearer validation
// message if an output file is genuinely missing.
}
try {
const next =
kind === "summary"
? await completePositionSummary(session.id, {
threadId: pending.threadId,
content: latestAnswer,
artifactManifest,
sourceRevisions: pending.sourceRevisions,
model: pending.model,
})
: await completePositionActionPlan(session.id, {
threadId: pending.threadId,
content: latestAnswer,
artifactManifest,
sourceRevisions: pending.sourceRevisions,
model: pending.model,
});
onSessionUpdate(next);
} catch (error) {
toast.error(
`${builtinTitle(kind)}结果归档失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
})();
},
});
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const displayThreadId = boundThreadId ?? `position-builtin-${kind}-${session?.id ?? "draft"}`;
const threadContextValue = useMemo(() => ({ thread }), [thread]);
const isSubmitting = pendingSubmission !== null;
const status = thread.error ? "error" : thread.isLoading || isSubmitting ? "streaming" : "ready";
const persistedArtifacts = useMemo(() => readBuiltinArtifacts(snapshot), [snapshot]);
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
boundThreadIdRef.current = actualThreadId;
setBoundThreadId(actualThreadId);
}, [actualThreadId]);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]);
const streamingArtifact = useMemo(
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
[thread.isLoading, thread.messages],
);
useEffect(() => {
if (!thread.isLoading || !streamingArtifact) return;
const artifactKey = `${activeThreadId}:${streamingArtifact.path}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId: activeThreadId,
path: streamingArtifact.path,
name: streamingArtifact.name,
mimeType: streamingArtifact.mimeType,
auto: true,
});
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
const ensureBuiltinThread = useCallback(
async (preparedThreadId: string | null, preparedAgentId: string) => {
if (preparedThreadId) {
return { agentName: preparedAgentId, threadId: preparedThreadId };
}
if (boundThreadIdRef.current) {
return { agentName: preparedAgentId, threadId: boundThreadIdRef.current };
}
return kind === "summary" ? initSummaryThread() : initPositionActionPlanThread();
},
[kind],
);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!session) {
toast.warning("请先启动业务链");
return;
}
if (!canWrite) {
toast.info(readOnlyHint || "当前阶段暂不可对话");
return;
}
if (pendingSubmissionRef.current || thread.isLoading) {
return;
}
pendingSubmissionRef.current = true;
const visibleText = message.text.trim() || defaultStartText(kind);
const followUp = kind === "summary" && !snapshotDone ? "" : visibleText;
try {
onConversationStart?.();
const preparation =
kind === "summary"
? await preparePositionSummary(session.id, followUp)
: await preparePositionActionPlan(session.id, followUp);
setAssistantId(preparation.agentId);
const builtinThread = await ensureBuiltinThread(preparation.threadId, preparation.agentId);
boundThreadIdRef.current = builtinThread.threadId;
setBoundThreadId(builtinThread.threadId);
pendingRunRef.current = {
threadId: builtinThread.threadId,
sourceRevisions: preparation.sourceRevisions,
model: selectedModel || undefined,
};
// `useThreadStream` is still attached to the previous (or empty)
// thread during this render. Queue the first turn and dispatch it
// only after the hook has rebound to the prepared thread; otherwise
// the UI can show the optimistic user bubble without opening a run.
setPendingSubmission({
threadId: builtinThread.threadId,
agentId: preparation.agentId,
message: { ...message, text: visibleText },
prompt: preparation.prompt,
sourceRevisions: preparation.sourceRevisions,
});
} catch (error) {
pendingRunRef.current = null;
pendingSubmissionRef.current = false;
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
}
},
[
canWrite,
ensureBuiltinThread,
kind,
onConversationStart,
readOnlyHint,
selectedModel,
session,
snapshotDone,
thread.isLoading,
],
);
useEffect(() => {
if (
!pendingSubmission ||
thread.isLoading ||
actualThreadId !== pendingSubmission.threadId ||
assistantId !== pendingSubmission.agentId
) {
return;
}
const submission = pendingSubmission;
setPendingSubmission(null);
void (async () => {
try {
await sendMessage(
submission.threadId,
submission.message,
{
agent_id: submission.agentId,
...(selectedModel ? { model: selectedModel } : {}),
},
{
hiddenContext: {
text: submission.prompt,
name: "position_builtin_context",
additionalKwargs: {
position_builtin_kind: kind,
position_session_id: session?.id,
source_revisions: submission.sourceRevisions,
},
},
},
);
} catch (error) {
pendingRunRef.current = null;
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
pendingSubmissionRef.current = false;
}
})();
}, [actualThreadId, assistantId, kind, pendingSubmission, selectedModel, sendMessage, session?.id, thread.isLoading]);
const handleStop = useCallback(() => {
pendingSubmissionRef.current = false;
pendingRunRef.current = null;
setPendingSubmission(null);
void thread.stop();
}, [thread]);
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: artifactDisplayName(filepath),
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
useEffect(() => {
if (
!autoStartRequestKey ||
handledAutoStartRequestRef.current === autoStartRequestKey ||
!canWrite ||
snapshotDone ||
thread.isLoading
) {
return;
}
handledAutoStartRequestRef.current = autoStartRequestKey;
void handleSubmit({ text: defaultStartText(kind), files: [] });
}, [autoStartRequestKey, canWrite, handleSubmit, kind, snapshotDone, thread.isLoading]);
const Icon = kind === "summary" ? Sparkles : Target;
const snapshotLabel = snapshotDone ? "已完成" : snapshotStale ? "需重做" : "待对话";
return (
<ThreadContext.Provider value={threadContextValue}>
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex min-h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-2 dark:border-[#2b8ffe]/20">
<span className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{canWrite ? <Icon className="size-4" /> : <LockKeyhole className="size-3.5" />}
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{agentDisplayName}
</h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || builtinDescription(kind)}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold",
snapshotDone
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: snapshotStale
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]",
)}
>
{snapshotLabel}
</span>
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={kind}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
</header>
{persistedArtifacts.length > 0 && (
<section className="shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
<FileText className="size-3.5 text-primary" />
阶段产物
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{persistedArtifacts.length}
</span>
</div>
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
{persistedArtifacts.map((artifact) => (
<button
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
onClick={() => {
if (boundThreadIdRef.current) {
onPreviewArtifact?.({
threadId: boundThreadIdRef.current,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
<FileText className="size-3 shrink-0" />
<span className="max-w-48 truncate">{artifact.name}</span>
</button>
))}
</div>
</section>
)}
{boundThreadId ? (
<MessageList
className="min-h-0 flex-1"
threadId={boundThreadId}
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Bot className="size-5" />
</span>
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
{canWrite ? `进入${builtinTitle(kind)}智能体对话` : "当前阶段尚未解锁"}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
{canWrite
? "你可以直接发送开始生成,也可以先补充口径、约束或输出格式要求。系统会自动带入全部有效岗位产物。"
: readOnlyHint}
</p>
{canWrite && (
<button
type="button"
disabled={isSubmitting}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleSubmit({ text: defaultStartText(kind), files: [] })}
>
<CheckCircle2 className="size-3.5" />
{kind === "summary" ? "开始方案总结" : "开始行动规划"}
</button>
)}
</div>
)}
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<InputBox
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
assistantId={assistantId}
status={status}
context={settings.context}
disabled={!canWrite || isSubmitting}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
onSubmit={(message) => void handleSubmit(message)}
onStop={handleStop}
/>
</div>
</div>
</ThreadContext.Provider>
);
}
@@ -7,7 +7,6 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
@@ -22,6 +21,11 @@ import {
} from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { desensitize } from "@/lib/desensitize";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentKind,
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
interface PositionNodeSwitchOption {
node: PositionRoundtableNode;
@@ -39,6 +43,8 @@ interface PositionNodeChatProps {
agentName?: string | null;
positionNodeOptions?: PositionNodeSwitchOption[];
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void;
@@ -108,10 +114,7 @@ function hasAnyArtifact(manifest: Array<Record<string, unknown>>): boolean {
}
function nodeSwitchStatusLabel(node: PositionRoundtableNode): string {
if (node.invalidatedBy || node.status === "stale") {
return "需重做";
}
if (node.invalidatedBy || node.status === "stale") return "需重做";
switch (node.status) {
case "running":
return "进行中";
@@ -193,6 +196,8 @@ export function PositionNodeChat({
agentName,
positionNodeOptions,
onSelectPositionNode,
builtinAgentOptions,
onSelectBuiltinAgent,
onNodeUpdate,
onConversationStart,
onPreviewArtifact,
@@ -208,16 +213,10 @@ export function PositionNodeChat({
: [{ node, agentName }],
[agentName, node, positionNodeOptions],
);
const artifactsCtx = useArtifactsOptional();
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
// The parent opens the outer sandbox by updating the shared
// ArtifactsProvider. Forward that provider selection only once per file and
// thread: opening the sandbox itself changes the provider value, which used
// to re-run this effect and re-open the same sandbox indefinitely.
const forwardedArtifactKeyRef = useRef<string | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite =
@@ -227,31 +226,6 @@ export function PositionNodeChat({
node.status !== "error" &&
!node.invalidatedBy;
useEffect(() => {
const selectedArtifact = artifactsCtx?.open
? artifactsCtx.selectedArtifact
: null;
if (!selectedArtifact) {
forwardedArtifactKeyRef.current = null;
return;
}
const threadId =
boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current;
const artifactKey = `${threadId}:${selectedArtifact}`;
if (forwardedArtifactKeyRef.current === artifactKey) return;
forwardedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId,
path: selectedArtifact,
name: selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: selectedArtifact.toLowerCase().endsWith(".md")
? "text/markdown"
: null,
});
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
useEffect(() => {
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
@@ -331,6 +305,17 @@ export function PositionNodeChat({
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: filepath.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
const hasMultipleSwitchOptions = switchOptions.length > 1;
const switchSelectOptions = useMemo(
() =>
@@ -341,14 +326,15 @@ export function PositionNodeChat({
option.agentName || optionNode.agentId || `智能体 ${index + 1}`,
);
const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : "";
return {
label: `${optionName} · ${statusLabel}${artifactText}`,
value: optionNode.nodeKey,
};
return { label: `${optionName} · ${statusLabel}${artifactText}`, value: optionNode.nodeKey };
}),
[switchOptions],
);
const switchSelectStyle = useMemo(() => ({ width: 380, maxWidth: "34vw" }), []);
const switchSelectPopupProps = useMemo(
() => ({ overlayInnerStyle: { minWidth: 380 } }),
[],
);
const handleSwitchNode = useCallback(
(targetNode: PositionRoundtableNode) => {
if (targetNode.nodeKey === node.nodeKey) return;
@@ -360,7 +346,13 @@ export function PositionNodeChat({
},
[node.nodeKey, onSelectPositionNode, thread.isLoading],
);
const handleSwitchSelectChange = useCallback(
(value: unknown) => {
const nextNode = switchOptions.find((option) => option.node.nodeKey === String(value))?.node;
if (nextNode) handleSwitchNode(nextNode);
},
[handleSwitchNode, switchOptions],
);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]);
@@ -465,30 +457,40 @@ export function PositionNodeChat({
</div>
{hasMultipleSwitchOptions && (
<div
className="ml-auto mr-12 flex min-w-0 shrink-0 items-center gap-2"
className="ml-auto flex min-w-0 shrink-0 items-center gap-2"
title={thread.isLoading ? "当前智能体正在生成,完成后可切换" : "切换同岗位智能体"}
>
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground xl:inline">
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
同岗位智能体
</span>
<TSelect
aria-label="同岗位智能体切换"
className="shrink-0 [&_.t-input]:!rounded-xl [&_.t-input]:!border-slate-200 [&_.t-input]:!bg-white [&_.t-input]:!text-xs [&_.t-input]:!font-semibold [&_.t-input]:!text-slate-700 [&_.t-input]:!shadow-sm dark:[&_.t-input]:!border-[#2b8ffe]/35 dark:[&_.t-input]:!bg-[#082d5f] dark:[&_.t-input]:!text-[#e8edf5]"
style={{ width: 380, maxWidth: "34vw" }}
value={node.nodeKey}
options={switchSelectOptions}
onChange={(value) => {
const nextNode = switchOptions.find(
(option) => option.node.nodeKey === String(value),
)?.node;
if (nextNode) {
handleSwitchNode(nextNode);
}
}}
popupProps={{ overlayInnerStyle: { minWidth: 380 } }}
/>
{thread.isLoading ? (
<div
className="flex h-9 shrink-0 items-center rounded-xl border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]"
style={switchSelectStyle}
>
{agentDisplayName} · 进行中
</div>
) : (
<TSelect
aria-label="同岗位智能体切换"
className="shrink-0 [&_.t-input]:!rounded-xl [&_.t-input]:!border-slate-200 [&_.t-input]:!bg-white [&_.t-input]:!text-xs [&_.t-input]:!font-semibold [&_.t-input]:!text-slate-700 [&_.t-input]:!shadow-sm dark:[&_.t-input]:!border-[#2b8ffe]/35 dark:[&_.t-input]:!bg-[#082d5f] dark:[&_.t-input]:!text-[#e8edf5]"
style={switchSelectStyle}
value={node.nodeKey}
options={switchSelectOptions}
onChange={handleSwitchSelectChange}
popupProps={switchSelectPopupProps}
/>
)}
</div>
)}
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={null}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
</header>
{nodeArtifacts.length > 0 && (
@@ -534,6 +536,7 @@ export function PositionNodeChat({
paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
File diff suppressed because it is too large. Load diff
@@ -90,6 +90,10 @@ export interface Step1PanelProps {
onEnterRoundtable: () => void;
/** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */
enterButtonLabel?: string;
/** 用于在意图确认后承接同一工作流的下一步动作。 */
footerAction?: React.ReactNode;
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
hideEnterRoundtable?: boolean;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarifications: (
@@ -116,6 +120,8 @@ export function Step1Panel(props: Step1PanelProps) {
onFollowUp,
onEnterRoundtable,
enterButtonLabel = "进入多智能体研讨 >",
footerAction,
hideEnterRoundtable = false,
onManualSave,
onTriggerToast,
submitIntentClarifications,
@@ -330,20 +336,24 @@ export function Step1Panel(props: Step1PanelProps) {
跳过此项
</button>
<button
id="enter-roundtable-btn"
onClick={onEnterRoundtable}
disabled={!intentReady}
title={intentReady ? undefined : "请先完成任务意图澄清"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
intentReady
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
{enterButtonLabel}
</button>
{footerAction ??
(!hideEnterRoundtable && (
<button
id="enter-roundtable-btn"
type="button"
onClick={onEnterRoundtable}
disabled={!intentReady}
title={intentReady ? undefined : "请先完成任务意图澄清"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition flex items-center gap-1.5 active:scale-95 shadow-md shadow-emerald-500/10 ${
intentReady
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
{enterButtonLabel}
</button>
))}
</div>
<button