Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-dev
This commit is contained in:
commit
f9a5b5bc1e
6 files changed
+851
-86
No files matched your search
@@ -123,6 +123,7 @@ export function ArtifactFileDetail({
|
|||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
threadId,
|
threadId,
|
||||||
|
mimeType = null,
|
||||||
onClose,
|
onClose,
|
||||||
allowVirtualMarkdownEdit = false,
|
allowVirtualMarkdownEdit = false,
|
||||||
disableDesensitize = false,
|
disableDesensitize = false,
|
||||||
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
|
|||||||
className?: string;
|
className?: string;
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
/** Server/tool supplied type wins over path guessing when available. */
|
||||||
|
mimeType?: string | null;
|
||||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -153,12 +156,34 @@ export function ArtifactFileDetail({
|
|||||||
const isWriteFileVirtual = useMemo(() => {
|
const isWriteFileVirtual = useMemo(() => {
|
||||||
return filepathFromProps.startsWith("write-file:");
|
return filepathFromProps.startsWith("write-file:");
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
|
// A streamed tool call can be opened before the path has been normalized by
|
||||||
|
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||||||
|
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||||||
|
const writeFileLanguageHint = useMemo(() => {
|
||||||
|
if (!isWriteFileVirtual) return null;
|
||||||
|
try {
|
||||||
|
const language = new URL(filepathFromProps).searchParams.get("language");
|
||||||
|
return language === "markdown" ? language : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [filepathFromProps, isWriteFileVirtual]);
|
||||||
|
const mimeLanguageHint = useMemo(() => {
|
||||||
|
const normalized = mimeType?.toLowerCase().trim() ?? "";
|
||||||
|
if (normalized.includes("markdown")) return "markdown";
|
||||||
|
if (normalized.includes("html")) return "html";
|
||||||
|
if (normalized.includes("mermaid")) return "mermaid";
|
||||||
|
if (normalized.startsWith("text/")) return "text";
|
||||||
|
return null;
|
||||||
|
}, [mimeType]);
|
||||||
const filepath = useMemo(() => {
|
const filepath = useMemo(() => {
|
||||||
if (isWriteFileVirtual) {
|
if (isWriteFileVirtual) {
|
||||||
const url = new URL(filepathFromProps);
|
const url = new URL(filepathFromProps);
|
||||||
return decodeURIComponent(url.pathname);
|
return decodeURIComponent(url.pathname).trim();
|
||||||
}
|
}
|
||||||
return filepathFromProps;
|
// Tool arguments may contain harmless surrounding whitespace. Passing it
|
||||||
|
// into extension detection turns `report.md ` into a generic TEXT file.
|
||||||
|
return filepathFromProps.trim();
|
||||||
}, [filepathFromProps, isWriteFileVirtual]);
|
}, [filepathFromProps, isWriteFileVirtual]);
|
||||||
const isSkillFile = useMemo(() => {
|
const isSkillFile = useMemo(() => {
|
||||||
return filepath.endsWith(".skill");
|
return filepath.endsWith(".skill");
|
||||||
@@ -188,15 +213,20 @@ export function ArtifactFileDetail({
|
|||||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||||
const { isCodeFile, language } = useMemo(() => {
|
const { isCodeFile, language } = useMemo(() => {
|
||||||
if (useWriteFileStreaming) {
|
if (useWriteFileStreaming) {
|
||||||
let nextLanguage = checkCodeFile(filepath).language;
|
let nextLanguage =
|
||||||
|
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||||||
nextLanguage ??= "text";
|
nextLanguage ??= "text";
|
||||||
return { isCodeFile: true, language: nextLanguage };
|
return { isCodeFile: true, language: nextLanguage };
|
||||||
}
|
}
|
||||||
if (isSkillFile) {
|
if (isSkillFile) {
|
||||||
return { isCodeFile: true, language: "markdown" };
|
return { isCodeFile: true, language: "markdown" };
|
||||||
}
|
}
|
||||||
return checkCodeFile(filepath);
|
const detected = checkCodeFile(filepath);
|
||||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
if (mimeLanguageHint) {
|
||||||
|
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||||||
|
}
|
||||||
|
return detected;
|
||||||
|
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||||||
const isSupportPreview = useMemo(() => {
|
const isSupportPreview = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
language === "html" ||
|
language === "html" ||
|
||||||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
|
|||||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||||
// 不会被旧的内存 stub 值覆盖回退。
|
// 不会被旧的内存 stub 值覆盖回退。
|
||||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
filepath:
|
||||||
|
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||||
enabled: isCodeFile && !useWriteFileStreaming,
|
enabled: isCodeFile && !useWriteFileStreaming,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ import type { AgentThread, AgentThreadState } from "./types";
|
|||||||
export type ToolEndEvent = {
|
export type ToolEndEvent = {
|
||||||
name: string;
|
name: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
toolCallId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolStartEvent = ToolEndEvent;
|
||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
@@ -49,6 +52,7 @@ export type ThreadStreamOptions = {
|
|||||||
threadMetadata?: Record<string, unknown>;
|
threadMetadata?: Record<string, unknown>;
|
||||||
onStart?: (threadId: string) => void;
|
onStart?: (threadId: string) => void;
|
||||||
onFinish?: (state: AgentThreadState) => void;
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
|
onToolStart?: (event: ToolStartEvent) => void;
|
||||||
onToolEnd?: (event: ToolEndEvent) => void;
|
onToolEnd?: (event: ToolEndEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -449,6 +453,7 @@ export function useThreadStream({
|
|||||||
threadMetadata,
|
threadMetadata,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
onToolStart,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -469,13 +474,14 @@ export function useThreadStream({
|
|||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
onToolStart,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep listeners ref updated with latest callbacks
|
// Keep listeners ref updated with latest callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listeners.current = { onStart, onFinish, onToolEnd };
|
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||||
}, [onStart, onFinish, onToolEnd]);
|
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedThreadId = threadId ?? null;
|
const normalizedThreadId = threadId ?? null;
|
||||||
@@ -545,12 +551,6 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
onLangChainEvent(event) {
|
onLangChainEvent(event) {
|
||||||
markStreamActivity();
|
markStreamActivity();
|
||||||
if (event.event === "on_tool_end") {
|
|
||||||
listeners.current.onToolEnd?.({
|
|
||||||
name: event.name,
|
|
||||||
data: event.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||||
@@ -574,6 +574,30 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
|
||||||
|
// mode. `events` is a separate LangChain trace channel; using it for UI
|
||||||
|
// reactions can miss a tool that the conversation has already rendered.
|
||||||
|
onToolEvent:
|
||||||
|
onToolStart || onToolEnd
|
||||||
|
? (event) => {
|
||||||
|
markStreamActivity();
|
||||||
|
if (event.event === "on_tool_start") {
|
||||||
|
listeners.current.onToolStart?.({
|
||||||
|
name: event.name,
|
||||||
|
data: event.input,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.event === "on_tool_end") {
|
||||||
|
listeners.current.onToolEnd?.({
|
||||||
|
name: event.name,
|
||||||
|
data: event.output,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onUpdateEvent(data) {
|
onUpdateEvent(data) {
|
||||||
markStreamActivity();
|
markStreamActivity();
|
||||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||||
|
|||||||
@@ -132,10 +132,15 @@ PositionRoundtablePage
|
|||||||
|
|
||||||
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
||||||
|
|
||||||
|
**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):**
|
||||||
|
[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md)
|
||||||
|
|
||||||
设计要点:
|
设计要点:
|
||||||
|
|
||||||
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
||||||
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
|
- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
|
||||||
|
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
|
||||||
|
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
|
||||||
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
||||||
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
|||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useEditMessage, useThreadStream } from "@/core/threads/hooks";
|
import {
|
||||||
|
useEditMessage,
|
||||||
|
useThreadStream,
|
||||||
|
type ToolStartEvent,
|
||||||
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThreadState } from "@/core/threads/types";
|
import type { AgentThreadState } from "@/core/threads/types";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
@@ -40,7 +44,6 @@ import {
|
|||||||
readConversationDraft,
|
readConversationDraft,
|
||||||
readConversationMessages,
|
readConversationMessages,
|
||||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||||
|
|
||||||
interface PositionNodeSwitchOption {
|
interface PositionNodeSwitchOption {
|
||||||
node: PositionRoundtableNode;
|
node: PositionRoundtableNode;
|
||||||
agentName?: string | null;
|
agentName?: string | null;
|
||||||
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
|
|||||||
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWriteFilePath(path: string): string {
|
||||||
|
let normalized = path.trim();
|
||||||
|
const first = normalized[0];
|
||||||
|
const last = normalized.at(-1);
|
||||||
|
if (
|
||||||
|
normalized.length >= 2 &&
|
||||||
|
((first === '"' && last === '"') ||
|
||||||
|
(first === "'" && last === "'") ||
|
||||||
|
(first === "`" && last === "`"))
|
||||||
|
) {
|
||||||
|
normalized = normalized.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownPath(path: string): boolean {
|
||||||
|
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
|
||||||
|
return pathname.endsWith(".md") || pathname.endsWith(".markdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompleteArtifactExtension(path: string): boolean {
|
||||||
|
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? "";
|
||||||
|
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||||
|
// During JSON argument streaming a Markdown path arrives as `.m` and then
|
||||||
|
// `.md`. Opening on the partial suffix permanently labels the preview TEXT.
|
||||||
|
// Extensionless files still open from on_tool_start once the full path is known.
|
||||||
|
return /\.[a-z0-9]{2,12}$/i.test(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `write_file` arguments stream as one JSON object and the key order is up to
|
||||||
|
* the model. Agents that emit `content` before `path` leave the file nameless
|
||||||
|
* for as long as the body takes to write — a minute for a full report. The
|
||||||
|
* sandbox follows a streaming file by message/tool-call id, not by path, so it
|
||||||
|
* can open under a provisional name and adopt the real one later. Sniff the
|
||||||
|
* body so the provisional name at least carries the right extension.
|
||||||
|
*/
|
||||||
|
function provisionalWriteFilePath(content: string): string {
|
||||||
|
const head = content.slice(0, 256).trimStart().toLowerCase();
|
||||||
|
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
||||||
|
return "未命名文件.html";
|
||||||
|
}
|
||||||
|
return "未命名文件.md";
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFilePathFromToolEvent(data: unknown, depth = 0): string | null {
|
||||||
|
if (depth > 3 || data == null) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
const value = data.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
if (value.startsWith("{") || value.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
return writeFilePathFromToolEvent(JSON.parse(value), depth + 1);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof data !== "object" || Array.isArray(data)) return null;
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
if (typeof record.path === "string") {
|
||||||
|
const path = normalizeWriteFilePath(record.path);
|
||||||
|
return path || null;
|
||||||
|
}
|
||||||
|
for (const key of ["input", "inputs", "args", "kwargs"]) {
|
||||||
|
const path = writeFilePathFromToolEvent(record[key], depth + 1);
|
||||||
|
if (path) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
|
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
|
||||||
|
const language = isMarkdownPath(path) ? "&language=markdown" : "";
|
||||||
return new URL(
|
return new URL(
|
||||||
`write-file:${path}?message_id=${encodeURIComponent(
|
`write-file:${path}?message_id=${encodeURIComponent(
|
||||||
messageId,
|
messageId,
|
||||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
)}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
|
||||||
).toString();
|
).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
|
||||||
|
return name === "write_file" || name === "str_replace";
|
||||||
|
}
|
||||||
|
|
||||||
function latestStreamingWriteFile(
|
function latestStreamingWriteFile(
|
||||||
messages: AgentThreadState["messages"],
|
messages: AgentThreadState["messages"],
|
||||||
): { key: string; path: string; name: string; mimeType: string | null } | null {
|
): {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
sourcePath: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string | null;
|
||||||
|
} | null {
|
||||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
const message = messages[messageIndex] as (typeof messages)[number] & {
|
const message = messages[messageIndex] as (typeof messages)[number] & {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
|
|||||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||||
const toolCall = message.tool_calls[toolIndex];
|
const toolCall = message.tool_calls[toolIndex];
|
||||||
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
|
if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
|
||||||
const rawPath = toolCall.args?.path;
|
const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
|
||||||
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
|
const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
|
||||||
const content = toolCall.args?.content;
|
const normalizedPath = normalizeWriteFilePath(rawPath);
|
||||||
if (typeof content !== "string" || content.length === 0) continue;
|
const hasUsablePath =
|
||||||
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
|
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||||
|
if (!hasUsablePath) {
|
||||||
|
// `str_replace` edits an existing file, so it can only be previewed once
|
||||||
|
// its real path is known. A new file is identified by its tool call, so
|
||||||
|
// its body can be previewed as soon as the first characters arrive.
|
||||||
|
if (toolCall.name !== "write_file" || !rawContent.trim()) continue;
|
||||||
|
}
|
||||||
|
const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent);
|
||||||
|
// Do not wait for the complete `content` argument. LangGraph streams
|
||||||
|
// write_file arguments incrementally, and the virtual artifact loader
|
||||||
|
// will redraw as `content` arrives. Waiting for it turns this into a
|
||||||
|
// post-generation preview instead of a live sandbox.
|
||||||
|
const previewPath =
|
||||||
|
toolCall.name === "write_file"
|
||||||
|
? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id)
|
||||||
|
: sourcePath;
|
||||||
return {
|
return {
|
||||||
key: `${message.id}:${toolCall.id}`,
|
key: `${message.id}:${toolCall.id}`,
|
||||||
path: virtualPath,
|
path: previewPath,
|
||||||
name: artifactDisplayName(rawPath),
|
sourcePath,
|
||||||
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
|
name: artifactDisplayName(sourcePath),
|
||||||
|
mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
|
|||||||
};
|
};
|
||||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||||
for (const toolCall of message.tool_calls) {
|
for (const toolCall of message.tool_calls) {
|
||||||
if (toolCall?.name === "write_file" && toolCall.id) {
|
if (isArtifactWriteTool(toolCall?.name) && toolCall.id) {
|
||||||
keys.add(`${message.id}:${toolCall.id}`);
|
keys.add(`${message.id}:${toolCall.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,11 +367,21 @@ export function PositionNodeChat({
|
|||||||
);
|
);
|
||||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||||
|
const previousNodeBindingRef = useRef({
|
||||||
|
nodeKey: node.nodeKey,
|
||||||
|
threadId: node.threadId,
|
||||||
|
});
|
||||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
||||||
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
||||||
const autoPreviewRunArmedRef = useRef(false);
|
const autoPreviewRunArmedRef = useRef(false);
|
||||||
|
const writeFilePreviewSequenceRef = useRef(0);
|
||||||
|
const activeWriteFilePreviewRef = useRef<{
|
||||||
|
path: string;
|
||||||
|
previewKey: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
} | null>(null);
|
||||||
const editMessage = useEditMessage();
|
const editMessage = useEditMessage();
|
||||||
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
||||||
const persistedMessages = useMemo(
|
const persistedMessages = useMemo(
|
||||||
@@ -288,12 +400,28 @@ export function PositionNodeChat({
|
|||||||
!node.invalidatedBy;
|
!node.invalidatedBy;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const previousBinding = previousNodeBindingRef.current;
|
||||||
|
previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId };
|
||||||
|
// `onStart` binds a newly created LangGraph thread before the session API
|
||||||
|
// persists that id back into `node.threadId`. That prop update happens
|
||||||
|
// while the first turn is already streaming; it is not a node switch and
|
||||||
|
// must not clear the preview run that has just been armed.
|
||||||
|
const isOwnThreadBinding =
|
||||||
|
previousBinding.nodeKey === node.nodeKey &&
|
||||||
|
previousBinding.threadId == null &&
|
||||||
|
node.threadId != null &&
|
||||||
|
node.threadId === boundThreadIdRef.current;
|
||||||
|
if (isOwnThreadBinding) return;
|
||||||
|
|
||||||
setBoundThreadId(node.threadId);
|
setBoundThreadId(node.threadId);
|
||||||
boundThreadIdRef.current = node.threadId;
|
boundThreadIdRef.current = node.threadId;
|
||||||
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
||||||
setMessageContentOverrides({});
|
setMessageContentOverrides({});
|
||||||
|
autoPreviewedArtifactKeyRef.current = null;
|
||||||
autoPreviewBaselineCallKeysRef.current = new Set();
|
autoPreviewBaselineCallKeysRef.current = new Set();
|
||||||
autoPreviewRunArmedRef.current = false;
|
autoPreviewRunArmedRef.current = false;
|
||||||
|
writeFilePreviewSequenceRef.current = 0;
|
||||||
|
activeWriteFilePreviewRef.current = null;
|
||||||
// The component is keyed by nodeKey in the page. This reset is therefore
|
// The component is keyed by nodeKey in the page. This reset is therefore
|
||||||
// only a defensive guard for callers that reuse the component instance.
|
// only a defensive guard for callers that reuse the component instance.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -314,6 +442,43 @@ export function PositionNodeChat({
|
|||||||
[node.nodeKey, onNodeUpdate, sessionId],
|
[node.nodeKey, onNodeUpdate, sessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleToolStart = useCallback(
|
||||||
|
(event: ToolStartEvent) => {
|
||||||
|
if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return;
|
||||||
|
const path = writeFilePathFromToolEvent(event.data);
|
||||||
|
const threadId = boundThreadIdRef.current;
|
||||||
|
if (!path || !threadId) return;
|
||||||
|
|
||||||
|
// Message chunks and on_tool_start can describe the same invocation in
|
||||||
|
// either order. Reuse the first preview key so a manual close suppresses
|
||||||
|
// only this write, while a later rewrite receives a fresh key and opens.
|
||||||
|
const activePreview = activeWriteFilePreviewRef.current;
|
||||||
|
if (
|
||||||
|
activePreview?.path === path &&
|
||||||
|
(activePreview.toolCallId === event.toolCallId ||
|
||||||
|
(event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`)))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewKey = event.toolCallId
|
||||||
|
? `tool-start:${event.toolCallId}`
|
||||||
|
: `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`;
|
||||||
|
activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId };
|
||||||
|
const artifactKey = `${threadId}:${previewKey}`;
|
||||||
|
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||||
|
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||||
|
onPreviewArtifact?.({
|
||||||
|
threadId,
|
||||||
|
path,
|
||||||
|
name: artifactDisplayName(path),
|
||||||
|
mimeType: isMarkdownPath(path) ? "text/markdown" : null,
|
||||||
|
previewKey,
|
||||||
|
auto: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[node.nodeKey, onPreviewArtifact],
|
||||||
|
);
|
||||||
|
|
||||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||||
threadId: boundThreadId,
|
threadId: boundThreadId,
|
||||||
assistantId: node.agentId,
|
assistantId: node.agentId,
|
||||||
@@ -334,15 +499,21 @@ export function PositionNodeChat({
|
|||||||
agent_id: node.agentId,
|
agent_id: node.agentId,
|
||||||
},
|
},
|
||||||
onStart: bindCreatedThread,
|
onStart: bindCreatedThread,
|
||||||
|
onToolStart: handleToolStart,
|
||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
// A turn is not considered delivered by the UI alone. The persisted
|
// A turn is not considered delivered by the UI alone. The persisted
|
||||||
// completion call records the answer, file manifest and upstream version
|
// completion call records the answer, file manifest and upstream version
|
||||||
// so the service can decide whether this seat unlocks its downstream.
|
// so the service can decide whether this seat unlocks its downstream.
|
||||||
const threadId = boundThreadIdRef.current;
|
const threadId = boundThreadIdRef.current;
|
||||||
const latestAi = [...(state.messages ?? [])]
|
// `present_files` is often the last AI tool call and has no prose of
|
||||||
|
// its own. Keep walking backwards to the last actual answer; otherwise
|
||||||
|
// a successful file delivery would return early here and never refresh
|
||||||
|
// the node's artifact cards.
|
||||||
|
const latestAnswer = [...(state.messages ?? [])]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.type === "ai");
|
.filter((message) => message.type === "ai")
|
||||||
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
|
.map((message) => textOfMessage(message) ?? "")
|
||||||
|
.find((content) => content.trim()) ?? "";
|
||||||
if (!threadId || !latestAnswer.trim()) return;
|
if (!threadId || !latestAnswer.trim()) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||||
@@ -378,6 +549,10 @@ export function PositionNodeChat({
|
|||||||
|
|
||||||
const activeThreadId =
|
const activeThreadId =
|
||||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||||
|
// A sandbox must always receive the server thread id. In particular, do not
|
||||||
|
// attach a streaming preview to the provisional UUID used for a new node
|
||||||
|
// before `useThreadStream` has created and bound its thread.
|
||||||
|
const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||||
const persistedThreadId =
|
const persistedThreadId =
|
||||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||||
const recoveredMessages = useMemo(
|
const recoveredMessages = useMemo(
|
||||||
@@ -566,32 +741,60 @@ export function PositionNodeChat({
|
|||||||
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
||||||
[thread.isLoading, thread.messages],
|
[thread.isLoading, thread.messages],
|
||||||
);
|
);
|
||||||
|
const newStreamingArtifact =
|
||||||
|
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||||
|
? streamingArtifact
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!thread.isLoading ||
|
!thread.isLoading ||
|
||||||
!streamingArtifact ||
|
!newStreamingArtifact ||
|
||||||
!autoPreviewRunArmedRef.current ||
|
!previewThreadId ||
|
||||||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
!autoPreviewRunArmedRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// One write_file call should open at most one preview. The parent further
|
// 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.
|
// guards manual close state, because it owns the page-wide singleton sandbox.
|
||||||
// A report is often rewritten to the same output path. The tool-call key
|
// A report is often rewritten to the same output path — identity must be the
|
||||||
// (not just the path) makes that a new previewable delivery.
|
// tool-call key, not the path, otherwise a second write keeps the old panel
|
||||||
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
|
// and the sandbox never clears / remounts for the rewrite.
|
||||||
|
const toolCallKey = newStreamingArtifact.key;
|
||||||
|
const toolCallId = toolCallKey.includes(":")
|
||||||
|
? toolCallKey.slice(toolCallKey.lastIndexOf(":") + 1)
|
||||||
|
: undefined;
|
||||||
|
const startedPreview = activeWriteFilePreviewRef.current;
|
||||||
|
const isSameToolCall =
|
||||||
|
startedPreview != null &&
|
||||||
|
(startedPreview.previewKey === toolCallKey ||
|
||||||
|
(toolCallId != null &&
|
||||||
|
(startedPreview.toolCallId === toolCallId ||
|
||||||
|
startedPreview.previewKey.endsWith(`:${toolCallId}`))));
|
||||||
|
// Same tool call may upgrade a provisional filename → real path; keep the
|
||||||
|
// open panel. A different tool call is a new delivery even on the same path.
|
||||||
|
const previewKey = isSameToolCall ? startedPreview.previewKey : toolCallKey;
|
||||||
|
activeWriteFilePreviewRef.current = {
|
||||||
|
path: newStreamingArtifact.sourcePath,
|
||||||
|
previewKey,
|
||||||
|
toolCallId: toolCallId ?? startedPreview?.toolCallId,
|
||||||
|
};
|
||||||
|
// The path takes part in the identity so that a preview opened under a
|
||||||
|
// provisional name is re-published once the real path streams in. The
|
||||||
|
// preview key stays the same, so the parent treats it as an update of the
|
||||||
|
// open panel rather than a second delivery.
|
||||||
|
const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`;
|
||||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||||
onPreviewArtifact?.({
|
onPreviewArtifact?.({
|
||||||
threadId: activeThreadId,
|
threadId: previewThreadId,
|
||||||
path: streamingArtifact.path,
|
path: newStreamingArtifact.path,
|
||||||
name: streamingArtifact.name,
|
name: newStreamingArtifact.name,
|
||||||
mimeType: streamingArtifact.mimeType,
|
mimeType: newStreamingArtifact.mimeType,
|
||||||
previewKey: streamingArtifact.key,
|
previewKey,
|
||||||
auto: true,
|
auto: true,
|
||||||
});
|
});
|
||||||
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
|
}, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
|
||||||
|
|
||||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||||
// watching the actual stream id as well closes the last race where a page
|
// watching the actual stream id as well closes the last race where a page
|
||||||
@@ -628,6 +831,7 @@ export function PositionNodeChat({
|
|||||||
: null;
|
: null;
|
||||||
// A thread retains historical write_file tool calls. Only files whose
|
// A thread retains historical write_file tool calls. Only files whose
|
||||||
// call id appears after this exact submission may open the sandbox.
|
// call id appears after this exact submission may open the sandbox.
|
||||||
|
activeWriteFilePreviewRef.current = null;
|
||||||
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
||||||
autoPreviewRunArmedRef.current = true;
|
autoPreviewRunArmedRef.current = true;
|
||||||
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# 岗位会商沙箱对接指南(重做页面 / 排障)
|
||||||
|
|
||||||
|
## 文档用途
|
||||||
|
|
||||||
|
给**下次重做或对接岗位会商页面**的同事用,也给线上「沙箱不实时 / 卡死 / 第二次不刷新」时排障用。
|
||||||
|
|
||||||
|
读完应能回答:
|
||||||
|
|
||||||
|
1. 新页面要接哪几个回调、传什么字段?
|
||||||
|
2. 打开 / 关闭沙箱各在什么时机?和步骤条是什么关系?
|
||||||
|
3. 哪些写法绝对不能用(会复现历史 bug)?
|
||||||
|
4. 出问题时按什么顺序查?
|
||||||
|
|
||||||
|
对照实现(当前生产路径):
|
||||||
|
|
||||||
|
| 角色 | 文件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 页面壳(沙箱唯一宿主) | `pages/PositionRoundtablePage.tsx` |
|
||||||
|
| 节点对话(探测 write_file、上报预览) | `components/PositionNodeChat.tsx` |
|
||||||
|
| 收口对话 | `components/PositionBuiltinAgentChat.tsx` |
|
||||||
|
| 预览 UI(共享组件,勿分叉逻辑) | `components/workspace/artifacts/artifact-file-detail.tsx` |
|
||||||
|
| 流式正文读取 | `core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
|
||||||
|
| 普通对话页对照(步骤条自动开) | `components/workspace/messages/message-group.tsx` |
|
||||||
|
|
||||||
|
模块总览见同目录上级 [`../README.md`](../README.md);本文只覆盖**文件预览沙箱**这一条链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 验收标准(对接完成即通过)
|
||||||
|
|
||||||
|
重做页面后,下列行为必须全部成立(与普通对话页用户感知一致):
|
||||||
|
|
||||||
|
| # | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| A | 步骤条刚出现「写入文件」时,右侧沙箱应已打开(不必等文件名出现) |
|
||||||
|
| B | 沙箱内源码随模型输出边写边滚;消息列表文字也实时出现,不能整页卡死 |
|
||||||
|
| C | 模型若先吐正文、后吐 `path`:先用临时名打开,path 到了再改名,不闪屏重挂 |
|
||||||
|
| D | 同一次问答里第二次 `write_file` 写同一路径(「重新生成」):沙箱清空并重新流式 |
|
||||||
|
| E | 整轮 `isLoading=false` 后,自动从源码切到 Markdown 预览 |
|
||||||
|
| F | 用户手动关闭后,**同一次**写入不再自动弹;**下一轮**新写入仍可自动开 |
|
||||||
|
| G | 切岗位 / 切节点 / 新建或切换会话时,沙箱关掉且不串内容 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 职责划分(谁干什么)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 页面壳(PositionRoundtablePage 或你的新页面) │
|
||||||
|
│ · 唯一 sandboxArtifact 状态 │
|
||||||
|
│ · handlePreviewArtifact / closeSandbox │
|
||||||
|
│ · 流式线程外部 store(禁止整页 useState) │
|
||||||
|
│ · 渲染唯一 ArtifactFileDetail │
|
||||||
|
└──────────────────────────▲──────────────────────────────────┘
|
||||||
|
│ onPreviewArtifact / onThreadSnapshot
|
||||||
|
┌──────────────────────────┴──────────────────────────────────┐
|
||||||
|
│ 对话子组件(PositionNodeChat 等) │
|
||||||
|
│ · useThreadStream 拉消息 │
|
||||||
|
│ · 渲染 MessageList / 步骤条(只展示,不直接开沙箱) │
|
||||||
|
│ · 探测 write_file → 组装 PositionArtifactPreview 上报 │
|
||||||
|
│ · 每次 thread 变化上报 onThreadSnapshot │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
硬性规则:
|
||||||
|
|
||||||
|
1. **沙箱只有一个实例**,挂在页面壳上,不要在对话子组件里再挂 `ArtifactFileDetail`。
|
||||||
|
2. 对话子组件**不要**直接 `artifacts.setOpen(true)` 当作岗位会商的主路径(页面壳统一开合,才能做关闭抑制、切节点清理)。
|
||||||
|
3. 步骤条和沙箱是**两条线**:步骤条只反映 tool call 展示;开沙箱走 `onPreviewArtifact`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 对接接口(重新接页面时照此接线)
|
||||||
|
|
||||||
|
### 3.1 对话子组件 → 页面壳
|
||||||
|
|
||||||
|
#### `onPreviewArtifact(artifact)`
|
||||||
|
|
||||||
|
请求打开或更新沙箱。载荷:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PositionArtifactPreview = {
|
||||||
|
threadId: string; // 必填:LangGraph thread id
|
||||||
|
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名
|
||||||
|
name: string; // 展示名
|
||||||
|
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
|
||||||
|
previewKey?: string; // 强烈建议:一次交付的身份,推荐 `${messageId}:${toolCallId}`
|
||||||
|
auto?: boolean; // true = 流式自动打开;false/缺省 = 用户点击打开
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
页面壳收到后应:
|
||||||
|
|
||||||
|
1. `auto===true` 且 `closedSandboxKey === sandboxCloseKey(artifact)` → **直接 return**(用户刚关过这一次)。
|
||||||
|
2. 更新页面级 `sandboxArtifact`(并同步 ref,见下)。
|
||||||
|
3. 同步 `ArtifactsContext.select(path) + setOpen(true)`(与共享预览组件生命周期对齐)。
|
||||||
|
4. 若已有该 thread 的最新快照,立刻推进沙箱 store。
|
||||||
|
5. React `key` 用 `sandboxCloseKey(artifact)`(有 `previewKey` 时优先它),保证新交付会重挂。
|
||||||
|
|
||||||
|
`sandboxCloseKey` 约定:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 有 previewKey:threadId:write:${previewKey}
|
||||||
|
// 无 previewKey:threadId:${normalizePath(path)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `onThreadSnapshot({ threadId, thread })`
|
||||||
|
|
||||||
|
把当前 `useThreadStream` 的 thread 对象持续交给页面壳,供沙箱读流式 `content`。
|
||||||
|
|
||||||
|
页面壳应:
|
||||||
|
|
||||||
|
1. 始终写入 `latestNodeThreadSnapshotRef`(打开瞬间可能用到)。
|
||||||
|
2. **仅当**当前沙箱 `threadId` 与 snapshot 一致时,才 `queue` → store `publish`。
|
||||||
|
3. 节流:前沿 160ms(见第 5 节),不要纯尾随 `setTimeout`。
|
||||||
|
|
||||||
|
### 3.2 页面壳 → 预览组件
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{sandboxOpen && sandboxArtifact && (
|
||||||
|
<SandboxThreadProvider store={store} threadId={sandboxArtifact.threadId} fallbackThread={stub}>
|
||||||
|
<ArtifactFileDetail
|
||||||
|
key={sandboxCloseKey(sandboxArtifact)}
|
||||||
|
filepath={sandboxArtifact.path}
|
||||||
|
threadId={sandboxArtifact.threadId}
|
||||||
|
mimeType={sandboxArtifact.mimeType}
|
||||||
|
onClose={closeSandbox}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
</SandboxThreadProvider>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
流式正文依赖虚拟 URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
write-file:${path}?message_id=...&tool_call_id=...&language=markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
`loadArtifactContentFromToolCall` **只认** URL 里的 `message_id` + `tool_call_id`,从 `thread.messages` 对应 tool call 的 `args.content` 取值。
|
||||||
|
因此:**可以没有真实磁盘 path 就开沙箱**;path 主要用于文件名展示和语言猜测。
|
||||||
|
|
||||||
|
### 3.3 对话子组件内部必做状态(可复用现有逻辑)
|
||||||
|
|
||||||
|
重新实现探测时,至少保留这些语义(不必同名,语义要对):
|
||||||
|
|
||||||
|
| 状态 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `armed` | 仅本轮用户 `send` 成功武装后才允许自动 `onPreviewArtifact` |
|
||||||
|
| `baselineCallKeys` | send 前已有的 `messageId:toolCallId` 集合;忽略历史 write,防止一提问就开旧文件 |
|
||||||
|
| `activePreview` | 当前已打开的 previewKey / path / toolCallId,用于 path 升级与同 call 去重 |
|
||||||
|
| 线程绑定保护 | `node.threadId` 从 null→本节点刚创建的 id 时,**不要** disarm |
|
||||||
|
|
||||||
|
发送时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baseline = 当前历史 write keys
|
||||||
|
armed = true
|
||||||
|
activePreview = null
|
||||||
|
→ sendMessage(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
探测时(`isLoading`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
找到最新 write_file / str_replace
|
||||||
|
若不在 baseline:
|
||||||
|
- 有完整扩展名 path → 用真实 path
|
||||||
|
- write_file 且仅有 content → 临时名(未命名文件.md / .html)
|
||||||
|
- str_replace 无 path → 先不报
|
||||||
|
previewKey = 同 tool call 复用,否则新 key
|
||||||
|
→ onPreviewArtifact({ ..., auto: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 打开 / 关闭时序(对接时按此实现)
|
||||||
|
|
||||||
|
### 4.1 打开(自动)
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户发送
|
||||||
|
→ armed=true + 拍 baseline
|
||||||
|
→ 模型开始 write_file
|
||||||
|
→ 步骤条出现「写入文件」(可能还没有 path)
|
||||||
|
→ content 或完整 path 任一满足探测条件
|
||||||
|
→ onPreviewArtifact(auto)
|
||||||
|
→ 页面壳打开 ArtifactFileDetail + 推送 thread 快照
|
||||||
|
→ 源码视图滚动更新
|
||||||
|
→(可选)真实 path 到达:同 previewKey 改名(path upgrade,不重挂)
|
||||||
|
→ isLoading=false:共享组件自动切预览
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 同轮第二次写入(重新生成)
|
||||||
|
|
||||||
|
```text
|
||||||
|
write_file #1 → previewKey = msg:tc1 → 打开
|
||||||
|
write_file #2 同 path → previewKey = msg:tc2(必须变)
|
||||||
|
→ React key 变 → 组件重挂 → 视觉上清空再生成
|
||||||
|
```
|
||||||
|
|
||||||
|
**禁止**用「path 相同 ⇒ 复用 previewKey」。那会导致步骤条有「重新生成」、沙箱却不动。
|
||||||
|
|
||||||
|
### 4.3 关闭
|
||||||
|
|
||||||
|
| 触发 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 用户点关闭 | `closedSandboxKeyRef = sandboxCloseKey(current)`;清 artifact / store / 定时器 |
|
||||||
|
| 切岗位、切节点、新会话、切历史 | 先 `closeSandbox()`(可按产品决定是否写入抑制键;至少清面板) |
|
||||||
|
| 自动打开被抑制 | 仅当 `auto && closedKey === 当前 key`;手动打开应清抑制 |
|
||||||
|
|
||||||
|
抑制粒度必须是**单次交付(previewKey)**,不能是「整个 thread 永远不再自动开」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 重做页面时的必守规则(违反必出 bug)
|
||||||
|
|
||||||
|
1. **流式 thread 快照禁止进页面级 `useState`**
|
||||||
|
大页面每秒重渲染数次会占满主线程:消息列表停更、沙箱冻住、定时器饿死。
|
||||||
|
正确:外部 store + `useSyncExternalStore`,只让沙箱子树订阅。
|
||||||
|
|
||||||
|
2. **节流用前沿(leading-edge),不要纯尾随 `setTimeout`**
|
||||||
|
距上次 publish ≥ 160ms → 同步 publish;否则补尾定时器。
|
||||||
|
纯「有 timer 就 return」在忙主线程下会十几秒不 flush。
|
||||||
|
|
||||||
|
3. **不要等完整 `path` 才开沙箱**
|
||||||
|
报告类 agent 常先流 `content`、最后才流 `path`,硬等会晚几十秒。
|
||||||
|
`write_file` + 已有 content → 临时名打开;`str_replace` 仍等真实 path。
|
||||||
|
|
||||||
|
4. **扩展名要完整再当「真实 path」**
|
||||||
|
流式中 `.m` → `.md`,过早打开会把语言锁成 TEXT。
|
||||||
|
|
||||||
|
5. **previewKey 按 tool call,不按 path**
|
||||||
|
同 path 多次写入 = 多次交付。
|
||||||
|
|
||||||
|
6. **打开瞬间同步推进 `sandboxArtifactRef`**
|
||||||
|
若 flush 快照时只看 ref、而 setState 尚未提交,首帧会被「threadId 不匹配」丢掉。
|
||||||
|
|
||||||
|
7. **不要依赖 `onToolStart` 作为唯一打开手段**
|
||||||
|
当前后端对部分 stream mode 可能过滤;主路径必须是 `thread.messages` 里渐进出现的 `tool_calls`。
|
||||||
|
|
||||||
|
8. **path upgrade 不要当第二次打开**
|
||||||
|
同 previewKey 改 path:更新 artifact,不要乱关抽屉 / 不要重挂。
|
||||||
|
|
||||||
|
9. **「结束后才切预览」不是打开 bug**
|
||||||
|
`ArtifactFileDetail` 对 markdown/html 在 `isLoading` 期间强制源码视图,与普通对话页相同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 步骤条怎么对接理解
|
||||||
|
|
||||||
|
步骤条来自消息里的 tool call 渲染(`message-group`),标题优先 `args.description`,否则文案「写入文件」;子行显示 `args.path`。
|
||||||
|
|
||||||
|
| 你看到的步骤条 | 含义 | 沙箱应处于 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仅「写入文件」,无路径 | tool call 已开始,path 可能还没流到 | **应已打开**(临时名) |
|
||||||
|
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
|
||||||
|
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
|
||||||
|
| 生成中顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
|
||||||
|
|
||||||
|
对接时不要把「步骤条出现文件名」当成「开沙箱的唯一信号」——那会在 content-first 模型上系统性偏晚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 排障手册(按症状查)
|
||||||
|
|
||||||
|
### 7.1 沙箱开得很晚 / 要等文件名才开
|
||||||
|
|
||||||
|
检查顺序:
|
||||||
|
|
||||||
|
1. 探测是否仍要求「必须有完整 path」?→ 改为 content 可开临时名。
|
||||||
|
2. `armed` 是否在 `threadId` 回写时被误清?→ 保护「本节点绑定自己的新 thread」。
|
||||||
|
3. 是否被 `closedSandboxKey` 抑制?→ 看是否刚关过同 previewKey。
|
||||||
|
4. 新 call 是否落在 baseline 里?→ baseline 只能在 send 时拍一次。
|
||||||
|
|
||||||
|
### 7.2 沙箱打开了但内容空白 / 冻住,生成完才一口气出来
|
||||||
|
|
||||||
|
1. 快照是否写进了页面 `useState`?→ 改 store。
|
||||||
|
2. 节流是否纯尾随定时器?→ 改前沿。
|
||||||
|
3. `SandboxThreadProvider` 是否把带 `messages` 的 thread 传给了 `ArtifactFileDetail`?
|
||||||
|
4. 虚拟 URL 的 `tool_call_id` 是否与消息里一致?
|
||||||
|
5. 打开时 `sandboxArtifactRef` 是否已同步(避免首帧 flush 被丢)?
|
||||||
|
|
||||||
|
### 7.3 消息列表也不实时,整页很卡
|
||||||
|
|
||||||
|
优先怀疑页面壳在流式频率下整页重渲染。
|
||||||
|
确认没有把 `onThreadSnapshot` 落到大页面 state;临时 `console.debug` 每 chunk 打大对象也会加重(排障完删掉)。
|
||||||
|
|
||||||
|
### 7.4 第二次「重新生成」步骤有了,沙箱不变
|
||||||
|
|
||||||
|
previewKey 是否按 path 复用了?→ 必须按 `messageId:toolCallId`。
|
||||||
|
`ArtifactFileDetail` 的 `key` 是否跟 previewKey 走?否则不会重挂。
|
||||||
|
|
||||||
|
### 7.5 第一次实时开,关掉后再问就不实时 / 很晚才开
|
||||||
|
|
||||||
|
抑制是否绑在整个 `threadId` 上?→ 改为单次 `sandboxCloseKey(previewKey)`。
|
||||||
|
|
||||||
|
### 7.6 预览变成纯文本 / 不像 Markdown
|
||||||
|
|
||||||
|
1. 是否在 `.m` 阶段就当完整 path 打开了?
|
||||||
|
2. 临时名是否带 `.md` / URL 是否带 `language=markdown` / 是否传了 `mimeType`?
|
||||||
|
3. path 是否有首尾空格?
|
||||||
|
|
||||||
|
### 7.7 只靠 onToolStart,线上偶发永远不开
|
||||||
|
|
||||||
|
后端可能不推 tools/events。主路径改为扫描 `thread.messages` 的 streaming `tool_calls`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 对接自测清单(重做页面后必跑)
|
||||||
|
|
||||||
|
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
|
||||||
|
- [ ] content-first agent:无文件名阶段是否也能开?
|
||||||
|
- [ ] path 到达后:是否只改名、不整页闪?
|
||||||
|
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
|
||||||
|
- [ ] 流式中消息列表是否仍实时?页面是否明显卡顿?
|
||||||
|
- [ ] 手动关闭后本次是否不再弹?下一轮是否仍可自动开?
|
||||||
|
- [ ] 结束后是否自动切 Markdown 预览?
|
||||||
|
- [ ] 切节点 / 切岗位 / 新会话:沙箱是否关闭且内容不串?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 最小接线伪代码(新页面可抄)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 页面壳
|
||||||
|
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
||||||
|
const sandboxArtifactRef = useRef(sandboxArtifact);
|
||||||
|
const closedSandboxKeyRef = useRef<string | null>(null);
|
||||||
|
const store = useMemo(createSandboxThreadStore, []);
|
||||||
|
|
||||||
|
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
|
||||||
|
const key = sandboxCloseKey(artifact);
|
||||||
|
if (artifact.auto && closedSandboxKeyRef.current === key) return;
|
||||||
|
|
||||||
|
const prev = sandboxArtifactRef.current;
|
||||||
|
const sameKey = prev?.previewKey && prev.previewKey === artifact.previewKey
|
||||||
|
&& prev.threadId === artifact.threadId;
|
||||||
|
const pathOnlyUpgrade = sameKey && prev.path !== artifact.path;
|
||||||
|
|
||||||
|
sandboxArtifactRef.current = artifact;
|
||||||
|
setSandboxArtifact(artifact);
|
||||||
|
// publish latest thread snapshot into store (leading-edge throttle)
|
||||||
|
// ArtifactsContext.select + setOpen(true)
|
||||||
|
if (!pathOnlyUpgrade) closeRightDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSandbox() {
|
||||||
|
if (sandboxArtifactRef.current) {
|
||||||
|
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
|
||||||
|
}
|
||||||
|
sandboxArtifactRef.current = null;
|
||||||
|
setSandboxArtifact(null);
|
||||||
|
store.publish(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话子组件
|
||||||
|
<PositionNodeChat
|
||||||
|
onPreviewArtifact={handlePreviewArtifact}
|
||||||
|
onThreadSnapshot={handleNodeThreadSnapshot}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
对话侧探测逻辑优先**复用** `PositionNodeChat` 内的 `latestStreamingWriteFile` / armed / baseline,而不是从零再写一套——这是最容易写错的部分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 术语
|
||||||
|
|
||||||
|
| 词 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| armed | 本轮用户发送后,允许自动打开沙箱 |
|
||||||
|
| baseline | send 前已有 write tool call 集合,用于忽略历史产物 |
|
||||||
|
| previewKey | 一次交付身份,推荐 `messageId:toolCallId` |
|
||||||
|
| sandboxCloseKey | 关闭抑制键 + React key |
|
||||||
|
| provisional path | 无真实 path 时的临时文件名 |
|
||||||
|
| path upgrade | 同 previewKey 下临时名 → 真实 path |
|
||||||
|
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
|
||||||
|
| write-file: URL | 用 message/tool call id 定位流式 content 的虚拟路径 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 相关提交(背景)
|
||||||
|
|
||||||
|
若需对照修复动机,可查:
|
||||||
|
|
||||||
|
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` — 临时名打开、store、前沿节流
|
||||||
|
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` — previewKey 按 tool call
|
||||||
|
|
||||||
|
排障时优先对照本文第 5、7 节,再对照上述提交的 diff。
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
@@ -336,6 +344,72 @@ function sandboxCloseKey(
|
|||||||
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
|
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SandboxThreadStore = {
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
getSnapshot: () => SandboxThreadSnapshot | null;
|
||||||
|
publish: (next: SandboxThreadSnapshot | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The streaming thread reaches the sandbox through a subscription rather than
|
||||||
|
* page state. This page is large — task panel, intent results, flowchart, node
|
||||||
|
* chat — and a streamed file publishes several times per second. Driving that
|
||||||
|
* through `useState` here re-renders the whole page at that rate and starves the
|
||||||
|
* browser so badly that even the message list stops painting until the run ends.
|
||||||
|
* With a store, only `SandboxThreadProvider` and the preview below it re-render.
|
||||||
|
*/
|
||||||
|
function createSandboxThreadStore(): SandboxThreadStore {
|
||||||
|
let snapshot: SandboxThreadSnapshot | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
publish(next) {
|
||||||
|
const isUnchanged =
|
||||||
|
snapshot === next ||
|
||||||
|
(snapshot != null &&
|
||||||
|
next != null &&
|
||||||
|
snapshot.threadId === next.threadId &&
|
||||||
|
snapshot.signature === next.signature);
|
||||||
|
if (isUnchanged) return;
|
||||||
|
snapshot = next;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SandboxThreadProvider({
|
||||||
|
store,
|
||||||
|
threadId,
|
||||||
|
fallbackThread,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
store: SandboxThreadStore;
|
||||||
|
threadId: string;
|
||||||
|
fallbackThread: BaseStream<AgentThreadState>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
const thread = snapshot?.threadId === threadId ? snapshot.thread : fallbackThread;
|
||||||
|
const value = useMemo(() => ({ thread, isMock: false }), [thread]);
|
||||||
|
return <ThreadContext.Provider value={value}>{children}</ThreadContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on how often the streaming thread is re-published to the sandbox.
|
||||||
|
* Message chunks arrive roughly every 30ms and re-rendering the artifact panel
|
||||||
|
* is expensive, so updates are coalesced — but never postponed past this gap,
|
||||||
|
* otherwise the panel visibly freezes while the file is being written.
|
||||||
|
*/
|
||||||
|
const SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160;
|
||||||
|
|
||||||
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
|
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
|
||||||
const messages = thread.messages ?? [];
|
const messages = thread.messages ?? [];
|
||||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
@@ -872,10 +946,11 @@ export default function PositionRoundtablePage({
|
|||||||
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
||||||
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
|
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
|
||||||
const closedSandboxKeyRef = useRef<string | null>(null);
|
const closedSandboxKeyRef = useRef<string | null>(null);
|
||||||
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<SandboxThreadSnapshot | null>(null);
|
const sandboxThreadStore = useMemo(createSandboxThreadStore, []);
|
||||||
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||||
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||||
const sandboxSnapshotTimerRef = useRef<number | null>(null);
|
const sandboxSnapshotTimerRef = useRef<number | null>(null);
|
||||||
|
const sandboxSnapshotAppliedAtRef = useRef(0);
|
||||||
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
|
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
|
||||||
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
|
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
|
||||||
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
|
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
|
||||||
@@ -908,7 +983,10 @@ export default function PositionRoundtablePage({
|
|||||||
sandboxArtifactRef.current = sandboxArtifact;
|
sandboxArtifactRef.current = sandboxArtifact;
|
||||||
}, [sandboxArtifact]);
|
}, [sandboxArtifact]);
|
||||||
const flushSandboxThreadSnapshot = useCallback(() => {
|
const flushSandboxThreadSnapshot = useCallback(() => {
|
||||||
sandboxSnapshotTimerRef.current = null;
|
if (sandboxSnapshotTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||||
|
sandboxSnapshotTimerRef.current = null;
|
||||||
|
}
|
||||||
const pending = pendingSandboxThreadSnapshotRef.current;
|
const pending = pendingSandboxThreadSnapshotRef.current;
|
||||||
pendingSandboxThreadSnapshotRef.current = null;
|
pendingSandboxThreadSnapshotRef.current = null;
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@@ -916,21 +994,28 @@ export default function PositionRoundtablePage({
|
|||||||
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
|
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSandboxThreadSnapshot((current) =>
|
sandboxSnapshotAppliedAtRef.current = Date.now();
|
||||||
current?.threadId === pending.threadId && current.signature === pending.signature
|
sandboxThreadStore.publish(pending);
|
||||||
? current
|
}, [sandboxThreadStore]);
|
||||||
: pending,
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
const queueSandboxThreadSnapshot = useCallback(
|
const queueSandboxThreadSnapshot = useCallback(
|
||||||
(snapshot: SandboxThreadSnapshot) => {
|
(snapshot: SandboxThreadSnapshot) => {
|
||||||
pendingSandboxThreadSnapshotRef.current = snapshot;
|
pendingSandboxThreadSnapshotRef.current = snapshot;
|
||||||
if (sandboxSnapshotTimerRef.current !== null) return;
|
// Leading-edge throttle: publish straight away whenever the previous
|
||||||
if (typeof window === "undefined") {
|
// publish is old enough. Trailing timers alone are not dependable here —
|
||||||
|
// a continuous stream keeps the main thread busy and the panel then stays
|
||||||
|
// frozen on the content it had when it opened.
|
||||||
|
const sinceApplied = Date.now() - sandboxSnapshotAppliedAtRef.current;
|
||||||
|
if (typeof window === "undefined" || sinceApplied >= SANDBOX_SNAPSHOT_MIN_INTERVAL_MS) {
|
||||||
flushSandboxThreadSnapshot();
|
flushSandboxThreadSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sandboxSnapshotTimerRef.current = window.setTimeout(flushSandboxThreadSnapshot, 160);
|
// Still inside the quiet window: make sure the last chunk of a stream that
|
||||||
|
// stops right now is not left unpublished.
|
||||||
|
if (sandboxSnapshotTimerRef.current !== null) return;
|
||||||
|
sandboxSnapshotTimerRef.current = window.setTimeout(
|
||||||
|
flushSandboxThreadSnapshot,
|
||||||
|
SANDBOX_SNAPSHOT_MIN_INTERVAL_MS - sinceApplied,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[flushSandboxThreadSnapshot],
|
[flushSandboxThreadSnapshot],
|
||||||
);
|
);
|
||||||
@@ -938,6 +1023,7 @@ export default function PositionRoundtablePage({
|
|||||||
() => () => {
|
() => () => {
|
||||||
if (sandboxSnapshotTimerRef.current !== null) {
|
if (sandboxSnapshotTimerRef.current !== null) {
|
||||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||||
|
sandboxSnapshotTimerRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -2014,7 +2100,7 @@ export default function PositionRoundtablePage({
|
|||||||
}
|
}
|
||||||
closedSandboxKeyRef.current = null;
|
closedSandboxKeyRef.current = null;
|
||||||
setSandboxArtifact(null);
|
setSandboxArtifact(null);
|
||||||
setSandboxThreadSnapshot(null);
|
sandboxThreadStore.publish(null);
|
||||||
intent.resetIntent();
|
intent.resetIntent();
|
||||||
hydratedIntentSessionRef.current = null;
|
hydratedIntentSessionRef.current = null;
|
||||||
restoringIntentSessionRef.current = null;
|
restoringIntentSessionRef.current = null;
|
||||||
@@ -2102,7 +2188,7 @@ export default function PositionRoundtablePage({
|
|||||||
// 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。
|
// 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。
|
||||||
intent.stopIntentStream();
|
intent.stopIntentStream();
|
||||||
setSandboxArtifact(null);
|
setSandboxArtifact(null);
|
||||||
setSandboxThreadSnapshot(null);
|
sandboxThreadStore.publish(null);
|
||||||
setActiveNodeKey(null);
|
setActiveNodeKey(null);
|
||||||
setActiveBuiltinKind(null);
|
setActiveBuiltinKind(null);
|
||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
@@ -2225,7 +2311,7 @@ export default function PositionRoundtablePage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifactKey = sandboxCloseKey(artifact);
|
const artifactKey = sandboxCloseKey(artifact);
|
||||||
if (artifact.auto) {
|
if (artifact.auto) {
|
||||||
// Closing an old file should only suppress that exact preview. A later
|
// 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
|
// write_file in the same thread is a new deliverable and must still be
|
||||||
@@ -2236,22 +2322,47 @@ export default function PositionRoundtablePage({
|
|||||||
}
|
}
|
||||||
closedSandboxKeyRef.current = null;
|
closedSandboxKeyRef.current = null;
|
||||||
|
|
||||||
setSandboxArtifact((current) => {
|
// Follow the same lifecycle as the regular conversation view. The
|
||||||
const isSamePreview =
|
// position workspace owns a custom visual container, but the artifact
|
||||||
current?.threadId === artifact.threadId &&
|
// selection/open state must still advance at tool-start so its preview is
|
||||||
current.path === artifact.path &&
|
// not deferred until the final artifact list is written.
|
||||||
current.previewKey === artifact.previewKey;
|
const ctx = artifactsCtxRef.current;
|
||||||
return isSamePreview ? current : artifact;
|
ctx?.select(artifact.path, true);
|
||||||
});
|
ctx?.setOpen(true);
|
||||||
setSandboxThreadSnapshot((current) =>
|
|
||||||
current?.threadId === artifact.threadId ? current : null,
|
const openedArtifact = sandboxArtifactRef.current;
|
||||||
);
|
const isSamePreview =
|
||||||
|
openedArtifact?.threadId === artifact.threadId &&
|
||||||
|
openedArtifact.path === artifact.path &&
|
||||||
|
openedArtifact.previewKey === artifact.previewKey;
|
||||||
|
// A streaming file is previewed under a provisional name until its real path
|
||||||
|
// arrives. Adopting that path renames the open panel; it does not deliver a
|
||||||
|
// second file, so the surrounding layout must not be reshuffled again.
|
||||||
|
const isPathUpgrade =
|
||||||
|
!isSamePreview &&
|
||||||
|
Boolean(artifact.previewKey) &&
|
||||||
|
openedArtifact?.threadId === artifact.threadId &&
|
||||||
|
openedArtifact.previewKey === artifact.previewKey;
|
||||||
|
if (!isSamePreview) {
|
||||||
|
setSandboxArtifact(artifact);
|
||||||
|
// The snapshot queued below is published synchronously, before the state
|
||||||
|
// update above commits, so the ref that gates it has to advance here.
|
||||||
|
sandboxArtifactRef.current = artifact;
|
||||||
|
// A newly opened panel must show the file's current content immediately
|
||||||
|
// instead of waiting out the throttle window of the previous preview.
|
||||||
|
sandboxSnapshotAppliedAtRef.current = 0;
|
||||||
|
}
|
||||||
|
if (sandboxThreadStore.getSnapshot()?.threadId !== artifact.threadId) {
|
||||||
|
sandboxThreadStore.publish(null);
|
||||||
|
}
|
||||||
|
|
||||||
const latestSnapshot = latestNodeThreadSnapshotRef.current;
|
const latestSnapshot = latestNodeThreadSnapshotRef.current;
|
||||||
if (latestSnapshot?.threadId === artifact.threadId) {
|
if (latestSnapshot?.threadId === artifact.threadId) {
|
||||||
queueSandboxThreadSnapshot(latestSnapshot);
|
queueSandboxThreadSnapshot(latestSnapshot);
|
||||||
}
|
}
|
||||||
setRightPanelOpen(false);
|
if (!isPathUpgrade) {
|
||||||
|
setRightPanelOpen(false);
|
||||||
|
}
|
||||||
}, [queueSandboxThreadSnapshot]);
|
}, [queueSandboxThreadSnapshot]);
|
||||||
const handlePreviewFlowArtifact = useCallback(
|
const handlePreviewFlowArtifact = useCallback(
|
||||||
(item: ResultItem) => {
|
(item: ResultItem) => {
|
||||||
@@ -2322,18 +2433,6 @@ export default function PositionRoundtablePage({
|
|||||||
}) as unknown as BaseStream<AgentThreadState>,
|
}) as unknown as BaseStream<AgentThreadState>,
|
||||||
[artifactsCtx?.artifacts],
|
[artifactsCtx?.artifacts],
|
||||||
);
|
);
|
||||||
const sandboxThread = useMemo(
|
|
||||||
() =>
|
|
||||||
sandboxArtifact &&
|
|
||||||
sandboxThreadSnapshot?.threadId === sandboxArtifact.threadId
|
|
||||||
? sandboxThreadSnapshot.thread
|
|
||||||
: sandboxThreadStub,
|
|
||||||
[sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub],
|
|
||||||
);
|
|
||||||
const sandboxThreadContextValue = useMemo(
|
|
||||||
() => ({ thread: sandboxThread, isMock: false }),
|
|
||||||
[sandboxThread],
|
|
||||||
);
|
|
||||||
const handleNodeThreadSnapshot = useCallback(
|
const handleNodeThreadSnapshot = useCallback(
|
||||||
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
|
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
|
||||||
const signature = sandboxThreadSignature(snapshot.thread);
|
const signature = sandboxThreadSignature(snapshot.thread);
|
||||||
@@ -2356,10 +2455,16 @@ export default function PositionRoundtablePage({
|
|||||||
if (ctx?.open) {
|
if (ctx?.open) {
|
||||||
ctx?.setOpen(false);
|
ctx?.setOpen(false);
|
||||||
}
|
}
|
||||||
|
if (sandboxSnapshotTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||||
|
sandboxSnapshotTimerRef.current = null;
|
||||||
|
}
|
||||||
pendingSandboxThreadSnapshotRef.current = null;
|
pendingSandboxThreadSnapshotRef.current = null;
|
||||||
|
sandboxSnapshotAppliedAtRef.current = 0;
|
||||||
|
sandboxArtifactRef.current = null;
|
||||||
setSandboxArtifact(null);
|
setSandboxArtifact(null);
|
||||||
setSandboxThreadSnapshot(null);
|
sandboxThreadStore.publish(null);
|
||||||
}, []);
|
}, [sandboxThreadStore]);
|
||||||
const handleSelectBuiltinStage = useCallback(
|
const handleSelectBuiltinStage = useCallback(
|
||||||
(kind: ClosureStageKind) => {
|
(kind: ClosureStageKind) => {
|
||||||
// The closing agents are part of the testing-analysis workspace only;
|
// The closing agents are part of the testing-analysis workspace only;
|
||||||
@@ -2993,18 +3098,23 @@ export default function PositionRoundtablePage({
|
|||||||
)}
|
)}
|
||||||
{sandboxOpen && sandboxArtifact && artifactsCtx && (
|
{sandboxOpen && sandboxArtifact && artifactsCtx && (
|
||||||
<aside className="flex w-[min(48vw,720px)] min-w-[440px] 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:shadow-none">
|
<aside className="flex w-[min(48vw,720px)] min-w-[440px] 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:shadow-none">
|
||||||
<ThreadContext.Provider value={sandboxThreadContextValue}>
|
<SandboxThreadProvider
|
||||||
|
store={sandboxThreadStore}
|
||||||
|
threadId={sandboxArtifact.threadId}
|
||||||
|
fallbackThread={sandboxThreadStub}
|
||||||
|
>
|
||||||
<ArtifactFileDetail
|
<ArtifactFileDetail
|
||||||
key={sandboxCloseKey(sandboxArtifact)}
|
key={sandboxCloseKey(sandboxArtifact)}
|
||||||
className="min-h-0 flex-1"
|
className="min-h-0 flex-1"
|
||||||
filepath={sandboxArtifact.path}
|
filepath={sandboxArtifact.path}
|
||||||
threadId={sandboxArtifact.threadId}
|
threadId={sandboxArtifact.threadId}
|
||||||
|
mimeType={sandboxArtifact.mimeType}
|
||||||
onClose={closeSandbox}
|
onClose={closeSandbox}
|
||||||
allowVirtualMarkdownEdit
|
allowVirtualMarkdownEdit
|
||||||
disableDesensitize
|
disableDesensitize
|
||||||
hideFileName
|
hideFileName
|
||||||
/>
|
/>
|
||||||
</ThreadContext.Provider>
|
</SandboxThreadProvider>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
<DeliverableDetailDialog
|
<DeliverableDetailDialog
|
||||||
|
|||||||
Reference in new issue
Block a user