代码提交

This commit is contained in:
董宏凯 committed 2026-07-23 15:53:43 +08:00
1 parent 7d249a81ce
commit 4948a80c56
3 files changed
+64 -137

No files matched your search

@@ -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<PendingBuiltinSubmission | null>(null);
const pendingSubmissionRef = useRef(false);
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
const clearThreadMessages = useClearThreadMessages();
const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState<Set<string>>(
() => 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}
/>
)}
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Trash2 className="!size-4 !shrink-0" />}
disabled={!boundThreadId || thread.isLoading || isSubmitting || clearThreadMessages.isPending}
onClick={() => void handleClearConversation()}
title="清空当前智能体对话"
/>
</header>
{persistedArtifacts.length > 0 && (
@@ -625,7 +585,6 @@ export function PositionBuiltinAgentChat({
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
externallyHiddenMessageIds={externallyHiddenMessageIds}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
@@ -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<string> {
const keys = new Set<string>();
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<Record<string, unknown> | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const clearThreadMessages = useClearThreadMessages();
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
const autoPreviewRunArmedRef = useRef(false);
const editMessage = useEditMessage();
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
const [externallyHiddenMessageIds, setExternallyHiddenMessageIds] = useState<Set<string>>(
() => 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<string, unknown>)
: 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({
查看驳回理由
</TButton>
)}
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Trash2 className="!size-4 !shrink-0" />}
disabled={!boundThreadId || thread.isLoading || clearThreadMessages.isPending}
onClick={() => void handleClearConversation()}
title="清空当前智能体对话"
/>
{onOpenConfiguration && (
<TButton
size="small"
@@ -690,7 +690,6 @@ export function PositionNodeChat({
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onEditSave={handleSaveMessageEdit}
messageContentOverrides={messageContentOverrides}
externallyHiddenMessageIds={externallyHiddenMessageIds}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
@@ -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<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const suppressedAutoPreviewThreadIdsRef = useRef<Set<string>>(new Set());
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<SandboxThreadSnapshot | null>(null);
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(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<string | null>(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({
</div>
</div>
}
headerRightSlot={
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Trash2 className="!size-4 !shrink-0" />}
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({
)}
<section className="relative flex min-w-[420px] flex-1 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none dark:backdrop-blur-md">
{!isIntelligenceDeliveryOverview &&
!rightPanelOpen &&
{!rightPanelOpen &&
!sandboxOpen &&
!(positionSession && activeNode && !activeBuiltinKind) && (
<TButton
@@ -1864,7 +1832,8 @@ export default function PositionRoundtablePage({
builtinAgentOptions={showClosureAgents ? builtinAgentOptions : undefined}
onSelectBuiltinAgent={showClosureAgents ? handleSelectBuiltinStage : undefined}
onSessionUpdate={setPositionSession}
onConversationStart={() => setRightPanelOpen(false)}
// 测试分析岗的右侧交付工作区应持续可见;会话开始不再把它收起。
onConversationStart={undefined}
onPreviewArtifact={handlePreviewArtifact}
onThreadSnapshot={handleNodeThreadSnapshot}
/>
@@ -1901,7 +1870,7 @@ export default function PositionRoundtablePage({
</div>
</section>
{activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && rightPanelOpen && !sandboxOpen && (
{activePosition.isIntentPosition && rightPanelOpen && !sandboxOpen && (
<aside className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md">
<RightWorkspace
selectedAgent={selectedAgent}