打开沙箱错误修改

This commit is contained in:
董宏凯 committed 2026-07-22 16:53:56 +08:00
1 parent ae184f434d
commit d9844e0180
2 files changed
+267 -47

No files matched your search

@@ -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<AgentThreadState>;
}) => void;
}
@@ -84,6 +91,53 @@ function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): 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<string, unknown> }>;
};
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<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | 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<string | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(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.
@@ -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<AgentThreadState>): 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<string, unknown> }>;
};
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<string>();
return chains.filter((chain) => {
@@ -389,8 +420,22 @@ export default function PositionRoundtablePage({
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<WorkspaceTab>("agents");
const artifactsCtx = useArtifactsOptional();
const artifactsCtxRef = useRef(artifactsCtx);
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<{
threadId: string;
thread: BaseStream<AgentThreadState>;
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<AgentThreadState>,
[artifactsCtx?.artifacts],
);
const sandboxThread = useMemo(
() =>
sandboxArtifact &&
sandboxThreadSnapshot?.threadId === sandboxArtifact.threadId
? sandboxThreadSnapshot.thread
: sandboxThreadStub,
[sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub],
);
const handleNodeThreadSnapshot = useCallback(
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
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 (
<div
@@ -911,6 +1022,7 @@ export default function PositionRoundtablePage({
// 其他岗位改用按需打开的抽屉,避免一进岗位就挤占对话空间。
const nextPositionId = String(value) as PositionId;
// 中间区由 activeNode 的兜底规则自动定位到首个流程节点。
closeSandbox();
setPositionId(nextPositionId);
setSelectedAgentId(null);
setActiveNodeKey(null);
@@ -918,10 +1030,10 @@ export default function PositionRoundtablePage({
}}
/>
{compactChrome && (
<>
<div className="contents">
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</>
</div>
)}
</div>
</header>
@@ -943,7 +1055,7 @@ export default function PositionRoundtablePage({
/>
)
) : (
<>
<div className="contents">
<IntentUnderstandingCard intent={intent.intentReady} />
<BusinessChainFlow
chain={activeChain}
@@ -951,6 +1063,7 @@ export default function PositionRoundtablePage({
session={positionSession}
activeNodeKey={activeNodeKey}
onSelectNode={(node) => {
closeSandbox();
setActiveNodeKey(node.nodeKey);
setSelectedAgentId(node.agentId);
}}
@@ -959,7 +1072,7 @@ export default function PositionRoundtablePage({
setRightTab("results");
}}
/>
</>
</div>
)}
</aside>
@@ -988,7 +1101,7 @@ export default function PositionRoundtablePage({
positionSession?.status !== "intent_pending" && frozenIntent ? (
<IntentConfirmedState intent={frozenIntent} />
) : (
<>
<div className="contents">
{intent.intentInitError && (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200">
<span className="line-clamp-2">对话初始化失败:{intent.intentInitError}</span>
@@ -1035,7 +1148,7 @@ export default function PositionRoundtablePage({
{startingChain ? "正在保存意图并生成岗位节点…" : "正在恢复岗位协同会话…"}
</div>
)}
</>
</div>
)
) : positionSession && activeNode ? (
<PositionNodeChat
@@ -1050,6 +1163,7 @@ export default function PositionRoundtablePage({
onNodeUpdate={handleNodeUpdate}
onConversationStart={() => setRightPanelOpen(false)}
onPreviewArtifact={handlePreviewArtifact}
onThreadSnapshot={handleNodeThreadSnapshot}
/>
) : (
<PositionNodeEmptyState
@@ -1068,6 +1182,7 @@ export default function PositionRoundtablePage({
session={positionSession}
activeNodeKey={activeNodeKey}
onSelectNode={(node) => {
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 && (
<aside className="flex w-[min(48vw,720px)] min-w-[440px] shrink-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none">
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-4 dark:border-[#2b8ffe]/25">
<header className="hidden h-12 shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-4 dark:border-[#2b8ffe]/25">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-800 dark:text-[#e8edf5]">
{desensitize(sandboxArtifact.name)}
@@ -1170,11 +1287,15 @@ export default function PositionRoundtablePage({
title="关闭沙箱"
/>
</header>
<ArtifactFileDetail
className="min-h-0 flex-1"
filepath={artifactsCtx.selectedArtifact}
threadId={sandboxArtifact.threadId}
/>
<ThreadContext.Provider value={{ thread: sandboxThread, isMock: false }}>
<ArtifactFileDetail
className="min-h-0 flex-1"
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
onClose={closeSandbox}
disableDesensitize
/>
</ThreadContext.Provider>
</aside>
)}
</div>
@@ -1890,7 +2011,7 @@ function BusinessChainFlow({
请先由情报分析岗选择业务链条
</div>
) : (
<>
<div className="contents">
<ChainFlowCanvas
chain={chain}
nodes={nodes}
@@ -1928,7 +2049,7 @@ function BusinessChainFlow({
</div>
</DialogContent>
</Dialog>
</>
</div>
)}
</section>
);
@@ -2911,7 +3032,7 @@ function AnalysisResults({
}
return (
<>
<div className="contents">
<div className="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border">
<div className="flex items-center justify-between">
<div>
@@ -3009,7 +3130,7 @@ function AnalysisResults({
const status = item.status ? nodeStatusMeta(item.status) : null;
const StatusIcon = status?.Icon;
const contents = (
<>
<div className="contents">
<div className="flex items-start gap-3">
<span
className={cn(
@@ -3064,7 +3185,7 @@ function AnalysisResults({
{displayedTime}
</span>
</footer>
</>
</div>
);
return item.node ? (
<button
@@ -3132,7 +3253,7 @@ function AnalysisResults({
onReasonChange={setRejectReason}
onSubmit={() => void handleRejectSubmit()}
/>
</>
</div>
);
}
@@ -3210,14 +3331,14 @@ function DeliverableDetailDialog({
时间:{displayedTime || "暂无"}
</div>
{item.node && (
<>
<div className="contents">
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
版本:第 {item.node.revision} 版
</div>
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
驳回次数:{item.node.rejectionCount}
</div>
</>
</div>
)}
</div>