bug修改

This commit is contained in:
董宏凯 committed 2026-08-26 15:46:49 +08:00
1 parent 910cee9e67
commit 704438b3d2
6 files changed
+278 -36

No files matched your search

@@ -76,6 +76,7 @@ import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { isWriteFileInFlight } from "@/core/artifacts/write-file-in-flight";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import {
isSandboxStreamPerfEnabled,
@@ -308,19 +309,33 @@ export function ArtifactFileDetail({
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
// 仅当「当前这个文件」的 write_file 还没返回 tool result 时才算在写。
// 不能用 thread.isLoading:用户协助后的下一轮问答也会 loading,否则会把已写完的
// md 从 CodeMirror 拽回巨大 textarea,主线程卡住、聊天变成「发送中然后一口气出来」。
const writeFileInFlight = useMemo(() => {
if (!isWriteFileVirtual || isImageFile(filepath)) {
return false;
}
return isWriteFileInFlight(thread, filepathFromProps);
}, [filepath, filepathFromProps, isWriteFileVirtual, thread.messages]);
// 圆桌/主聊天沙箱:write_file 虚拟预览的 md 在该文件写完后切回真实 artifacts API
// (可加载 + PATCH 保存)。后续问答轮次的 loading 不再把预览拽回去。
const editVirtualMarkdown = useMemo(() => {
return (
allowVirtualMarkdownEdit &&
isWriteFileVirtual &&
!isImageFile(filepath) &&
!isMock &&
!thread.isLoading &&
!writeFileInFlight &&
checkCodeFile(filepath).language === "markdown"
);
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
}, [
allowVirtualMarkdownEdit,
isWriteFileVirtual,
filepath,
isMock,
writeFileInFlight,
]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
@@ -377,7 +392,7 @@ export function ArtifactFileDetail({
language === "markdown" &&
!isSkillFile &&
!isMock &&
!thread.isLoading &&
!writeFileInFlight &&
(canEditArtifact || useWriteFileStreaming);
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
@@ -406,7 +421,7 @@ export function ArtifactFileDetail({
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = thread.isLoading || documentRewriteActive;
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
@@ -426,7 +441,7 @@ export function ArtifactFileDetail({
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive);
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
@@ -889,7 +904,7 @@ export function ArtifactFileDetail({
}
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
return;
}
@@ -899,14 +914,20 @@ export function ArtifactFileDetail({
}
setViewMode("code");
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
}, [
documentRewriteActive,
isPreviewStreaming,
isSupportPreview,
language,
writeFileInFlight,
]);
useEffect(() => {
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
return;
}
if (thread.isLoading) {
if (writeFileInFlight || documentRewriteActive) {
setViewMode("code");
return;
}
@@ -918,7 +939,13 @@ export function ArtifactFileDetail({
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
isSupportPreview,
writeFileInFlight,
]);
const scrollArtifactToBottom = useCallback(
(behavior: ScrollBehavior = "auto") => {
@@ -1015,13 +1042,13 @@ export function ArtifactFileDetail({
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) {
if (!isLiveStreaming || !autoScrollEnabled) {
return;
}
setAutoScrollEnabled(false);
setShowResumeAutoScroll(true);
}, [autoScrollEnabled, thread.isLoading]);
}, [autoScrollEnabled, isLiveStreaming]);
const handleResumeAutoScroll = useCallback(() => {
setAutoScrollEnabled(true);
@@ -1774,7 +1801,7 @@ export function ArtifactFileDetail({
}
: undefined
}
isStreaming={thread.isLoading}
isStreaming={writeFileInFlight || documentRewriteActive}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
+8 -17
View File
@@ -3,8 +3,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
@@ -15,22 +13,13 @@ import {
isSandboxStreamPerfEnabled,
} from "./sandbox-stream-perf";
import { isArtifactApiPath } from "./streaming-write-preview";
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
function hasToolResult(
thread: ReturnType<typeof useThread>["thread"],
toolCallId?: string,
) {
if (!toolCallId) {
return false;
}
@@ -144,11 +133,13 @@ export function useArtifactContent({
const liveWriteFileContent = useWriteFileStreaming
? loadArtifactContentFromToolCall({ url: filepath, thread })
: undefined;
const writeFileInFlight =
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
const displayedWriteFileContent = useThrottledWriteFileDisplay({
filepath,
liveContent: liveWriteFileContent,
enabled: streamPerfEnabled && useWriteFileStreaming,
streaming: thread.isLoading,
streaming: writeFileInFlight,
});
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
const toolCallFallbackContent = useMemo(() => {
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
"report.md",
);
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
});
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
const inFlight = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
],
};
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
const completed = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
],
};
assert.equal(
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
false,
);
});
test("a later thread turn does not treat a finished write as in-flight", () => {
const laterTurn = {
messages: [
{
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "report.md" } },
],
},
{ type: "tool", tool_call_id: "w1", name: "write_file" },
{ type: "human", content: "按方案 A" },
{
type: "ai",
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
},
],
};
assert.equal(
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
false,
);
});
@@ -0,0 +1,77 @@
import { isImageFile } from "../utils/files";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
export function resolveWriteFilePath(filepath: string): string {
if (filepath.startsWith("write-file:")) {
try {
return decodeURIComponent(new URL(filepath).pathname).trim();
} catch {
const rest = filepath.slice("write-file:".length);
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
}
}
return filepath.trim();
}
type WriteFileThreadLike = {
messages: Array<{
type?: string;
tool_call_id?: string;
tool_calls?: Array<{
id?: string;
name?: string;
args?: unknown;
}>;
}>;
};
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
/** True only while THIS file's write_file call has not yet returned a tool result. */
export function isWriteFileInFlight(
thread: WriteFileThreadLike,
filepath: string,
): boolean {
const path = resolveWriteFilePath(filepath);
if (!path) {
return false;
}
for (const message of thread.messages) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of message.tool_calls) {
if (toolCall.name !== "write_file") {
continue;
}
const args = toolCall.args as { path?: unknown } | undefined;
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
if (callPath !== path) {
continue;
}
if (!hasToolResult(thread, toolCall.id)) {
return true;
}
}
}
return false;
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import { groupMessages } from "./utils";
function groupTypes(messages: Message[]) {
const types: string[] = [];
groupMessages(messages, (group) => {
types.push(group.type);
return group.type;
});
return types;
}
test("write_file tool after 用户协助 opens a processing group instead of erroring", () => {
const types = groupTypes([
{ type: "human", id: "h1", content: "写一份报告" } as Message,
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "c1", name: "ask_clarification", args: {} }],
} as Message,
{
type: "tool",
id: "t1",
name: "ask_clarification",
content: "请选择范围",
tool_call_id: "c1",
} as Message,
{ type: "human", id: "h2", content: "按方案 A" } as Message,
{
type: "tool",
id: "t2",
name: "write_file",
content: "OK",
tool_call_id: "w1",
} as Message,
{
type: "tool",
id: "t3",
name: "write_file",
content: "OK",
tool_call_id: "w2",
} as Message,
]);
assert.deepEqual(types, [
"human",
"assistant:processing",
"assistant:clarification",
"human",
"assistant:processing",
]);
});
test("tool messages still attach to an open processing group", () => {
const types = groupTypes([
{
type: "ai",
id: "a1",
content: "",
tool_calls: [{ id: "w1", name: "write_file", args: { path: "a.md" } }],
} as Message,
{
type: "tool",
id: "t1",
name: "write_file",
content: "OK",
tool_call_id: "w1",
} as Message,
]);
assert.deepEqual(types, ["assistant:processing"]);
});
+9 -4
View File
@@ -119,10 +119,15 @@ export function groupMessages<T>(
if (open) {
open.messages.push(message);
} else {
console.error(
"Unexpected tool message outside a processing group",
message,
);
// After 用户协助 the last group is human/clarification, so later
// write_file results have nowhere to attach. Opening a processing
// group keeps them renderable and avoids console.error(full message)
// on every MessageList render (that froze the SSE thread).
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
}
}
continue;