diff --git a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx index c4457cc8..e62c5efa 100644 --- a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx @@ -1,6 +1,6 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target, Trash2 } from "lucide-react"; +import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "lucide-react"; import { toast } from "sonner"; import { Button as TButton } from "tdesign-react"; @@ -9,7 +9,7 @@ import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { useAgent } from "@/core/agents"; import { useLocalSettings } from "@/core/settings"; -import { useClearThreadMessages, useThreadStream } from "@/core/threads/hooks"; +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"; @@ -229,10 +229,6 @@ export function PositionBuiltinAgentChat({ useState(null); const pendingSubmissionRef = useRef(false); const handledAutoStartRequestRef = useRef(undefined); - const clearThreadMessages = useClearThreadMessages(); - const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState>( - () => new Set(), - ); useEffect(() => { setAssistantId(fallbackAgentId); @@ -243,7 +239,6 @@ export function PositionBuiltinAgentChat({ pendingSubmissionRef.current = false; setPendingSubmission(null); handledAutoStartRequestRef.current = undefined; - setExternallyHiddenMessageIds(new Set()); // Do not depend on `persistedThreadId` here. Preparing the automatic // first question can persist a thread id while the page's initial // session-request is still resolving. Treating that update as a new @@ -483,31 +478,6 @@ export function PositionBuiltinAgentChat({ [activeThreadId, onPreviewArtifact], ); - const handleClearConversation = useCallback(async () => { - const threadId = boundThreadIdRef.current; - if (!threadId) { - toast.info("当前智能体还没有可清空的对话"); - return; - } - if (thread.isLoading || isSubmitting) { - toast.info("智能体正在生成,完成后再清空对话"); - return; - } - const confirmed = window.confirm("确定清空当前智能体对话吗?清空后不可恢复。"); - if (!confirmed) return; - - const fallbackIds = thread.messages - .map((message) => message.id) - .filter((id): id is string => Boolean(id)); - try { - const deletedIds = await clearThreadMessages.mutateAsync({ threadId }); - setExternallyHiddenMessageIds(new Set(deletedIds.length ? deletedIds : fallbackIds)); - toast.success("对话已清空"); - } catch (error) { - toast.error(error instanceof Error ? error.message : "清空对话失败"); - } - }, [clearThreadMessages, isSubmitting, thread.isLoading, thread.messages]); - useEffect(() => { if ( !autoStartRequestKey || @@ -563,16 +533,6 @@ export function PositionBuiltinAgentChat({ onSelect={onSelectBuiltinAgent} /> )} - } - disabled={!boundThreadId || thread.isLoading || isSubmitting || clearThreadMessages.isPending} - onClick={() => void handleClearConversation()} - title="清空当前智能体对话" - /> {persistedArtifacts.length > 0 && ( @@ -625,7 +585,6 @@ export function PositionBuiltinAgentChat({ } onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onOpenArtifact={handleOpenMessageArtifact} - externallyHiddenMessageIds={externallyHiddenMessageIds} /> ) : (
diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index 8ddae1d3..ff6f2053 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -1,6 +1,6 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Bot, FileText, LockKeyhole, Settings, Trash2 } from "lucide-react"; +import { Bot, FileText, LockKeyhole, Settings } from "lucide-react"; import { toast } from "sonner"; import { Button as TButton, Select as TSelect } from "tdesign-react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; @@ -15,7 +15,7 @@ import { ThreadContext } from "@/components/workspace/messages/context"; import { MessageList } from "@/components/workspace/messages"; import { useAgent } from "@/core/agents"; import { useLocalSettings } from "@/core/settings"; -import { useClearThreadMessages, useEditMessage, useThreadStream } from "@/core/threads/hooks"; +import { useEditMessage, useThreadStream } from "@/core/threads/hooks"; import type { AgentThreadState } from "@/core/threads/types"; import { textOfMessage } from "@/core/threads/utils"; import { uuid } from "@/core/utils/uuid"; @@ -163,7 +163,7 @@ function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: s function latestStreamingWriteFile( messages: AgentThreadState["messages"], -): { path: string; name: string; mimeType: string | null } | null { +): { key: string; 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; @@ -179,6 +179,7 @@ function latestStreamingWriteFile( if (typeof content !== "string" || content.length === 0) continue; const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id); return { + key: `${message.id}:${toolCall.id}`, path: virtualPath, name: artifactDisplayName(rawPath), mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null, @@ -188,6 +189,28 @@ function latestStreamingWriteFile( return null; } +/** + * A tool-call id is stable within a persisted thread. Capturing the existing + * ids right before a new submission lets the streaming preview distinguish a + * new write_file from an older deliverable kept in the message history. + */ +function writeFileCallKeys(messages: AgentThreadState["messages"]): Set { + const keys = new Set(); + for (const rawMessage of messages) { + const message = rawMessage as (typeof messages)[number] & { + id?: string; + tool_calls?: Array<{ id?: string; name?: string }>; + }; + if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue; + for (const toolCall of message.tool_calls) { + if (toolCall?.name === "write_file" && toolCall.id) { + keys.add(`${message.id}:${toolCall.id}`); + } + } + } + return keys; +} + /** * Direct single-agent chat for one frozen business-chain node. * @@ -232,12 +255,10 @@ export function PositionNodeChat({ const draftThreadIdRef = useRef(node.threadId ?? uuid()); const upstreamRevisionRef = useRef | null>(null); const autoPreviewedArtifactKeyRef = useRef(null); - const clearThreadMessages = useClearThreadMessages(); + const autoPreviewBaselineCallKeysRef = useRef>(new Set()); + const autoPreviewRunArmedRef = useRef(false); const editMessage = useEditMessage(); const [messageContentOverrides, setMessageContentOverrides] = useState>({}); - const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState>( - () => new Set(), - ); const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]); const canWrite = sessionStatus !== "archived" && @@ -250,7 +271,8 @@ export function PositionNodeChat({ setBoundThreadId(node.threadId); boundThreadIdRef.current = node.threadId; setMessageContentOverrides({}); - setExternallyHiddenMessageIds(new Set()); + autoPreviewBaselineCallKeysRef.current = new Set(); + autoPreviewRunArmedRef.current = false; }, [node.nodeKey, node.threadId]); const bindCreatedThread = useCallback( @@ -399,31 +421,6 @@ export function PositionNodeChat({ sessionId, ], ); - const handleClearConversation = useCallback(async () => { - const threadId = boundThreadIdRef.current; - if (!threadId) { - toast.info("当前智能体还没有可清空的对话"); - return; - } - if (thread.isLoading) { - toast.info("智能体正在生成,完成后再清空对话"); - return; - } - const confirmed = window.confirm("确定清空当前智能体对话吗?清空后不可恢复。"); - if (!confirmed) return; - - const fallbackIds = thread.messages - .map((message) => message.id) - .filter((id): id is string => Boolean(id)); - try { - const deletedIds = await clearThreadMessages.mutateAsync({ threadId }); - setExternallyHiddenMessageIds(new Set(deletedIds.length ? deletedIds : fallbackIds)); - setMessageContentOverrides({}); - toast.success("对话已清空"); - } catch (error) { - toast.error(error instanceof Error ? error.message : "清空对话失败"); - } - }, [clearThreadMessages, thread.isLoading, thread.messages]); const hasMultipleSwitchOptions = switchOptions.length > 1; const switchSelectOptions = useMemo( () => @@ -471,7 +468,14 @@ export function PositionNodeChat({ ); useEffect(() => { - if (!thread.isLoading || !streamingArtifact) return; + if ( + !thread.isLoading || + !streamingArtifact || + !autoPreviewRunArmedRef.current || + autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key) + ) { + return; + } // One write_file call should open at most one preview. The parent further // guards manual close state, because it owns the page-wide singleton sandbox. const artifactKey = `${activeThreadId}:${streamingArtifact.path}`; @@ -519,11 +523,16 @@ export function PositionNodeChat({ upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision) ? (upstreamRevision as Record) : null; + // A thread retains historical write_file tool calls. Only files whose + // call id appears after this exact submission may open the sandbox. + autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages); + autoPreviewRunArmedRef.current = true; await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, { agent_id: node.agentId, task_external_context: taskExternalContext, }); } catch (error) { + autoPreviewRunArmedRef.current = false; toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`); } }, @@ -536,6 +545,7 @@ export function PositionNodeChat({ sendMessage, sessionId, sessionStatus, + thread.messages, ], ); @@ -615,16 +625,6 @@ export function PositionNodeChat({ 查看驳回理由 )} - } - disabled={!boundThreadId || thread.isLoading || clearThreadMessages.isPending} - onClick={() => void handleClearConversation()} - title="清空当前智能体对话" - /> {onOpenConfiguration && ( void handleSubmit({ text, files: [] })} onEditSave={handleSaveMessageEdit} messageContentOverrides={messageContentOverrides} - externallyHiddenMessageIds={externallyHiddenMessageIds} onOpenArtifact={handleOpenMessageArtifact} /> ) : ( diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 1df0ee88..8c0d68d6 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -26,7 +26,6 @@ import { Settings, Sparkles, Target, - Trash2, UserRoundCog, XCircle, type LucideIcon, @@ -67,7 +66,6 @@ import { useAgent } from "@/core/agents"; import { loadArtifactContent } from "@/core/artifacts/loader"; import type { Agent } from "@/core/agents"; import { useModels } from "@/core/models/hooks"; -import { useClearThreadMessages } from "@/core/threads/hooks"; import type { AgentThreadState } from "@/core/threads/types"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; @@ -517,7 +515,6 @@ 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); @@ -634,7 +631,6 @@ export default function PositionRoundtablePage({ })), [models], ); - const clearIntentThreadMessages = useClearThreadMessages(); const triggerToast = useCallback((message: string) => toast.info(message), []); const intent = useStep1Intent({ // 全岗位共用原会商的对话接口与输入面板;岗位运行编排后续再按角色接入。 @@ -651,26 +647,6 @@ export default function PositionRoundtablePage({ const persistedIntentRef = useRef(null); const isArchivedSession = positionSession?.status === "archived"; - const handleClearIntentConversation = useCallback(async () => { - if (intent.isIntentSending) { - toast.info("意图理解正在生成,完成后再清空对话"); - return; - } - const confirmed = window.confirm("确定清空意图理解对话吗?清空后不可恢复。"); - if (!confirmed) return; - try { - if (intent.intentThreadId) { - await clearIntentThreadMessages.mutateAsync({ - threadId: intent.intentThreadId, - }); - } - intent.clearInteractiveMessages(); - toast.success("意图理解对话已清空"); - } catch (error) { - toast.error(error instanceof Error ? error.message : "清空意图理解对话失败"); - } - }, [clearIntentThreadMessages, intent]); - const syncSessionUrl = useCallback( (sessionId: string, nextTaskId: string) => { const next = new URLSearchParams(searchParamsString); @@ -836,6 +812,16 @@ export default function PositionRoundtablePage({ }, [positionSession?.chainSnapshot, positionSession?.status, selectedChain]); const isIntelligenceDeliveryOverview = activePosition.isIntentPosition && positionSession !== null && positionSession.status !== "intent_pending"; + const previousIntelligenceDeliveryOverviewRef = useRef(isIntelligenceDeliveryOverview); + useEffect(() => { + // Sending the last intent answer transitions the page into the delivery + // overview. Restore the right workspace at that moment instead of leaving + // the user on an unexpectedly full-width, empty-looking conversation area. + if (isIntelligenceDeliveryOverview && !previousIntelligenceDeliveryOverviewRef.current) { + setRightPanelOpen(true); + } + previousIntelligenceDeliveryOverviewRef.current = isIntelligenceDeliveryOverview; + }, [isIntelligenceDeliveryOverview]); const leftSidebarHidden = leftPanelCollapsed; const allPositionDeliveriesDone = useMemo( () => allExecutableNodesDone(positionSession?.nodes ?? []), @@ -1250,7 +1236,6 @@ export default function PositionRoundtablePage({ ctx?.setOpen(false); } closedSandboxKeyRef.current = null; - suppressedAutoPreviewThreadIdsRef.current.clear(); setSandboxArtifact(null); setSandboxThreadSnapshot(null); intent.resetIntent(); @@ -1329,14 +1314,12 @@ export default function PositionRoundtablePage({ const artifactKey = sandboxCloseKey(artifact); if (artifact.auto) { - if ( - closedSandboxKeyRef.current === artifactKey || - suppressedAutoPreviewThreadIdsRef.current.has(artifact.threadId) - ) { + // Closing an old file should only suppress that exact preview. A later + // write_file in the same thread is a new deliverable and must still be + // able to open its own streaming sandbox. + if (closedSandboxKeyRef.current === artifactKey) { return; } - } else { - suppressedAutoPreviewThreadIdsRef.current.delete(artifact.threadId); } closedSandboxKeyRef.current = null; @@ -1453,7 +1436,6 @@ export default function PositionRoundtablePage({ const current = sandboxArtifactRef.current; if (current) { closedSandboxKeyRef.current = sandboxCloseKey(current); - suppressedAutoPreviewThreadIdsRef.current.add(current.threadId); } const ctx = artifactsCtxRef.current; if (ctx?.open) { @@ -1554,18 +1536,6 @@ export default function PositionRoundtablePage({
} - headerRightSlot={ - } - disabled={intent.isIntentSending || clearIntentThreadMessages.isPending} - onClick={() => void handleClearIntentConversation()} - title="清空意图理解对话" - /> - } interactiveMessages={intent.interactiveMessages} intentThreadId={intent.intentThreadId} intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady} @@ -1578,7 +1548,6 @@ export default function PositionRoundtablePage({ handleStartedIntentSend(); return; } - setRightPanelOpen(false); void intent.sendIntentMessage(text, files); }} onStopIntent={intent.stopIntentStream} @@ -1836,8 +1805,7 @@ export default function PositionRoundtablePage({ )}
- {!isIntelligenceDeliveryOverview && - !rightPanelOpen && + {!rightPanelOpen && !sandboxOpen && !(positionSession && activeNode && !activeBuiltinKind) && ( setRightPanelOpen(false)} + // 测试分析岗的右侧交付工作区应持续可见;会话开始不再把它收起。 + onConversationStart={undefined} onPreviewArtifact={handlePreviewArtifact} onThreadSnapshot={handleNodeThreadSnapshot} /> @@ -1901,7 +1870,7 @@ export default function PositionRoundtablePage({
- {activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && rightPanelOpen && !sandboxOpen && ( + {activePosition.isIntentPosition && rightPanelOpen && !sandboxOpen && (