mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 12:24:46 +08:00
feat: support SSE write_file(0
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
21
frontend/src/components/workspace/messages/context.ts
Normal file
21
frontend/src/components/workspace/messages/context.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user