diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index c5b99f42..dd584c4c 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -1,3 +1,4 @@ +import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Bot, FileText, LockKeyhole } from "lucide-react"; import { toast } from "sonner"; @@ -9,6 +10,7 @@ import { useArtifactsOptional } from "@/components/workspace/artifacts"; 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 { @@ -37,6 +39,11 @@ interface PositionNodeChatProps { path: string; name: string; mimeType: string | null; + auto?: boolean; + }) => void; + onThreadSnapshot?: (snapshot: { + threadId: string; + thread: BaseStream; }) => void; } @@ -84,6 +91,53 @@ function hasMarkdownArtifact(manifest: Array>): boolean }); } +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; + const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id); + return { + path: virtualPath, + name: artifactDisplayName(rawPath), + mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null, + }; + } + } + return null; +} + /** * Direct single-agent chat for one frozen business-chain node. * @@ -102,6 +156,7 @@ export function PositionNodeChat({ onNodeUpdate, onConversationStart, onPreviewArtifact, + onThreadSnapshot, }: PositionNodeChatProps) { const [settings, setSettings] = useLocalSettings(); const { agent } = useAgent(node.agentId); @@ -111,6 +166,12 @@ export function PositionNodeChat({ 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 = sessionStatus !== "archived" && @@ -120,16 +181,28 @@ export function PositionNodeChat({ !node.invalidatedBy; useEffect(() => { - if (artifactsCtx?.open && artifactsCtx.selectedArtifact) { - onPreviewArtifact?.({ - threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current, - path: artifactsCtx.selectedArtifact, - name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告", - mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md") - ? "text/markdown" - : null, - }); + 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(() => { @@ -209,6 +282,32 @@ export function PositionNodeChat({ }, }); + const activeThreadId = + boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current; + + 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]); + // SDK thread creation is asynchronous. ``onStart`` normally binds it, but // watching the actual stream id as well closes the last race where a page // refresh could happen between creation and the async node-index write. diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 4e5488e4..4ce86d06 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -1,3 +1,4 @@ +import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -40,6 +41,7 @@ import { agentColor } from "@/components/workspace/agent-icon-color"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar"; import { ArtifactFileDetail, useArtifactsOptional } from "@/components/workspace/artifacts"; +import { ThreadContext } from "@/components/workspace/messages/context"; import { Dialog, DialogContent, @@ -112,8 +114,37 @@ type PositionArtifactPreview = { path: string; name: string; mimeType: string | null; + auto?: boolean; }; +function sandboxThreadSignature(thread: BaseStream): string { + const messages = thread.messages ?? []; + 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" || !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") continue; + const path = typeof toolCall.args?.path === "string" ? toolCall.args.path : ""; + const content = typeof toolCall.args?.content === "string" ? toolCall.args.content : ""; + return [ + thread.isLoading ? "1" : "0", + messages.length, + message.id ?? "", + toolCall.id ?? "", + path, + content.length, + content.slice(-32), + ].join("|"); + } + } + const lastMessage = messages.at(-1) as (typeof messages)[number] & { id?: string } | undefined; + return [thread.isLoading ? "1" : "0", messages.length, lastMessage?.id ?? ""].join("|"); +} + function uniqueChains(chains: BusinessChain[]): BusinessChain[] { const seen = new Set(); return chains.filter((chain) => { @@ -389,8 +420,22 @@ export default function PositionRoundtablePage({ const [rightPanelOpen, setRightPanelOpen] = useState(true); const [rightTab, setRightTab] = useState("agents"); const artifactsCtx = useArtifactsOptional(); + const artifactsCtxRef = useRef(artifactsCtx); const [sandboxArtifact, setSandboxArtifact] = useState(null); + const sandboxArtifactRef = useRef(null); + const closedSandboxKeyRef = useRef(null); + const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<{ + threadId: string; + thread: BaseStream; + signature: string; + } | null>(null); const activePosition = POSITIONS.find((item) => item.id === positionId)!; + useEffect(() => { + artifactsCtxRef.current = artifactsCtx; + }, [artifactsCtx]); + useEffect(() => { + sandboxArtifactRef.current = sandboxArtifact; + }, [sandboxArtifact]); const frozenIntent = useMemo( () => parseIntentSummary(positionSession?.intentSnapshot), [positionSession?.intentSnapshot], @@ -784,6 +829,13 @@ export default function PositionRoundtablePage({ }, [intent.intentReady, intent.intentThreadId, positionSession, searchParams, selectedChain, setSearchParams, startingChain, task]); const handleCreateNewSession = useCallback(() => { + const ctx = artifactsCtxRef.current; + if (sandboxArtifactRef.current || ctx?.open) { + ctx?.setOpen(false); + } + closedSandboxKeyRef.current = null; + setSandboxArtifact(null); + setSandboxThreadSnapshot(null); intent.resetIntent(); hydratedIntentSessionRef.current = null; persistedIntentRef.current = null; @@ -812,29 +864,88 @@ export default function PositionRoundtablePage({ }); }, [positionSession]); - const handlePreviewArtifact = useCallback( - (artifact: PositionArtifactPreview) => { - if (!artifact.threadId || !artifact.path) { - toast.warning("该文件缺少沙箱预览所需的信息"); - return; - } - // 与普通智能体问答完全使用同一套 ArtifactsProvider:选中文件、展开 - // 文档面板,并让 ArtifactFileDetail 以所属 thread 读取沙箱文件。 - setSandboxArtifact(artifact); - artifactsCtx?.setArtifacts([artifact.path]); - artifactsCtx?.select(artifact.path); - artifactsCtx?.setOpen(true); - setRightPanelOpen(false); - }, - [artifactsCtx], - ); + const handlePreviewArtifact = useCallback((artifact: PositionArtifactPreview) => { + if (!artifact.threadId || !artifact.path) { + toast.warning("该文件缺少沙箱预览所需的信息"); + return; + } + + const artifactKey = `${artifact.threadId}:${artifact.path}`; + if (artifact.auto && closedSandboxKeyRef.current === artifactKey) { + return; + } + closedSandboxKeyRef.current = null; + + setSandboxArtifact((current) => + current?.threadId === artifact.threadId && current.path === artifact.path + ? current + : artifact, + ); + + 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); + } + setRightPanelOpen(false); + }, []); const sandboxOpen = Boolean( sandboxArtifact && artifactsCtx?.open && artifactsCtx.selectedArtifact, ); + // ArtifactFileDetail is shared with the normal LangGraph chat surface and + // therefore reads ThreadContext for streaming state. The position workspace + // renders its sandbox as a sibling of PositionNodeChat, so it is outside the + // node's provider. Artifact files here always load through the artifacts API; + // provide the smallest stable stream shape needed by the preview component. + const sandboxThreadStub = useMemo( + () => + ({ + messages: [], + isLoading: false, + isThreadLoading: false, + values: { + title: "", + messages: [], + artifacts: artifactsCtx?.artifacts ?? [], + }, + }) as unknown as BaseStream, + [artifactsCtx?.artifacts], + ); + const sandboxThread = useMemo( + () => + sandboxArtifact && + sandboxThreadSnapshot?.threadId === sandboxArtifact.threadId + ? sandboxThreadSnapshot.thread + : sandboxThreadStub, + [sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub], + ); + const handleNodeThreadSnapshot = useCallback( + (snapshot: { threadId: string; thread: BaseStream }) => { + const signature = sandboxThreadSignature(snapshot.thread); + setSandboxThreadSnapshot((current) => { + const sameThreadData = + current?.threadId === snapshot.threadId && + current.signature === signature; + return sameThreadData ? current : { ...snapshot, signature }; + }); + }, + [], + ); const closeSandbox = useCallback(() => { - artifactsCtx?.setOpen(false); + const current = sandboxArtifactRef.current; + if (current) { + closedSandboxKeyRef.current = `${current.threadId}:${current.path}`; + } + const ctx = artifactsCtxRef.current; + if (current || ctx?.open) { + ctx?.setOpen(false); + } setSandboxArtifact(null); - }, [artifactsCtx]); + setSandboxThreadSnapshot(null); + }, []); return (
{compactChrome && ( - <> +
- +
)}
@@ -943,7 +1055,7 @@ export default function PositionRoundtablePage({ /> ) ) : ( - <> +
{ + closeSandbox(); setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} @@ -959,7 +1072,7 @@ export default function PositionRoundtablePage({ setRightTab("results"); }} /> - +
)} @@ -988,7 +1101,7 @@ export default function PositionRoundtablePage({ positionSession?.status !== "intent_pending" && frozenIntent ? ( ) : ( - <> +
{intent.intentInitError && (
对话初始化失败:{intent.intentInitError} @@ -1035,7 +1148,7 @@ export default function PositionRoundtablePage({ {startingChain ? "正在保存意图并生成岗位节点…" : "正在恢复岗位协同会话…"}
)} - +
) ) : positionSession && activeNode ? ( setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} + onThreadSnapshot={handleNodeThreadSnapshot} /> ) : ( { + closeSandbox(); setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} @@ -1096,6 +1211,7 @@ export default function PositionRoundtablePage({ nodes={positionSession?.nodes ?? []} chain={activeChain} onSelectNode={(node) => { + closeSandbox(); setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} @@ -1138,6 +1254,7 @@ export default function PositionRoundtablePage({ nodes={positionSession?.nodes ?? []} chain={activeChain} onSelectNode={(node) => { + closeSandbox(); setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} @@ -1153,7 +1270,7 @@ export default function PositionRoundtablePage({ )} {sandboxOpen && sandboxArtifact && artifactsCtx?.selectedArtifact && ( )} @@ -1890,7 +2011,7 @@ function BusinessChainFlow({ 请先由情报分析岗选择业务链条 ) : ( - <> +
- +
)} ); @@ -2911,7 +3032,7 @@ function AnalysisResults({ } return ( - <> +
@@ -3009,7 +3130,7 @@ function AnalysisResults({ const status = item.status ? nodeStatusMeta(item.status) : null; const StatusIcon = status?.Icon; const contents = ( - <> +
- +
); return item.node ? (
); } @@ -3210,14 +3331,14 @@ function DeliverableDetailDialog({ 时间:{displayedTime || "暂无"}
{item.node && ( - <> +
版本:第 {item.node.revision} 版
驳回次数:{item.node.rejectionCount}
- +
)}