From 13bfa719ee31e61bca0c4f27cbf154edf01f4a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 22 Jul 2026 22:22:38 +0800 Subject: [PATCH] feat: refine position roundtable workflow --- frontend-web/src/components/ui/dialog.tsx | 20 +- .../artifacts/artifact-file-detail.tsx | 130 ++- .../artifacts/artifact-file-list.tsx | 38 +- .../workspace/messages/message-list.tsx | 24 +- frontend-web/src/core/artifacts/loader.ts | 32 +- frontend-web/src/core/artifacts/utils.ts | 42 +- frontend-web/src/core/threads/hooks.ts | 93 +- .../components/PositionAgentSwitcher.tsx | 67 ++ .../components/PositionBuiltinAgentChat.tsx | 675 +++++++++++ .../components/PositionNodeChat.tsx | 123 +- .../pages/PositionRoundtablePage.tsx | 1014 +++++++++++++---- .../components/Step1Panel.tsx | 38 +- 12 files changed, 1838 insertions(+), 458 deletions(-) create mode 100644 frontend-web/src/position-roundtable/components/PositionAgentSwitcher.tsx create mode 100644 frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx diff --git a/frontend-web/src/components/ui/dialog.tsx b/frontend-web/src/components/ui/dialog.tsx index a95a00ae..3474709e 100644 --- a/frontend-web/src/components/ui/dialog.tsx +++ b/frontend-web/src/components/ui/dialog.tsx @@ -71,15 +71,17 @@ const DialogOverlay = React.forwardRef< DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - showCloseButton?: boolean; - } ->(({ className, children, showCloseButton = true, ...props }, ref) => { - return ( - - +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + 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 ( + + 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({ {fileTypeLabel} - - {useWriteFileStreaming ? ( -
{getFileName(filepath)}
- ) : hasMultipleArtifacts ? ( - - ) : ( -
{getFileName(filepath)}
- )} -
+ {!hideFileName && ( + + {useWriteFileStreaming ? ( +
{getFileName(filepath)}
+ ) : hasMultipleArtifacts ? ( + + ) : ( +
{getFileName(filepath)}
+ )} +
+ )}
@@ -634,10 +642,10 @@ export function ArtifactFileDetail({ - {isHtmlStreaming && ( -
- - 正在生成,完成后自动预览 + {isPreviewStreaming && ( +
+ + 正在生成,完成后自动预览
)}
diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index fc418584..ebeb73f6 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -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(null); const [downloadingFile, setDownloadingFile] = useState(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) => { diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index aa833058..a117d4fa 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -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; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; - onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; - onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; + onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; + onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; /** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */ - onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise; -}) { + onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise; + 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" /> )} - + ; + /** + * 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; + } + | Array<{ + text: string; + name?: string; + additionalKwargs?: Record; + }>; }; type ThreadTitleRefreshState = Pick & { 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, diff --git a/frontend-web/src/position-roundtable/components/PositionAgentSwitcher.tsx b/frontend-web/src/position-roundtable/components/PositionAgentSwitcher.tsx new file mode 100644 index 00000000..543a5584 --- /dev/null +++ b/frontend-web/src/position-roundtable/components/PositionAgentSwitcher.tsx @@ -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 ( +
+ + 收口智能体 + + {options.map((option) => { + const active = activeKind === option.kind; + const disabled = Boolean(option.disabled || (isStreaming && !active)); + return ( + + ); + })} +
+ ); +} diff --git a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx new file mode 100644 index 00000000..843c7d35 --- /dev/null +++ b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx @@ -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; +} + +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; + }) => 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 | null | undefined, key: string): string { + const value = record?.[key]; + return typeof value === "string" ? value : ""; +} + +function snapshotStatus(snapshot: Record | null | undefined) { + const status = recordString(snapshot, "status"); + return status || "pending"; +} + +function snapshotArtifact( + snapshot: Record | null | undefined, + key: string, +): Record | null { + const value = snapshot?.[key]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function artifactName(artifact: Record | 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 | null | undefined): string { + return recordString(artifact, "path").trim(); +} + +function artifactMimeType(artifact: Record | null | undefined): string | null { + return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null; +} + +function readBuiltinArtifacts( + snapshot: Record | null | undefined, +): BuiltinArtifact[] { + const seen = new Set(); + 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 }>; + }; + 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>["files"], +): Array> { + 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(persistedThreadId); + const boundThreadIdRef = useRef(persistedThreadId); + const draftThreadIdRef = useRef(uuid()); + const pendingRunRef = useRef<{ + threadId: string; + sourceRevisions: Record; + model?: string; + } | null>(null); + const [pendingSubmission, setPendingSubmission] = + useState(null); + const pendingSubmissionRef = useRef(false); + const autoPreviewedArtifactKeyRef = useRef(null); + const handledAutoStartRequestRef = useRef(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> = []; + 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 ( + +
+
+ + {canWrite ? : } + +
+

+ {agentDisplayName} +

+

+ {readOnlyHint || builtinDescription(kind)} +

+
+ + {snapshotLabel} + + {builtinAgentOptions && onSelectBuiltinAgent && ( + + )} +
+ + {persistedArtifacts.length > 0 && ( +
+
+ + 阶段产物 + + {persistedArtifacts.length} + +
+
+ {persistedArtifacts.map((artifact) => ( + + ))} +
+
+ )} + + {boundThreadId ? ( + void handleSubmit({ text, files: [] })} + onOpenArtifact={handleOpenMessageArtifact} + /> + ) : ( +
+ + + +

+ {canWrite ? `进入${builtinTitle(kind)}智能体对话` : "当前阶段尚未解锁"} +

+

+ {canWrite + ? "你可以直接发送开始生成,也可以先补充口径、约束或输出格式要求。系统会自动带入全部有效岗位产物。" + : readOnlyHint} +

+ {canWrite && ( + + )} +
+ )} + +
+ setSettings("context", context)} + onSubmit={(message) => void handleSubmit(message)} + onStop={handleStop} + /> +
+
+
+ ); +} diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index 9428c91a..83af5f1c 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -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>): 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(node.threadId); const boundThreadIdRef = useRef(node.threadId); const draftThreadIdRef = useRef(node.threadId ?? uuid()); const upstreamRevisionRef = useRef | 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(null); const autoPreviewedArtifactKeyRef = useRef(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({
{hasMultipleSwitchOptions && (
- + 同岗位智能体 - { - const nextNode = switchOptions.find( - (option) => option.node.nodeKey === String(value), - )?.node; - if (nextNode) { - handleSwitchNode(nextNode); - } - }} - popupProps={{ overlayInnerStyle: { minWidth: 380 } }} - /> + {thread.isLoading ? ( +
+ {agentDisplayName} · 进行中 +
+ ) : ( + + )}
)} + {builtinAgentOptions && onSelectBuiltinAgent && ( + + )} {nodeArtifacts.length > 0 && ( @@ -534,6 +536,7 @@ export function PositionNodeChat({ paddingBottom={24} scrollButtonClassName="bottom-6" onEditResubmit={(text) => void handleSubmit({ text, files: [] })} + onOpenArtifact={handleOpenMessageArtifact} /> ) : (
diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 3216bcec..dd2a0c31 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -70,6 +70,11 @@ import { useStep1Intent, type InteractiveMessage } from "@/roundtable-planning/h import type { IntentSummary } from "@/roundtable-planning/api/intent"; import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants"; +import { + PositionBuiltinAgentChat, + type PositionBuiltinAgentKind, +} from "@/position-roundtable/components/PositionBuiltinAgentChat"; +import type { PositionBuiltinAgentSwitchOption } from "@/position-roundtable/components/PositionAgentSwitcher"; import { DEFAULT_POSITION_ID, POSITION_OPTIONS, @@ -119,6 +124,7 @@ type PositionArtifactPreview = { mimeType: string | null; auto?: boolean; }; +type ClosureStageKind = PositionBuiltinAgentKind; type IntentEditDraft = { objective: string; constraints: string; @@ -130,6 +136,22 @@ type SandboxThreadSnapshot = { signature: string; }; +function normalizePreviewArtifactPath(path: string): string { + let normalized = path.trim(); + if (normalized.startsWith("write-file:")) { + try { + normalized = decodeURIComponent(new URL(normalized).pathname || normalized); + } catch { + // Keep the original value if the virtual URL is malformed. + } + } + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function sandboxCloseKey(artifact: Pick): string { + return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`; +} + function sandboxThreadSignature(thread: BaseStream): string { const messages = thread.messages ?? []; for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { @@ -445,10 +467,11 @@ export default function PositionRoundtablePage({ const [sandboxArtifact, setSandboxArtifact] = useState(null); const sandboxArtifactRef = useRef(null); const closedSandboxKeyRef = useRef(null); + const suppressedAutoPreviewThreadIdsRef = useRef>(new Set()); const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState(null); const latestNodeThreadSnapshotRef = useRef(null); const pendingSandboxThreadSnapshotRef = useRef(null); - const sandboxSnapshotFrameRef = useRef(null); + const sandboxSnapshotTimerRef = useRef(null); const [flowDetailItem, setFlowDetailItem] = useState(null); const [taskEditorOpen, setTaskEditorOpen] = useState(false); const [taskDraft, setTaskDraft] = useState(fallbackTask); @@ -462,6 +485,18 @@ export default function PositionRoundtablePage({ const [intentSaving, setIntentSaving] = useState(false); const [intentCompletedAt, setIntentCompletedAt] = useState(""); const activePosition = POSITIONS.find((item) => item.id === positionId)!; + const currentPositionOptions = useMemo( + () => + POSITIONS.map((position) => ({ + label: position.name, + value: position.id, + })), + [], + ); + const currentPositionSelectStyle = useMemo( + () => ({ width: 210, minWidth: 210, flex: "0 0 210px" }), + [], + ); useEffect(() => { artifactsCtxRef.current = artifactsCtx; }, [artifactsCtx]); @@ -469,7 +504,7 @@ export default function PositionRoundtablePage({ sandboxArtifactRef.current = sandboxArtifact; }, [sandboxArtifact]); const flushSandboxThreadSnapshot = useCallback(() => { - sandboxSnapshotFrameRef.current = null; + sandboxSnapshotTimerRef.current = null; const pending = pendingSandboxThreadSnapshotRef.current; pendingSandboxThreadSnapshotRef.current = null; if (!pending) return; @@ -486,19 +521,19 @@ export default function PositionRoundtablePage({ const queueSandboxThreadSnapshot = useCallback( (snapshot: SandboxThreadSnapshot) => { pendingSandboxThreadSnapshotRef.current = snapshot; - if (sandboxSnapshotFrameRef.current !== null) return; + if (sandboxSnapshotTimerRef.current !== null) return; if (typeof window === "undefined") { flushSandboxThreadSnapshot(); return; } - sandboxSnapshotFrameRef.current = window.requestAnimationFrame(flushSandboxThreadSnapshot); + sandboxSnapshotTimerRef.current = window.setTimeout(flushSandboxThreadSnapshot, 160); }, [flushSandboxThreadSnapshot], ); useEffect( () => () => { - if (sandboxSnapshotFrameRef.current !== null) { - window.cancelAnimationFrame(sandboxSnapshotFrameRef.current); + if (sandboxSnapshotTimerRef.current !== null) { + window.clearTimeout(sandboxSnapshotTimerRef.current); } }, [], @@ -713,6 +748,41 @@ export default function PositionRoundtablePage({ }, [positionSession?.chainSnapshot, positionSession?.status, selectedChain]); const isIntelligenceDeliveryOverview = activePosition.isIntentPosition && positionSession !== null && positionSession.status !== "intent_pending"; + const allPositionDeliveriesDone = useMemo( + () => allExecutableNodesDone(positionSession?.nodes ?? []), + [positionSession?.nodes], + ); + const summaryDone = snapshotStatus(positionSession?.summarySnapshot) === "done"; + const actionPlanDone = snapshotStatus(positionSession?.actionPlanSnapshot) === "done"; + const [activeBuiltinKind, setActiveBuiltinKind] = useState(null); + const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0); + const builtinAgentOptions = useMemo( + () => [ + { + kind: "summary", + label: "方案总结智能体", + disabled: !allPositionDeliveriesDone || isArchivedSession, + disabledReason: isArchivedSession + ? "该会话已归档,仅可查看历史记录" + : "完成全部岗位交付后可进入方案总结", + }, + { + kind: "action_plan", + label: "行动规划智能体", + disabled: !allPositionDeliveriesDone || !summaryDone || isArchivedSession, + disabledReason: isArchivedSession + ? "该会话已归档,仅可查看历史记录" + : !allPositionDeliveriesDone + ? "完成全部岗位交付后可进入行动规划" + : "完成方案总结后可进入行动规划", + }, + ], + [allPositionDeliveriesDone, isArchivedSession, summaryDone], + ); + useEffect(() => { + setActiveBuiltinKind(null); + setBuiltinAutoStartRequestKey(0); + }, [positionSession?.id]); const assignedAgentIds = useMemo( () => new Set( @@ -893,6 +963,7 @@ export default function PositionRoundtablePage({ }, [frozenIntent, intent, positionSession?.id, positionSession?.intentThreadId]); const handleSelectAgent = useCallback( (agentId: string) => { + setActiveBuiltinKind(null); setSelectedAgentId(agentId); const node = positionSession?.nodes?.find( (item) => item.agentId === agentId && item.positionId === positionId, @@ -1061,6 +1132,7 @@ export default function PositionRoundtablePage({ ctx?.setOpen(false); } closedSandboxKeyRef.current = null; + suppressedAutoPreviewThreadIdsRef.current.clear(); setSandboxArtifact(null); setSandboxThreadSnapshot(null); intent.resetIntent(); @@ -1070,6 +1142,8 @@ export default function PositionRoundtablePage({ setSessionDiscoveryDone(true); setSelectedAgentId(null); setActiveNodeKey(null); + setActiveBuiltinKind(null); + setBuiltinAutoStartRequestKey(0); setIntentCompletedAt(""); const next = new URLSearchParams(searchParams); next.set("taskId", task.id); @@ -1097,9 +1171,16 @@ export default function PositionRoundtablePage({ return; } - const artifactKey = `${artifact.threadId}:${artifact.path}`; - if (artifact.auto && closedSandboxKeyRef.current === artifactKey) { - return; + const artifactKey = sandboxCloseKey(artifact); + if (artifact.auto) { + if ( + closedSandboxKeyRef.current === artifactKey || + suppressedAutoPreviewThreadIdsRef.current.has(artifact.threadId) + ) { + return; + } + } else { + suppressedAutoPreviewThreadIdsRef.current.delete(artifact.threadId); } closedSandboxKeyRef.current = null; @@ -1112,14 +1193,6 @@ export default function PositionRoundtablePage({ current?.threadId === artifact.threadId ? current : null, ); - const ctx = artifactsCtxRef.current; - if (ctx?.selectedArtifact !== artifact.path) { - ctx?.setArtifacts([artifact.path]); - ctx?.select(artifact.path, artifact.auto ?? false); - } - if (!ctx?.open) { - ctx?.setOpen(true); - } const latestSnapshot = latestNodeThreadSnapshotRef.current; if (latestSnapshot?.threadId === artifact.threadId) { queueSandboxThreadSnapshot(latestSnapshot); @@ -1143,6 +1216,10 @@ export default function PositionRoundtablePage({ [handlePreviewArtifact], ); const openIntentFlowDetail = useCallback(() => { + if (!(frozenIntent ?? intent.intentReady)) { + toast.info("任务意图还在梳理中,完成确认后就能查看完整的意图说明。"); + return; + } setFlowDetailItem( buildIntentResultItem( frozenIntent ?? intent.intentReady, @@ -1152,30 +1229,22 @@ export default function PositionRoundtablePage({ }, [frozenIntent, intent.intentReady, intentCompletedAt, positionSession?.updatedAt]); const openNodeFlowDetail = useCallback( (node: PositionRoundtableNode) => { - setActiveNodeKey(node.nodeKey); - setSelectedAgentId(node.agentId); - setFlowDetailItem(buildNodeResultItem(node, activeChain)); + const item = buildNodeResultItem(node, activeChain); + if (!canOpenDeliverable(item)) { + toast.info(deliverableUnavailableMessage(item)); + return; + } + setFlowDetailItem(item); }, [activeChain], ); const openSeatFlowDetail = useCallback( - (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => { - setFlowDetailItem(buildSeatPendingResultItem(seat, stageIndex, seatIndex)); + (seat: BusinessChain["seats"][number]) => { + const agentName = desensitize(seat.name?.trim() || seat.agent_id); + toast.info(`「${agentName}」已排入业务链,轮到该阶段后就可以开始问答并生成交付。`); }, [], ); - const openBuiltinFlowDetail = useCallback( - (kind: "summary" | "action_plan") => { - setFlowDetailItem( - buildBuiltinResultItem( - kind, - positionSession, - allExecutableNodesDone(positionSession?.nodes ?? []), - ), - ); - }, - [positionSession], - ); const handleStartedIntentSend = useCallback(() => { toast.info("业务链已启动,任务意图已冻结;如需重新识别请新建会话"); }, []); @@ -1227,24 +1296,60 @@ export default function PositionRoundtablePage({ const closeSandbox = useCallback(() => { const current = sandboxArtifactRef.current; if (current) { - closedSandboxKeyRef.current = `${current.threadId}:${current.path}`; + closedSandboxKeyRef.current = sandboxCloseKey(current); + suppressedAutoPreviewThreadIdsRef.current.add(current.threadId); } const ctx = artifactsCtxRef.current; - if (current || ctx?.open) { + if (ctx?.open) { ctx?.setOpen(false); } pendingSandboxThreadSnapshotRef.current = null; setSandboxArtifact(null); setSandboxThreadSnapshot(null); }, []); + const handleSelectBuiltinStage = useCallback( + (kind: ClosureStageKind) => { + if (!allPositionDeliveriesDone) { + toast.info("岗位产物还在汇集中;全部岗位完成 Markdown 交付后,即可开启方案总结。"); + return; + } + if (kind === "action_plan" && !summaryDone) { + toast.info("方案总结完成后,行动规划会自动解锁。先完成方案总结,我们再一起进入下一步。"); + return; + } + closeSandbox(); + setSelectedAgentId(null); + setActiveNodeKey(null); + setRightPanelOpen(false); + setActiveBuiltinKind(kind); + setBuiltinAutoStartRequestKey((current) => current + 1); + }, + [allPositionDeliveriesDone, closeSandbox, summaryDone], + ); const handleSelectPositionNode = useCallback( (nextNode: PositionRoundtableNode) => { closeSandbox(); + setActiveBuiltinKind(null); setActiveNodeKey(nextNode.nodeKey); setSelectedAgentId(nextNode.agentId); }, [closeSandbox], ); + const handlePositionChange = useCallback( + (value: unknown) => { + // 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表, + // 其他岗位改用按需打开的抽屉,避免一进岗位就挤占对话空间。 + const nextPositionId = String(value) as PositionId; + // 中间区由 activeNode 的兜底规则自动定位到首个流程节点。 + closeSandbox(); + setPositionId(nextPositionId); + setActiveBuiltinKind(null); + setSelectedAgentId(null); + setActiveNodeKey(null); + setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID); + }, + [closeSandbox], + ); const renderIntentChatPanel = (chainStarted: boolean) => (
{intent.intentInitError && ( @@ -1269,13 +1374,6 @@ export default function PositionRoundtablePage({
} - headerRightSlot={ - chainStarted ? ( - - 业务链已启动 - - ) : undefined - } interactiveMessages={intent.interactiveMessages} intentThreadId={intent.intentThreadId} intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady} @@ -1296,12 +1394,33 @@ export default function PositionRoundtablePage({ onFollowUp={chainStarted ? handleStartedIntentSend : handleFollowUp} onEnterRoundtable={chainStarted ? handleStartedIntentSend : () => void handleActivateChain()} enterButtonLabel={ - chainStarted - ? "业务链已启动" - : startingChain + startingChain ? "正在启动业务链…" : "确认意图并启动业务链 >" } + hideEnterRoundtable={chainStarted} + footerAction={ + !isArchivedSession && chainStarted && allPositionDeliveriesDone && !actionPlanDone ? ( + + ) : undefined + } onManualSave={() => undefined} onTriggerToast={triggerToast} submitIntentClarifications={intent.submitIntentClarifications} @@ -1379,23 +1498,10 @@ export default function PositionRoundtablePage({ ({ - label: position.name, - value: position.id, - }))} - onChange={(value) => { - // 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表, - // 其他岗位改用按需打开的抽屉,避免一进岗位就挤占对话空间。 - const nextPositionId = String(value) as PositionId; - // 中间区由 activeNode 的兜底规则自动定位到首个流程节点。 - closeSandbox(); - setPositionId(nextPositionId); - setSelectedAgentId(null); - setActiveNodeKey(null); - setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID); - }} + options={currentPositionOptions} + onChange={handlePositionChange} /> {compactChrome && (
@@ -1442,15 +1548,8 @@ export default function PositionRoundtablePage({ nodes={positionSession?.nodes ?? []} session={positionSession} activeNodeKey={activeNodeKey} - onSelectNode={(node) => { - closeSandbox(); - setActiveNodeKey(node.nodeKey); - setSelectedAgentId(node.agentId); - }} - onSelectBuiltin={() => { - setRightPanelOpen(true); - setRightTab("results"); - }} + onSelectNode={openNodeFlowDetail} + onSelectBuiltin={handleSelectBuiltinStage} />
)} @@ -1469,7 +1568,24 @@ export default function PositionRoundtablePage({ /> )}
- {activePosition.isIntentPosition ? ( + {activeBuiltinKind && positionSession ? ( + setRightPanelOpen(false)} + onPreviewArtifact={handlePreviewArtifact} + onThreadSnapshot={handleNodeThreadSnapshot} + /> + ) : activePosition.isIntentPosition ? ( renderIntentChatPanel(isIntelligenceDeliveryOverview) ) : positionSession && activeNode ? ( setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} @@ -1507,7 +1625,7 @@ export default function PositionRoundtablePage({ onSelectIntent={openIntentFlowDetail} onSelectNode={openNodeFlowDetail} onSelectSeat={openSeatFlowDetail} - onSelectBuiltin={openBuiltinFlowDetail} + onSelectBuiltin={handleSelectBuiltinStage} className="h-full w-full" /> @@ -1529,15 +1647,10 @@ export default function PositionRoundtablePage({ } nodes={positionSession?.nodes ?? []} chain={activeChain} - onSelectNode={(node) => { - closeSandbox(); - setActiveNodeKey(node.nodeKey); - setSelectedAgentId(node.agentId); - }} onSessionUpdate={setPositionSession} onCollapse={() => setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} - selectedModel={selectedModel} + onOpenBuiltinChat={handleSelectBuiltinStage} tab={rightTab} onTabChange={setRightTab} /> @@ -1572,15 +1685,10 @@ export default function PositionRoundtablePage({ } nodes={positionSession?.nodes ?? []} chain={activeChain} - onSelectNode={(node) => { - closeSandbox(); - setActiveNodeKey(node.nodeKey); - setSelectedAgentId(node.agentId); - }} onSessionUpdate={setPositionSession} onCollapse={() => setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} - selectedModel={selectedModel} + onOpenBuiltinChat={handleSelectBuiltinStage} tab={rightTab} onTabChange={setRightTab} /> @@ -1589,23 +1697,6 @@ export default function PositionRoundtablePage({ )} {sandboxOpen && sandboxArtifact && artifactsCtx && ( @@ -1648,6 +1740,496 @@ export default function PositionRoundtablePage({ ); } +function ClosureStagePanel({ + focus, + onFocusChange, + nodes, + session, + taskId, + selectedModel, + onSessionUpdate, + onPreviewArtifact, + onThreadSnapshot, +}: { + focus: ClosureStageKind; + onFocusChange: (kind: ClosureStageKind) => void; + nodes: PositionRoundtableNode[]; + session: PositionRoundtableSession | null; + taskId: string; + selectedModel: string; + onSessionUpdate: (session: PositionRoundtableSession) => void; + onPreviewArtifact: (artifact: PositionArtifactPreview) => void; + onThreadSnapshot?: (snapshot: { + threadId: string; + thread: BaseStream; + }) => void; +}) { + const allDelivered = allExecutableNodesDone(nodes); + const deliveredCount = nodes.filter( + (node) => + node.status === "done" && + (isIntentPositionNode(node) || nodeHasMarkdownDelivery(node)), + ).length; + const summaryDone = snapshotStatus(session?.summarySnapshot) === "done"; + const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDelivered); + const actionPlanStatus = builtinNodeStatus(session?.actionPlanSnapshot, summaryDone); + const actionPlanDone = snapshotStatus(session?.actionPlanSnapshot) === "done"; + + const renderStageEntry = ( + kind: ClosureStageKind, + title: string, + description: string, + status: NodeVisualStatus, + ) => { + const meta = nodeStatusMeta(status); + const Icon = meta.Icon; + const active = focus === kind; + return ( + + ); + }; + + return ( +
+
+
+
+
+ +

+ 测试分析岗 · 收口智能体 +

+
+

+ 已收齐 {deliveredCount}/{nodes.length} 个岗位产物。点击「方案总结」或「行动规划」会进入对应智能体对话。 +

+
+ + {actionPlanDone ? "会商已完成" : allDelivered ? "可进入收口" : "等待岗位交付"} + +
+
+ +
+ {renderStageEntry( + "summary", + "1. 方案总结智能体", + "点击进入方案总结对话,可先生成初稿,也可继续追问或要求重写。", + summaryStatus, + )} + {renderStageEntry( + "action_plan", + "2. 行动规划智能体", + "点击进入行动规划对话,基于有效方案总结拆解动作和子任务。", + actionPlanStatus, + )} +
+ + undefined} + onPreviewArtifact={onPreviewArtifact} + onThreadSnapshot={onThreadSnapshot} + /> +
+ ); +} + +export function ClosureStagePanelLegacy({ + focus, + onFocusChange, + nodes, + session, + selectedModel, + onSessionUpdate, + onOpenBuiltinDetail, + onPreviewArtifact, +}: { + focus: ClosureStageKind; + onFocusChange: (kind: ClosureStageKind) => void; + nodes: PositionRoundtableNode[]; + session: PositionRoundtableSession | null; + selectedModel: string; + onSessionUpdate: (session: PositionRoundtableSession) => void; + onOpenBuiltinDetail: (kind: ClosureStageKind) => void; + onPreviewArtifact: (artifact: PositionArtifactPreview) => void; +}) { + const [summarySubmitting, setSummarySubmitting] = useState(false); + const [actionPlanSubmitting, setActionPlanSubmitting] = useState(false); + const allDelivered = allExecutableNodesDone(nodes); + const deliveredCount = nodes.filter( + (node) => + node.status === "done" && + (isIntentPositionNode(node) || nodeHasMarkdownDelivery(node)), + ).length; + const summaryDone = snapshotStatus(session?.summarySnapshot) === "done"; + const summaryStale = snapshotStatus(session?.summarySnapshot) === "stale"; + const actionPlanDone = snapshotStatus(session?.actionPlanSnapshot) === "done"; + const actionPlanStale = snapshotStatus(session?.actionPlanSnapshot) === "stale"; + const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDelivered); + const actionPlanStatus = builtinNodeStatus(session?.actionPlanSnapshot, summaryDone); + const currentSnapshot = focus === "summary" ? session?.summarySnapshot : session?.actionPlanSnapshot; + const currentStatus = focus === "summary" ? summaryStatus : actionPlanStatus; + const currentDone = focus === "summary" ? summaryDone : actionPlanDone; + const currentSubmitting = focus === "summary" ? summarySubmitting : actionPlanSubmitting; + const currentTitle = focus === "summary" ? "方案总结" : "行动规划"; + const currentDescription = + focus === "summary" + ? "汇总各岗位 Markdown 交付,形成最终方案建议、关键依据与风险提示。" + : "基于方案总结拆解执行动作、时间节奏、责任建议与风险兜底。"; + const sourceNodes = nodes.filter((node) => !isIntentPositionNode(node)); + + const runBuiltinAgent = useCallback( + async (kind: ClosureStageKind) => { + if (!session) { + toast.warning("请先启动业务链"); + return false; + } + if (!allDelivered) { + toast.warning("所有岗位产物完成后才能进入收口阶段"); + return false; + } + if (kind === "action_plan" && !summaryDone) { + toast.warning("请先生成有效的方案总结"); + return false; + } + const label = kind === "summary" ? "方案总结智能体" : "行动规划智能体"; + try { + const preparation = + kind === "summary" + ? await preparePositionSummary(session.id) + : await preparePositionActionPlan(session.id); + const thread = preparation.threadId + ? { agentName: preparation.agentId, threadId: preparation.threadId } + : kind === "summary" + ? await initSummaryThread() + : await initPositionActionPlanThread(); + toast.info(`${label}正在基于当前有效岗位交付生成结果…`); + const result = await streamMultiAgent({ + agentType: "special", + agentName: thread.agentName, + dAgentThreadId: { [thread.agentName]: thread.threadId }, + displayName: preparation.displayName, + newMessage: preparation.prompt, + model: selectedModel || undefined, + }); + const artifacts = await listSessionArtifacts(thread.threadId); + const artifactManifest = artifacts.files.map((file) => ({ + name: file.name, + path: file.path, + mime_type: file.mimeType, + modified_at: file.modifiedAt, + size_bytes: file.sizeBytes, + })); + const next = + kind === "summary" + ? await completePositionSummary(session.id, { + threadId: thread.threadId, + content: result.content, + artifactManifest, + sourceRevisions: preparation.sourceRevisions, + model: result.modelUsed || selectedModel || undefined, + }) + : await completePositionActionPlan(session.id, { + threadId: thread.threadId, + content: result.content, + artifactManifest, + sourceRevisions: preparation.sourceRevisions, + model: result.modelUsed || selectedModel || undefined, + }); + onSessionUpdate(next); + if (kind === "summary") { + onFocusChange("action_plan"); + } + toast.success(kind === "summary" ? "方案总结已生成并归档" : "行动规划与子任务清单已生成"); + return true; + } catch (error) { + toast.error(`${label}执行失败:${error instanceof Error ? error.message : String(error)}`); + return false; + } + }, + [allDelivered, onFocusChange, onSessionUpdate, selectedModel, session, summaryDone], + ); + + const handleGenerate = useCallback( + async (kind: ClosureStageKind) => { + if (kind === "summary") { + setSummarySubmitting(true); + try { + await runBuiltinAgent("summary"); + } finally { + setSummarySubmitting(false); + } + return; + } + setActionPlanSubmitting(true); + try { + await runBuiltinAgent("action_plan"); + } finally { + setActionPlanSubmitting(false); + } + }, + [runBuiltinAgent], + ); + + const previewBuiltinArtifact = useCallback( + (kind: ClosureStageKind) => { + const snapshot = kind === "summary" ? session?.summarySnapshot : session?.actionPlanSnapshot; + const artifact = snapshotArtifact(snapshot, "primary_artifact"); + const path = artifactPath(artifact); + const threadId = + snapshotText(snapshot, "thread_id") || + (kind === "summary" ? session?.summaryThreadId : session?.actionPlanThreadId) || + ""; + if (!threadId || !path) { + toast.info("当前阶段暂无可预览的文件产物"); + return; + } + onPreviewArtifact({ + threadId, + path, + name: artifactName(artifact) || (kind === "summary" ? "方案总结.md" : "行动规划.md"), + mimeType: artifactMimeType(artifact), + }); + }, + [onPreviewArtifact, session], + ); + + const renderStep = ( + kind: ClosureStageKind, + title: string, + description: string, + status: NodeVisualStatus, + disabled = false, + ) => { + const meta = nodeStatusMeta(status); + const Icon = meta.Icon; + const active = focus === kind; + return ( + + ); + }; + + const primaryDisabled = + currentSubmitting || + !allDelivered || + (focus === "action_plan" && !summaryDone); + const primaryLabel = + focus === "summary" + ? summaryDone + ? "重新生成方案总结" + : summaryStale + ? "重新生成失效总结" + : "生成方案总结" + : actionPlanDone + ? "重新生成行动规划" + : actionPlanStale + ? "重新生成失效规划" + : "生成行动规划"; + + return ( +
+
+
+
+
+ +

+ 测试分析岗 · 收口阶段 +

+
+

+ 已收齐 {deliveredCount}/{nodes.length} 个岗位产物,先生成方案总结,再解锁行动规划。 +

+
+ + {actionPlanDone ? "会商已完成" : allDelivered ? "可进入收口" : "等待岗位交付"} + +
+
+ +
+
+ {renderStep( + "summary", + "1. 方案总结", + "整合全部岗位交付,形成最终方案建议。", + summaryStatus, + )} + {renderStep( + "action_plan", + "2. 行动规划", + "基于方案总结拆解行动步骤和子任务。", + actionPlanStatus, + !summaryDone, + )} +
+ +
+
+
+

{currentTitle}

+

+ {currentDescription} +

+
+ + {currentDone + ? `已完成 · ${formatResultTime(snapshotText(currentSnapshot, "generated_at"), "刚刚")}` + : currentStatus === "locked" + ? "尚未解锁" + : "待生成"} + +
+ +
+

输入材料

+
+ {sourceNodes.map((node) => ( + + + {positionNameById(node.positionId)} + + ))} +
+
+ +
+ + {currentDone && ( + + )} + {currentDone && ( + + )} + {focus === "summary" && summaryDone && !actionPlanDone && ( + + )} +
+
+
+
+ ); +} + function PositionNodeEmptyState({ hasSession, hasAssignedAgent, @@ -2932,11 +3514,10 @@ function RightWorkspace({ onConfigureChain, nodes, chain, - onSelectNode, onSessionUpdate, onCollapse, onPreviewArtifact, - selectedModel, + onOpenBuiltinChat, tab, onTabChange, }: { @@ -2952,11 +3533,10 @@ function RightWorkspace({ onConfigureChain?: () => void; nodes: PositionRoundtableNode[]; chain: BusinessChain | null; - onSelectNode: (node: PositionRoundtableNode) => void; onSessionUpdate: (session: PositionRoundtableSession) => void; onCollapse: () => void; onPreviewArtifact: (artifact: PositionArtifactPreview) => void; - selectedModel: string; + onOpenBuiltinChat: (kind: ClosureStageKind) => void; tab: WorkspaceTab; onTabChange: (tab: WorkspaceTab) => void; }) { @@ -3050,10 +3630,9 @@ function RightWorkspace({ nodes={nodes} chain={chain} session={session} - onSelectNode={onSelectNode} onSessionUpdate={onSessionUpdate} onPreviewArtifact={onPreviewArtifact} - selectedModel={selectedModel} + onOpenBuiltinChat={onOpenBuiltinChat} />
)} @@ -3236,6 +3815,38 @@ interface ResultItem { artifactThreadId?: string | null; } +function canOpenDeliverable(item: ResultItem): boolean { + return Boolean(item.status === "done" && item.artifactPath && item.artifactThreadId); +} + +function deliverableUnavailableMessage(item: ResultItem): string { + const agentName = desensitize(item.agentName || "该智能体"); + const node = item.node; + + if (node?.status === "locked") { + return `「${agentName}」还在等待上游岗位交付。前序产物完成后,它会自动解锁。`; + } + if (node?.status === "running") { + return `「${agentName}」正在认真生成交付内容,完成后这里会自动提供可查看的产物。`; + } + if (node?.status === "stale" || node?.invalidatedBy) { + return `「${agentName}」的上游信息已更新,待它基于最新内容重新交付后,再查看产物会更准确。`; + } + if (node?.status === "rejected") { + return `「${agentName}」的产物正在根据反馈完善中,新的交付完成后会在这里显示。`; + } + if (node?.status === "error") { + return `「${agentName}」这次生成没有顺利完成。重新发起问答并交付后,就可以在这里查看产物。`; + } + if (item.kind === "summary") { + return "方案总结还在准备中。完成全部岗位交付后,即可由方案总结智能体生成可查看的报告。"; + } + if (item.kind === "action_plan") { + return "行动规划将在方案总结完成后继续生成,届时可在这里查看完整交付。"; + } + return `「${agentName}」尚未交付可预览的 Markdown 文件。完成交付后,这里会直接展示产物。`; +} + function artifactPath(artifact: Record | null | undefined): string | undefined { const path = recordString(artifact, "path"); return path || undefined; @@ -3245,6 +3856,29 @@ function artifactMimeType(artifact: Record | null | undefined): return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null; } +function isArtifactErrorContent(content: string | null | undefined): boolean { + const trimmed = content?.trim(); + if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false; + } + try { + const parsed = JSON.parse(trimmed) as Record; + const detail = parsed.detail ?? parsed.message ?? parsed.error; + return typeof detail === "string" && detail.trim().length > 0; + } catch { + return false; + } +} + +function deliverableFallbackContent(item: ResultItem | null): string { + const nodeAnswer = item?.node?.latestAnswer?.trim(); + const summary = item?.summary?.trim(); + if (item?.kind === "artifact") { + return nodeAnswer || summary || "暂无产物正文"; + } + return summary || nodeAnswer || "暂无产物正文"; +} + function buildIntentResultItem( intent: IntentSummary | null, updatedAt: string | undefined, @@ -3368,20 +4002,18 @@ function AnalysisResults({ nodes, chain, session, - onSelectNode, onSessionUpdate, onPreviewArtifact, - selectedModel, + onOpenBuiltinChat, }: { intentObjective?: string; intentCompletedAt: string; nodes: PositionRoundtableNode[]; chain: BusinessChain | null; session: PositionRoundtableSession | null; - onSelectNode: (node: PositionRoundtableNode) => void; onSessionUpdate: (session: PositionRoundtableSession) => void; onPreviewArtifact: (artifact: PositionArtifactPreview) => void; - selectedModel: string; + onOpenBuiltinChat: (kind: ClosureStageKind) => void; }) { const [positionFilter, setPositionFilter] = useState("all"); const [nodeFilter, setNodeFilter] = useState("all"); @@ -3391,9 +4023,6 @@ function AnalysisResults({ const [rejectTitle, setRejectTitle] = useState(""); const [rejectReason, setRejectReason] = useState(""); const [rejectSubmitting, setRejectSubmitting] = useState(false); - const [summarySubmitting, setSummarySubmitting] = useState(false); - const [actionPlanSubmitting, setActionPlanSubmitting] = useState(false); - const [builtinAskSubmitting, setBuiltinAskSubmitting] = useState(false); const seatsByAgentId = useMemo( () => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])), [chain], @@ -3476,12 +4105,15 @@ function AnalysisResults({ node, agentName, productName: name, - summary: `${mimeType}${node.status === "stale" ? " · 上游已更新,请基于最新结果复核" : ""}`, + summary: + node.latestAnswer?.trim() || + `${mimeType}${node.status === "stale" ? " · 上游已更新,请基于最新结果复核" : ""}`, positionId: node.positionId, updatedAt: node.updatedAt, status: node.status, artifactPath: path, artifactMimeType: mimeType, + artifactThreadId: node.threadId, }); }); } @@ -3583,12 +4215,13 @@ function AnalysisResults({ const openDetail = useCallback( (item: ResultItem) => { - setDetailItem(item); - if (item.node) { - onSelectNode(item.node); + if (!canOpenDeliverable(item)) { + toast.info(deliverableUnavailableMessage(item)); + return; } + setDetailItem(item); }, - [onSelectNode], + [], ); const openRejectDialog = useCallback((node: PositionRoundtableNode) => { @@ -3624,110 +4257,6 @@ function AnalysisResults({ } }, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]); - const runBuiltinAgent = useCallback( - async (kind: "summary" | "action_plan", followUp = "") => { - if (!session) { - toast.warning("请先启动业务链"); - return false; - } - if (!allDelivered) { - toast.warning("所有岗位产物完成后才能生成或更新内置交付"); - return false; - } - if (kind === "action_plan" && !summaryDone) { - toast.warning("请先生成有效的方案总结"); - return false; - } - const label = kind === "summary" ? "方案总结智能体" : "行动规划智能体"; - try { - const preparation = - kind === "summary" - ? await preparePositionSummary(session.id, followUp) - : await preparePositionActionPlan(session.id, followUp); - const thread = preparation.threadId - ? { agentName: preparation.agentId, threadId: preparation.threadId } - : kind === "summary" - ? await initSummaryThread() - : await initPositionActionPlanThread(); - toast.info(`${label}正在基于当前有效岗位交付生成结果…`); - const result = await streamMultiAgent({ - agentType: "special", - agentName: thread.agentName, - dAgentThreadId: { [thread.agentName]: thread.threadId }, - displayName: preparation.displayName, - newMessage: preparation.prompt, - model: selectedModel || undefined, - }); - const artifacts = await listSessionArtifacts(thread.threadId); - const artifactManifest = artifacts.files.map((file) => ({ - name: file.name, - path: file.path, - mime_type: file.mimeType, - modified_at: file.modifiedAt, - size_bytes: file.sizeBytes, - })); - const next = - kind === "summary" - ? await completePositionSummary(session.id, { - threadId: thread.threadId, - content: result.content, - artifactManifest, - sourceRevisions: preparation.sourceRevisions, - model: result.modelUsed || selectedModel || undefined, - }) - : await completePositionActionPlan(session.id, { - threadId: thread.threadId, - content: result.content, - artifactManifest, - sourceRevisions: preparation.sourceRevisions, - model: result.modelUsed || selectedModel || undefined, - }); - onSessionUpdate(next); - toast.success(kind === "summary" ? "方案总结已生成并归档" : "行动规划与子任务清单已生成"); - return true; - } catch (error) { - toast.error(`${label}执行失败:${error instanceof Error ? error.message : String(error)}`); - return false; - } - }, - [allDelivered, onSessionUpdate, selectedModel, session, summaryDone], - ); - - const handleGenerateSummary = useCallback(async () => { - setSummarySubmitting(true); - try { - await runBuiltinAgent("summary"); - } finally { - setSummarySubmitting(false); - } - }, [runBuiltinAgent]); - - const handleGenerateActionPlan = useCallback(async () => { - setActionPlanSubmitting(true); - try { - await runBuiltinAgent("action_plan"); - } finally { - setActionPlanSubmitting(false); - } - }, [runBuiltinAgent]); - - const handleAskBuiltin = useCallback( - async (kind: "summary" | "action_plan", message: string) => { - const text = message.trim(); - if (!text) { - toast.warning("请输入需要继续分析或修改的内容"); - return false; - } - setBuiltinAskSubmitting(true); - try { - return await runBuiltinAgent(kind, text); - } finally { - setBuiltinAskSubmitting(false); - } - }, - [runBuiltinAgent], - ); - if (resultItems.length === 0) { return (
@@ -3796,25 +4325,23 @@ function AnalysisResults({ block size="small" theme={summaryStale ? "warning" : "primary"} - variant={summaryDone ? "outline" : "base"} - loading={summarySubmitting} - disabled={!allDelivered || summarySubmitting} + variant="outline" + disabled={!allDelivered} icon={} - onClick={() => void handleGenerateSummary()} + onClick={() => onOpenBuiltinChat("summary")} > - {summaryDone ? "重新生成方案总结" : summaryStale ? "重新生成失效总结" : "生成方案总结"} + {summaryDone ? "进入方案总结智能体" : summaryStale ? "进入方案总结智能体重做" : "进入方案总结智能体"} } - onClick={() => void handleGenerateActionPlan()} + onClick={() => onOpenBuiltinChat("action_plan")} > - {actionPlanDone ? "重新生成行动规划" : actionPlanStale ? "重新生成失效规划" : "生成行动规划"} + {actionPlanDone ? "进入行动规划智能体" : actionPlanStale ? "进入行动规划智能体重做" : "进入行动规划智能体"}
@@ -3942,8 +4469,6 @@ function AnalysisResults({ mimeType: item.artifactMimeType ?? null, }); }} - onAskBuiltin={handleAskBuiltin} - askSubmitting={builtinAskSubmitting} /> - + @@ -4115,7 +4645,7 @@ function DeliverableDetailDialog({ /> )} - {artifactContentQuery.isError && ( + {artifactLoadFailed && (

Markdown 文件内容加载失败,当前展示的是节点摘要。

diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx index 7731f963..82e58047 100644 --- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx @@ -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) { 跳过此项 - + {footerAction ?? + (!hideEnterRoundtable && ( + + ))}