diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index dd584c4c..9428c91a 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -2,6 +2,7 @@ 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"; +import { Select as TSelect } from "tdesign-react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { InputBox } from "@/components/workspace/input-box"; import { ThreadContext } from "@/components/workspace/messages/context"; @@ -22,6 +23,11 @@ import { import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent"; import { desensitize } from "@/lib/desensitize"; +interface PositionNodeSwitchOption { + node: PositionRoundtableNode; + agentName?: string | null; +} + interface PositionNodeChatProps { sessionId: string; /** Parent task id: all node threads are indexed under this task. */ @@ -31,6 +37,8 @@ interface PositionNodeChatProps { currentPositionId: string; sessionStatus: "intent_pending" | "active" | "completed" | "archived"; agentName?: string | null; + positionNodeOptions?: PositionNodeSwitchOption[]; + onSelectPositionNode?: (node: PositionRoundtableNode) => void; onNodeUpdate: (node: PositionRoundtableNode) => void; /** The workspace collapses the configuration panel on the first real turn. */ onConversationStart?: () => void; @@ -91,6 +99,36 @@ function hasMarkdownArtifact(manifest: Array>): boolean }); } +function hasAnyArtifact(manifest: Array>): boolean { + return manifest.some((artifact) => { + const path = typeof artifact.path === "string" ? artifact.path.trim() : ""; + const name = typeof artifact.name === "string" ? artifact.name.trim() : ""; + return Boolean(path || name); + }); +} + +function nodeSwitchStatusLabel(node: PositionRoundtableNode): string { + if (node.invalidatedBy || node.status === "stale") { + return "需重做"; + } + + switch (node.status) { + case "running": + return "进行中"; + case "done": + return "已完成"; + case "rejected": + return "已驳回"; + case "error": + return "异常"; + case "locked": + return "未开始"; + case "ready": + default: + return "待开始"; + } +} + function artifactDisplayName(pathOrUrl: string): string { let path = pathOrUrl; if (pathOrUrl.startsWith("write-file:")) { @@ -153,6 +191,8 @@ export function PositionNodeChat({ currentPositionId, sessionStatus, agentName, + positionNodeOptions, + onSelectPositionNode, onNodeUpdate, onConversationStart, onPreviewArtifact, @@ -161,6 +201,13 @@ export function PositionNodeChat({ const [settings, setSettings] = useLocalSettings(); const { agent } = useAgent(node.agentId); const agentDisplayName = desensitize(agentName || agent?.name || node.agentId); + const switchOptions = useMemo( + () => + positionNodeOptions?.length + ? positionNodeOptions + : [{ node, agentName }], + [agentName, node, positionNodeOptions], + ); const artifactsCtx = useArtifactsOptional(); const [boundThreadId, setBoundThreadId] = useState(node.threadId); const boundThreadIdRef = useRef(node.threadId); @@ -284,6 +331,35 @@ export function PositionNodeChat({ const activeThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current; + const hasMultipleSwitchOptions = switchOptions.length > 1; + const switchSelectOptions = useMemo( + () => + switchOptions.map((option, index) => { + const optionNode = option.node; + const statusLabel = nodeSwitchStatusLabel(optionNode); + const optionName = desensitize( + option.agentName || optionNode.agentId || `智能体 ${index + 1}`, + ); + const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : ""; + + return { + label: `${optionName} · ${statusLabel}${artifactText}`, + value: optionNode.nodeKey, + }; + }), + [switchOptions], + ); + const handleSwitchNode = useCallback( + (targetNode: PositionRoundtableNode) => { + if (targetNode.nodeKey === node.nodeKey) return; + if (thread.isLoading) { + toast.info("当前智能体正在生成,完成后可切换"); + return; + } + onSelectPositionNode?.(targetNode); + }, + [node.nodeKey, onSelectPositionNode, thread.isLoading], + ); useEffect(() => { onThreadSnapshot?.({ threadId: activeThreadId, thread }); @@ -360,6 +436,7 @@ export function PositionNodeChat({ const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready"; const displayThreadId = boundThreadId ?? `position-node-${node.id}`; + const threadContextValue = useMemo(() => ({ thread }), [thread]); const readOnlyHint = sessionStatus === "archived" ? "该会话已归档,仅可查看历史对话和产物" @@ -372,9 +449,9 @@ export function PositionNodeChat({ : ""; return ( - +
-
+
{node.status === "locked" ? : } @@ -386,6 +463,32 @@ export function PositionNodeChat({ {readOnlyHint || `节点状态:${node.status}`}

+ {hasMultipleSwitchOptions && ( +
+ + 同岗位智能体 + + { + const nextNode = switchOptions.find( + (option) => option.node.nodeKey === String(value), + )?.node; + if (nextNode) { + handleSwitchNode(nextNode); + } + }} + popupProps={{ overlayInnerStyle: { minWidth: 380 } }} + /> +
+ )} {nodeArtifacts.length > 0 && ( diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index a907c2e0..3216bcec 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -41,7 +41,7 @@ import { toast } from "sonner"; 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 { ArtifactFileDetail, ArtifactFilePreview, useArtifactsOptional } from "@/components/workspace/artifacts"; import { ThreadContext } from "@/components/workspace/messages/context"; import { Dialog, @@ -55,6 +55,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls"; import { getAPIClient } from "@/core/api/api-client"; import { useAgent } from "@/core/agents"; +import { loadArtifactContent } from "@/core/artifacts/loader"; import type { Agent } from "@/core/agents"; import { useModels } from "@/core/models/hooks"; import type { AgentThreadState } from "@/core/threads/types"; @@ -123,6 +124,11 @@ type IntentEditDraft = { constraints: string; assumptions: string; }; +type SandboxThreadSnapshot = { + threadId: string; + thread: BaseStream; + signature: string; +}; function sandboxThreadSignature(thread: BaseStream): string { const messages = thread.messages ?? []; @@ -439,11 +445,10 @@ export default function PositionRoundtablePage({ 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 [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState(null); + const latestNodeThreadSnapshotRef = useRef(null); + const pendingSandboxThreadSnapshotRef = useRef(null); + const sandboxSnapshotFrameRef = useRef(null); const [flowDetailItem, setFlowDetailItem] = useState(null); const [taskEditorOpen, setTaskEditorOpen] = useState(false); const [taskDraft, setTaskDraft] = useState(fallbackTask); @@ -463,6 +468,41 @@ export default function PositionRoundtablePage({ useEffect(() => { sandboxArtifactRef.current = sandboxArtifact; }, [sandboxArtifact]); + const flushSandboxThreadSnapshot = useCallback(() => { + sandboxSnapshotFrameRef.current = null; + const pending = pendingSandboxThreadSnapshotRef.current; + pendingSandboxThreadSnapshotRef.current = null; + if (!pending) return; + const currentArtifact = sandboxArtifactRef.current; + if (!currentArtifact || currentArtifact.threadId !== pending.threadId) { + return; + } + setSandboxThreadSnapshot((current) => + current?.threadId === pending.threadId && current.signature === pending.signature + ? current + : pending, + ); + }, []); + const queueSandboxThreadSnapshot = useCallback( + (snapshot: SandboxThreadSnapshot) => { + pendingSandboxThreadSnapshotRef.current = snapshot; + if (sandboxSnapshotFrameRef.current !== null) return; + if (typeof window === "undefined") { + flushSandboxThreadSnapshot(); + return; + } + sandboxSnapshotFrameRef.current = window.requestAnimationFrame(flushSandboxThreadSnapshot); + }, + [flushSandboxThreadSnapshot], + ); + useEffect( + () => () => { + if (sandboxSnapshotFrameRef.current !== null) { + window.cancelAnimationFrame(sandboxSnapshotFrameRef.current); + } + }, + [], + ); const frozenIntent = useMemo( () => parseIntentSummary(positionSession?.intentSnapshot), [positionSession?.intentSnapshot], @@ -706,6 +746,14 @@ export default function PositionRoundtablePage({ if (!activeNode) return null; return activeChain?.seats.find((seat) => seat.agent_id === activeNode.agentId)?.name ?? null; }, [activeChain?.seats, activeNode]); + const currentPositionNodeOptions = useMemo( + () => + currentPositionNodes.map((node) => ({ + node, + agentName: activeChain?.seats.find((seat) => seat.agent_id === node.agentId)?.name ?? null, + })), + [activeChain?.seats, currentPositionNodes], + ); useEffect(() => { const nextNodeKey = activeNode?.nodeKey ?? null; if (activeNodeKey !== nextNodeKey) setActiveNodeKey(nextNodeKey); @@ -1060,6 +1108,9 @@ export default function PositionRoundtablePage({ ? current : artifact, ); + setSandboxThreadSnapshot((current) => + current?.threadId === artifact.threadId ? current : null, + ); const ctx = artifactsCtxRef.current; if (ctx?.selectedArtifact !== artifact.path) { @@ -1069,8 +1120,12 @@ export default function PositionRoundtablePage({ if (!ctx?.open) { ctx?.setOpen(true); } + const latestSnapshot = latestNodeThreadSnapshotRef.current; + if (latestSnapshot?.threadId === artifact.threadId) { + queueSandboxThreadSnapshot(latestSnapshot); + } setRightPanelOpen(false); - }, []); + }, [queueSandboxThreadSnapshot]); const handlePreviewFlowArtifact = useCallback( (item: ResultItem) => { const threadId = item.artifactThreadId || item.node?.threadId; @@ -1124,9 +1179,7 @@ export default function PositionRoundtablePage({ const handleStartedIntentSend = useCallback(() => { toast.info("业务链已启动,任务意图已冻结;如需重新识别请新建会话"); }, []); - const sandboxOpen = Boolean( - sandboxArtifact && artifactsCtx?.open && artifactsCtx.selectedArtifact, - ); + const sandboxOpen = Boolean(sandboxArtifact && artifactsCtx); // 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 @@ -1154,17 +1207,22 @@ export default function PositionRoundtablePage({ : sandboxThreadStub, [sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub], ); + const sandboxThreadContextValue = useMemo( + () => ({ thread: sandboxThread, isMock: false }), + [sandboxThread], + ); 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 nextSnapshot: SandboxThreadSnapshot = { ...snapshot, signature }; + latestNodeThreadSnapshotRef.current = nextSnapshot; + const currentArtifact = sandboxArtifactRef.current; + if (!currentArtifact || currentArtifact.threadId !== snapshot.threadId) { + return; + } + queueSandboxThreadSnapshot(nextSnapshot); }, - [], + [queueSandboxThreadSnapshot], ); const closeSandbox = useCallback(() => { const current = sandboxArtifactRef.current; @@ -1175,9 +1233,18 @@ export default function PositionRoundtablePage({ if (current || ctx?.open) { ctx?.setOpen(false); } + pendingSandboxThreadSnapshotRef.current = null; setSandboxArtifact(null); setSandboxThreadSnapshot(null); }, []); + const handleSelectPositionNode = useCallback( + (nextNode: PositionRoundtableNode) => { + closeSandbox(); + setActiveNodeKey(nextNode.nodeKey); + setSelectedAgentId(nextNode.agentId); + }, + [closeSandbox], + ); const renderIntentChatPanel = (chainStarted: boolean) => (
{intent.intentInitError && ( @@ -1414,6 +1481,8 @@ export default function PositionRoundtablePage({ currentPositionId={positionId} sessionStatus={positionSession.status} agentName={activeNodeAgentName} + positionNodeOptions={currentPositionNodeOptions} + onSelectPositionNode={handleSelectPositionNode} onNodeUpdate={handleNodeUpdate} onConversationStart={() => setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} @@ -1518,7 +1587,7 @@ export default function PositionRoundtablePage({
)} - {sandboxOpen && sandboxArtifact && artifactsCtx?.selectedArtifact && ( + {sandboxOpen && sandboxArtifact && artifactsCtx && (