bug修改
This commit is contained in:
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>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user