From dd80348b7640193ac87eb5b26757db52f4797239 Mon Sep 17 00:00:00 2001 From: Henry Li Date: Sun, 18 Jan 2026 17:13:15 +0800 Subject: [PATCH] feat: support SSE write_file(0 --- .../app/workspace/chats/[thread_id]/page.tsx | 255 +++++++++--------- frontend/src/app/workspace/layout.tsx | 2 +- .../src/components/ai-elements/artifact.tsx | 5 +- .../src/components/ai-elements/code-block.tsx | 12 +- .../artifacts/artifact-file-detail.tsx | 99 ++++--- .../workspace/artifacts/file-viewer.tsx | 21 +- .../components/workspace/messages/context.ts | 21 ++ .../workspace/messages/message-group.tsx | 22 +- frontend/src/core/artifacts/hooks.ts | 34 ++- 9 files changed, 293 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/workspace/messages/context.ts diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index c3a077f..5eef71f 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -19,6 +19,7 @@ import { } from "@/components/workspace/artifacts"; import { InputBox } from "@/components/workspace/input-box"; import { MessageList } from "@/components/workspace/messages"; +import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { Tooltip } from "@/components/workspace/tooltip"; import { Welcome } from "@/components/workspace/welcome"; @@ -86,140 +87,148 @@ export default function ChatPage() { await thread.stop(); }, [thread]); + if (!threadId) { + return null; + } + return ( - - -
-
-
- {threadId && title !== "Untitled" && ( - - )} -
-
- {artifacts?.length && !artifactsOpen && ( - - - - )} -
-
-
-
- -
-
-
+ + +
+
+
+ {title !== "Untitled" && ( + )} - > +
+
+ {artifacts?.length && !artifactsOpen && ( + + + + )} +
+
+
+
+ +
+
- +
+ +
+ + setSettings("context", context) + } + onSubmit={handleSubmit} + onStop={handleStop} + />
- setSettings("context", context)} - onSubmit={handleSubmit} - onStop={handleStop} - />
-
-
-
-
- - -
+
+
+ + - {selectedArtifact ? ( - - ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
+
+ {selectedArtifact ? ( + + ) : ( +
+
+
- )} -
- )} -
- - + {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ )} +
+
+
+ ); } diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index ece7349..83c69c0 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -35,7 +35,7 @@ export default function WorkspaceLayout({ > - {children} + {children} diff --git a/frontend/src/components/ai-elements/artifact.tsx b/frontend/src/components/ai-elements/artifact.tsx index c42459d..ca12a53 100644 --- a/frontend/src/components/ai-elements/artifact.tsx +++ b/frontend/src/components/ai-elements/artifact.tsx @@ -143,5 +143,8 @@ export const ArtifactContent = ({ className, ...props }: ArtifactContentProps) => ( -
+
); diff --git a/frontend/src/components/ai-elements/code-block.tsx b/frontend/src/components/ai-elements/code-block.tsx index b6865f0..a3d0d09 100644 --- a/frontend/src/components/ai-elements/code-block.tsx +++ b/frontend/src/components/ai-elements/code-block.tsx @@ -52,7 +52,7 @@ const lineNumberTransformer: ShikiTransformer = { export async function highlightCode( code: string, language: BundledLanguage, - showLineNumbers = false + showLineNumbers = false, ) { const transformers: ShikiTransformer[] = showLineNumbers ? [lineNumberTransformer] @@ -102,19 +102,19 @@ export const CodeBlock = ({
-
+
diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 5ec2d02..a64a063 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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 (
- + {isWriteFile ? ( +
{getFileName(filepath)}
+ ) : ( + + )}
- - - + {!isWriteFile && ( + + + + )} {isCodeFile && ( )} - - console.log("Download")} - tooltip="Download file" - /> - + {!isWriteFile && ( + + console.log("Download")} + tooltip="Download file" + /> + + )} diff --git a/frontend/src/components/workspace/artifacts/file-viewer.tsx b/frontend/src/components/workspace/artifacts/file-viewer.tsx index 97604f7..49b1786 100644 --- a/frontend/src/components/workspace/artifacts/file-viewer.tsx +++ b/frontend/src/components/workspace/artifacts/file-viewer.tsx @@ -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 ( @@ -55,7 +62,7 @@ function CodeFileView({ if (code) { return ( diff --git a/frontend/src/components/workspace/messages/context.ts b/frontend/src/components/workspace/messages/context.ts new file mode 100644 index 0000000..3d1ee82 --- /dev/null +++ b/frontend/src/components/workspace/messages/context.ts @@ -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; +} + +export const ThreadContext = createContext( + undefined, +); + +export function useThread() { + const context = useContext(ThreadContext); + if (context === undefined) { + throw new Error("useThread must be used within a ThreadContext"); + } + return context; +} diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index b0db94a..8d41a8b 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -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; result?: string | Record; }) { + 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 ( - + { + select( + new URL( + `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, + ).toString(), + ); + setOpen(true); + }} + > {path && ( {path} )} @@ -258,6 +275,7 @@ function ToolCall({ interface GenericCoTStep { 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, diff --git a/frontend/src/core/artifacts/hooks.ts b/frontend/src/core/artifacts/hooks.ts index e7a6cd4..3e01521 100644 --- a/frontend/src/core/artifacts/hooks.ts +++ b/frontend/src/core/artifacts/hooks.ts @@ -1,4 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { useThread } from "@/components/workspace/messages/context"; import { loadArtifactContent } from "./loader"; @@ -11,10 +14,37 @@ export function useArtifactContent({ threadId: string; 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({ queryKey: ["artifact", filepath, threadId], - queryFn: () => loadArtifactContent({ filepath, threadId }), + queryFn: () => { + return loadArtifactContent({ filepath, threadId }); + }, enabled, }); - return { content: data, isLoading, error }; + return { content: isWriteFile ? content : data, isLoading, error }; }