feat: support SSE write_file(0

This commit is contained in:
Henry Li
2026-01-18 17:13:15 +08:00
parent c50540e3fc
commit dd80348b76
9 changed files with 293 additions and 178 deletions

View File

@@ -32,7 +32,7 @@ import { FileViewer } from "./file-viewer";
export function ArtifactFileDetail({
className,
filepath,
filepath: filepathFromProps,
threadId,
}: {
className?: string;
@@ -40,42 +40,65 @@ export function ArtifactFileDetail({
threadId: string;
}) {
const { artifacts, setOpen, select } = useArtifacts();
const { isCodeFile } = useMemo(() => checkCodeFile(filepath), [filepath]);
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepathFromProps);
return url.pathname;
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
const { isCodeFile } = useMemo(() => {
if (isWriteFile) {
let language = checkCodeFile(filepath).language;
language ??= "markdown";
return { isCodeFile: true, language };
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile]);
const { content } = useArtifactContent({
threadId,
filepath,
enabled: isCodeFile,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
return (
<Artifact className={cn("rounded-none", className)}>
<ArtifactHeader className="px-2">
<div>
<ArtifactTitle>
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((filepath) => (
<SelectItem key={filepath} value={filepath}>
{getFileName(filepath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((filepath) => (
<SelectItem key={filepath} value={filepath}>
{getFileName(filepath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</ArtifactTitle>
</div>
<div className="flex items-center gap-2">
<ArtifactActions>
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label="Open in new window"
tooltip="Open in new window"
/>
</a>
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label="Open in new window"
tooltip="Open in new window"
/>
</a>
)}
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -93,17 +116,19 @@ export function ArtifactFileDetail({
tooltip="Copy content to clipboard"
/>
)}
<a
href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank"
>
<ArtifactAction
icon={DownloadIcon}
label="Download"
onClick={() => console.log("Download")}
tooltip="Download file"
/>
</a>
{!isWriteFile && (
<a
href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank"
>
<ArtifactAction
icon={DownloadIcon}
label="Download"
onClick={() => console.log("Download")}
tooltip="Download file"
/>
</a>
)}
<ArtifactAction
icon={XIcon}
label="Close"
@@ -117,7 +142,7 @@ export function ArtifactFileDetail({
<FileViewer
className="size-full"
threadId={threadId}
filepath={filepath}
filepath={filepathFromProps}
/>
</ArtifactContent>
</Artifact>

View File

@@ -16,14 +16,21 @@ export function FileViewer({
filepath: string;
threadId: string;
}) {
const { isCodeFile, language } = useMemo(
() => checkCodeFile(filepath),
[filepath],
);
if (isCodeFile && language !== "html") {
const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:");
}, [filepath]);
const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepath);
const path = url.pathname;
return checkCodeFile(path);
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile]);
if (isWriteFile || (isCodeFile && language !== "html")) {
return (
<CodeFileView
language={language}
language={language ?? "markdown"}
filepath={filepath}
threadId={threadId}
/>
@@ -55,7 +62,7 @@ function CodeFileView({
if (code) {
return (
<CodeBlock
className="rounded-none border-none"
className="size-full rounded-none border-none"
language={language}
code={code}
/>

View File

@@ -0,0 +1,21 @@
import type { UseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react";
import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
threadId: string;
thread: UseStream<AgentThreadState>;
}
export const ThreadContext = createContext<ThreadContextType | undefined>(
undefined,
);
export function useThread() {
const context = useContext(ThreadContext);
if (context === undefined) {
throw new Error("useThread must be used within a ThreadContext");
}
return context;
}

View File

@@ -30,6 +30,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display";
export function MessageGroup({
@@ -108,15 +109,18 @@ export function MessageGroup({
function ToolCall({
id,
messageId,
name,
args,
result,
}: {
id?: string;
messageId?: string;
name: string;
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
}) {
const { select, setOpen } = useArtifacts();
if (name === "web_search") {
let label: React.ReactNode = "Search for related information";
if (typeof args.query === "string") {
@@ -198,7 +202,20 @@ function ToolCall({
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={NotebookPenIcon}>
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
@@ -258,6 +275,7 @@ function ToolCall({
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
@@ -281,6 +299,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
if (reasoning) {
const step: CoTReasoningStep = {
id: message.id,
messageId: message.id,
type: "reasoning",
reasoning: extractReasoningContentFromMessage(message),
};
@@ -289,6 +308,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
for (const tool_call of message.tool_calls ?? []) {
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,