227 lines
6.5 KiB
TypeScript
227 lines
6.5 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { useThread } from "@/components/workspace/messages/context";
|
|
|
|
import {
|
|
loadArtifactContent,
|
|
loadArtifactContentFromLatestWriteFile,
|
|
loadArtifactContentFromToolCall,
|
|
} from "./loader";
|
|
import {
|
|
SANDBOX_STREAM_FLUSH_MS,
|
|
isSandboxStreamPerfEnabled,
|
|
} from "./sandbox-stream-perf";
|
|
import { isArtifactApiPath } from "./streaming-write-preview";
|
|
import {
|
|
isWriteFileInFlight,
|
|
isWriteFileStreamingUrl,
|
|
latestCompletedArtifactMutationKey,
|
|
} from "./write-file-in-flight";
|
|
import { updateArtifactContent } from "./api";
|
|
|
|
function hasToolResult(
|
|
thread: ReturnType<typeof useThread>["thread"],
|
|
toolCallId?: string,
|
|
) {
|
|
if (!toolCallId) {
|
|
return false;
|
|
}
|
|
return thread.messages.some(
|
|
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
|
);
|
|
}
|
|
|
|
function applyPendingTextEdit({
|
|
content,
|
|
filepath,
|
|
thread,
|
|
}: {
|
|
content: string | undefined;
|
|
filepath: string;
|
|
thread: ReturnType<typeof useThread>["thread"];
|
|
}) {
|
|
if (!content || !thread.isLoading) {
|
|
return null;
|
|
}
|
|
|
|
for (const message of [...thread.messages].reverse()) {
|
|
if (message.type !== "ai" || !message.tool_calls) {
|
|
continue;
|
|
}
|
|
for (const toolCall of [...message.tool_calls].reverse()) {
|
|
if (toolCall.name !== "str_replace" || hasToolResult(thread, toolCall.id)) {
|
|
continue;
|
|
}
|
|
const args = toolCall.args as {
|
|
path?: unknown;
|
|
old_str?: unknown;
|
|
new_str?: unknown;
|
|
replace_all?: unknown;
|
|
};
|
|
if (args.path !== filepath) {
|
|
continue;
|
|
}
|
|
if (typeof args.old_str !== "string" || typeof args.new_str !== "string") {
|
|
continue;
|
|
}
|
|
if (!content.includes(args.old_str)) {
|
|
return { content, kind: "editing" as const };
|
|
}
|
|
const nextContent =
|
|
args.replace_all === true
|
|
? content.split(args.old_str).join(args.new_str)
|
|
: content.replace(args.old_str, args.new_str);
|
|
return { content: nextContent, kind: "editing" as const };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Keep the latest write_file text in a ref (always current for later readers)
|
|
* but only publish a display snapshot on a timer while the run is in flight.
|
|
* Display therefore does not follow `thread` object identity / every SSE chunk.
|
|
*/
|
|
function useThrottledWriteFileDisplay({
|
|
filepath,
|
|
liveContent,
|
|
enabled,
|
|
streaming,
|
|
}: {
|
|
filepath: string;
|
|
liveContent: string | undefined;
|
|
enabled: boolean;
|
|
streaming: boolean;
|
|
}) {
|
|
const latestRef = useRef(liveContent);
|
|
latestRef.current = liveContent;
|
|
const [displayed, setDisplayed] = useState(liveContent);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
setDisplayed(latestRef.current);
|
|
return;
|
|
}
|
|
const flush = () => {
|
|
setDisplayed((previous) =>
|
|
Object.is(previous, latestRef.current) ? previous : latestRef.current,
|
|
);
|
|
};
|
|
if (!streaming) {
|
|
flush();
|
|
return;
|
|
}
|
|
flush();
|
|
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
|
|
return () => window.clearInterval(intervalId);
|
|
}, [enabled, filepath, streaming]);
|
|
|
|
return enabled && streaming ? displayed : liveContent;
|
|
}
|
|
|
|
export function useArtifactContent({
|
|
filepath,
|
|
threadId,
|
|
enabled,
|
|
}: {
|
|
filepath: string;
|
|
threadId: string;
|
|
enabled?: boolean;
|
|
}) {
|
|
const useWriteFileStreaming = useMemo(() => {
|
|
return isWriteFileStreamingUrl(filepath);
|
|
}, [filepath]);
|
|
const { thread, isMock, publicShareCode } = useThread();
|
|
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
|
const liveWriteFileContent = useWriteFileStreaming
|
|
? loadArtifactContentFromToolCall({ url: filepath, thread })
|
|
: undefined;
|
|
const writeFileInFlight =
|
|
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
|
|
const completedArtifactMutationKey = useMemo(() => {
|
|
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
|
|
return null;
|
|
}
|
|
return latestCompletedArtifactMutationKey(thread, filepath);
|
|
}, [filepath, thread, useWriteFileStreaming]);
|
|
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
|
filepath,
|
|
liveContent: liveWriteFileContent,
|
|
enabled: streamPerfEnabled && useWriteFileStreaming,
|
|
streaming: writeFileInFlight,
|
|
});
|
|
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
|
|
const toolCallFallbackContent = useMemo(() => {
|
|
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
|
|
return undefined;
|
|
}
|
|
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
|
|
}, [filepath, thread, useWriteFileStreaming]);
|
|
|
|
const { data, isLoading, error, refetch } = useQuery({
|
|
queryKey: [
|
|
"artifact",
|
|
filepath,
|
|
threadId,
|
|
isMock,
|
|
publicShareCode,
|
|
completedArtifactMutationKey,
|
|
],
|
|
queryFn: () => {
|
|
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
|
},
|
|
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
|
|
refetchInterval: thread.isLoading ? 1000 : false,
|
|
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const pendingEdit = useMemo(() => {
|
|
if (useWriteFileStreaming) {
|
|
return null;
|
|
}
|
|
return applyPendingTextEdit({
|
|
content: data?.content,
|
|
filepath,
|
|
thread,
|
|
});
|
|
}, [data?.content, filepath, useWriteFileStreaming, thread]);
|
|
|
|
return {
|
|
content: useWriteFileStreaming
|
|
? content
|
|
: (pendingEdit?.content ?? data?.content ?? toolCallFallbackContent),
|
|
url: useWriteFileStreaming ? undefined : data?.url,
|
|
isLoading,
|
|
isApplyingEdit: pendingEdit?.kind === "editing",
|
|
error,
|
|
refetch,
|
|
};
|
|
}
|
|
|
|
export function useUpdateArtifactContent() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: updateArtifactContent,
|
|
onSuccess: (content, variables) => {
|
|
// useArtifactContent 的查询键带第 5 个元素 publicShareCode,这里用前缀
|
|
// 过滤(setQueriesData)匹配,避免精确键缺少 publicShareCode 写到幽灵缓存、
|
|
// 导致保存后内容被旧缓存重置回原值。
|
|
queryClient.setQueriesData(
|
|
{
|
|
queryKey: [
|
|
"artifact",
|
|
variables.filepath,
|
|
variables.threadId,
|
|
variables.isMock,
|
|
],
|
|
},
|
|
{
|
|
content,
|
|
url: undefined,
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|