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

@@ -19,6 +19,7 @@ import {
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
@@ -86,140 +87,148 @@ export default function ChatPage() {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
if (!threadId) {
return null;
}
return ( return (
<ResizablePanelGroup orientation="horizontal"> <ThreadContext.Provider value={{ threadId, thread }}>
<ResizablePanel <ResizablePanelGroup orientation="horizontal">
className="relative" <ResizablePanel
defaultSize={artifactsOpen ? 46 : 100} className="relative"
minSize={30} defaultSize={artifactsOpen ? 46 : 100}
> minSize={30}
<div className="relative flex size-full min-h-0 justify-between"> >
<header className="bg-background/80 absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4 backdrop-blur"> <div className="relative flex size-full min-h-0 justify-between">
<div className="flex w-full items-center text-sm font-medium"> <header className="bg-background/80 absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4 backdrop-blur">
{threadId && title !== "Untitled" && ( <div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} threadTitle={title} /> {title !== "Untitled" && (
)} <ThreadTitle threadId={threadId} threadTitle={title} />
</div>
<div>
{artifacts?.length && !artifactsOpen && (
<Tooltip content="Show artifacts of this conversation">
<Button
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
setSidebarOpen(false);
}}
>
<FilesIcon />
Artifacts
</Button>
</Tooltip>
)}
</div>
</header>
<main className="flex min-h-0 grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className="size-full"
threadId={threadId!}
thread={thread}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-120px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)} )}
> </div>
<div>
{artifacts?.length && !artifactsOpen && (
<Tooltip content="Show artifacts of this conversation">
<Button
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
setSidebarOpen(false);
}}
>
<FilesIcon />
Artifacts
</Button>
</Tooltip>
)}
</div>
</header>
<main className="flex min-h-0 grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div <div
className={cn( className={cn(
"absolute right-0 bottom-[148px] left-0 flex", "relative w-full",
isNewThread ? "" : "pointer-events-none opacity-0", isNewThread && "-translate-y-[calc(50vh-120px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)} )}
> >
<Welcome /> <div
className={cn(
"absolute right-0 bottom-[148px] left-0 flex",
isNewThread ? "" : "pointer-events-none opacity-0",
)}
>
<Welcome />
</div>
<InputBox
className={cn("w-full")}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}
onContextChange={(context) =>
setSettings("context", context)
}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div> </div>
<InputBox
className={cn("w-full")}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div> </div>
</div> </main>
</main> </div>
</div> </ResizablePanel>
</ResizablePanel> <ResizableHandle
<ResizableHandle
className={cn(
"transition-opacity duration-300",
!artifactsOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
defaultSize={artifactsOpen ? 64 : 0}
minSize={0}
>
<div
className={cn( className={cn(
"h-full transition-transform duration-300 ease-in-out", "transition-opacity duration-300",
artifactsOpen ? "translate-x-0" : "translate-x-full", !artifactsOpen && "pointer-events-none opacity-0",
)} )}
/>
<ResizablePanel
className={cn(
"transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
defaultSize={artifactsOpen ? 64 : 0}
minSize={0}
> >
{selectedArtifact ? ( <div
<ArtifactFileDetail className={cn(
className="size-full" "h-full transition-transform duration-300 ease-in-out",
filepath={selectedArtifact} artifactsOpen ? "translate-x-0" : "translate-x-full",
threadId={threadId!} )}
/> >
) : ( {selectedArtifact ? (
<div className="relative flex size-full justify-center"> <ArtifactFileDetail
<div className="absolute top-1 right-1 z-30"> className="size-full"
<Button filepath={selectedArtifact}
size="icon-sm" threadId={threadId}
variant="ghost" />
onClick={() => { ) : (
setArtifactsOpen(false); <div className="relative flex size-full justify-center">
}} <div className="absolute top-1 right-1 z-30">
> <Button
<XIcon /> size="icon-sm"
</Button> variant="ghost"
</div> onClick={() => {
{thread.values.artifacts?.length === 0 ? ( setArtifactsOpen(false);
<ConversationEmptyState }}
icon={<FilesIcon />} >
title="No artifact selected" <XIcon />
description="Select an artifact to view its details" </Button>
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId!}
/>
</main>
</div> </div>
)} {thread.values.artifacts?.length === 0 ? (
</div> <ConversationEmptyState
)} icon={<FilesIcon />}
</div> title="No artifact selected"
</ResizablePanel> description="Select an artifact to view its details"
</ResizablePanelGroup> />
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider>
); );
} }

View File

@@ -35,7 +35,7 @@ export default function WorkspaceLayout({
> >
<Overscroll behavior="none" overflow="hidden" /> <Overscroll behavior="none" overflow="hidden" />
<WorkspaceSidebar /> <WorkspaceSidebar />
<SidebarInset>{children}</SidebarInset> <SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider> </SidebarProvider>
<Toaster position="top-center" /> <Toaster position="top-center" />
</QueryClientProvider> </QueryClientProvider>

View File

@@ -143,5 +143,8 @@ export const ArtifactContent = ({
className, className,
...props ...props
}: ArtifactContentProps) => ( }: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} /> <div
className={cn("min-h-0 flex-1 overflow-auto p-4", className)}
{...props}
/>
); );

View File

@@ -52,7 +52,7 @@ const lineNumberTransformer: ShikiTransformer = {
export async function highlightCode( export async function highlightCode(
code: string, code: string,
language: BundledLanguage, language: BundledLanguage,
showLineNumbers = false showLineNumbers = false,
) { ) {
const transformers: ShikiTransformer[] = showLineNumbers const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer] ? [lineNumberTransformer]
@@ -102,19 +102,19 @@ export const CodeBlock = ({
<CodeBlockContext.Provider value={{ code }}> <CodeBlockContext.Provider value={{ code }}>
<div <div
className={cn( className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground", "group bg-background text-foreground relative size-full overflow-hidden rounded-md border",
className className,
)} )}
{...props} {...props}
> >
<div className="relative"> <div className="relative size-full">
<div <div
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm" className="[&>pre]:bg-background! [&>pre]:text-foreground! size-full overflow-auto dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
<div <div
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm" className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }} dangerouslySetInnerHTML={{ __html: darkHtml }}
/> />

View File

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

View File

@@ -16,14 +16,21 @@ export function FileViewer({
filepath: string; filepath: string;
threadId: string; threadId: string;
}) { }) {
const { isCodeFile, language } = useMemo( const isWriteFile = useMemo(() => {
() => checkCodeFile(filepath), return filepath.startsWith("write-file:");
[filepath], }, [filepath]);
); const { isCodeFile, language } = useMemo(() => {
if (isCodeFile && language !== "html") { 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 ( return (
<CodeFileView <CodeFileView
language={language} language={language ?? "markdown"}
filepath={filepath} filepath={filepath}
threadId={threadId} threadId={threadId}
/> />
@@ -55,7 +62,7 @@ function CodeFileView({
if (code) { if (code) {
return ( return (
<CodeBlock <CodeBlock
className="rounded-none border-none" className="size-full rounded-none border-none"
language={language} language={language}
code={code} 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 { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
export function MessageGroup({ export function MessageGroup({
@@ -108,15 +109,18 @@ export function MessageGroup({
function ToolCall({ function ToolCall({
id, id,
messageId,
name, name,
args, args,
result, result,
}: { }: {
id?: string; id?: string;
messageId?: string;
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
}) { }) {
const { select, setOpen } = useArtifacts();
if (name === "web_search") { if (name === "web_search") {
let label: React.ReactNode = "Search for related information"; let label: React.ReactNode = "Search for related information";
if (typeof args.query === "string") { if (typeof args.query === "string") {
@@ -198,7 +202,20 @@ function ToolCall({
} }
const path: string | undefined = (args as { path: string })?.path; const path: string | undefined = (args as { path: string })?.path;
return ( 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 && ( {path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult> <ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)} )}
@@ -258,6 +275,7 @@ function ToolCall({
interface GenericCoTStep<T extends string = string> { interface GenericCoTStep<T extends string = string> {
id?: string; id?: string;
messageId?: string;
type: T; type: T;
} }
@@ -281,6 +299,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
if (reasoning) { if (reasoning) {
const step: CoTReasoningStep = { const step: CoTReasoningStep = {
id: message.id, id: message.id,
messageId: message.id,
type: "reasoning", type: "reasoning",
reasoning: extractReasoningContentFromMessage(message), reasoning: extractReasoningContentFromMessage(message),
}; };
@@ -289,6 +308,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
for (const tool_call of message.tool_calls ?? []) { for (const tool_call of message.tool_calls ?? []) {
const step: CoTToolCallStep = { const step: CoTToolCallStep = {
id: tool_call.id, id: tool_call.id,
messageId: message.id,
type: "toolCall", type: "toolCall",
name: tool_call.name, name: tool_call.name,
args: tool_call.args, args: tool_call.args,

View File

@@ -1,4 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { loadArtifactContent } from "./loader"; import { loadArtifactContent } from "./loader";
@@ -11,10 +14,37 @@ export function useArtifactContent({
threadId: string; threadId: string;
enabled?: boolean; enabled?: boolean;
}) { }) {
const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:");
}, [filepath]);
const { thread } = useThread();
const content = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepath);
const toolCallId = url.searchParams.get("tool_call_id");
const messageId = url.searchParams.get("message_id");
if (messageId && toolCallId) {
const message = thread.messages.find(
(message) => message.id === messageId,
);
if (message?.type === "ai" && message.tool_calls) {
const toolCall = message.tool_calls.find(
(toolCall) => toolCall.id === toolCallId,
);
if (toolCall) {
return toolCall.args.content;
}
}
}
}
return null;
}, [filepath, isWriteFile, thread.messages]);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId], queryKey: ["artifact", filepath, threadId],
queryFn: () => loadArtifactContent({ filepath, threadId }), queryFn: () => {
return loadArtifactContent({ filepath, threadId });
},
enabled, enabled,
}); });
return { content: data, isLoading, error }; return { content: isWriteFile ? content : data, isLoading, error };
} }