代码修改

This commit is contained in:
董宏凯 committed 2026-05-16 14:24:44 +08:00
1 parent 5a25dd08f6
commit e8a31b5d37
9 files changed
+533 -417

No files matched your search

@@ -94,3 +94,7 @@ export function useArtifacts() {
} }
return context; return context;
} }
export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null;
}
@@ -33,7 +33,7 @@ import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
@@ -201,8 +201,7 @@ function ToolCall({
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = const artifacts = useArtifactsOptional();
useArtifacts();
if (name === "web_search") { if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -329,43 +328,43 @@ function ToolCall({
)} )}
</ChainOfThoughtStep> </ChainOfThoughtStep>
); );
} else if (name === "write_file" || name === "str_replace") { } else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string }) let description: string | undefined = (args as { description: string })
?.description; ?.description;
if (!description) { if (!description) {
description = t.toolCalls.writeFile; description = t.toolCalls.writeFile;
} }
const path: string | undefined = (args as { path: string })?.path; const path: string | undefined = (args as { path: string })?.path;
const artifactPath = const artifactPath =
name === "write_file" && path name === "write_file" && path
? new URL( ? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString() ).toString()
: path; : path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) { if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
setTimeout(() => { setTimeout(() => {
if (!artifactPath || selectedArtifact === artifactPath) { if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
return; return;
} }
select(artifactPath, true); artifacts.select(artifactPath, true);
setOpen(true); artifacts.setOpen(true);
}, 100); }, 100);
} }
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
className="cursor-pointer" className="cursor-pointer"
label={description} label={description}
icon={NotebookPenIcon} icon={NotebookPenIcon}
onClick={() => { onClick={() => {
if (!artifactPath) { if (!artifactPath || !artifacts) {
return; return;
} }
select(artifactPath); artifacts.select(artifactPath);
setOpen(true); artifacts.setOpen(true);
}} }}
> >
{path && ( {path && (
<ChainOfThoughtSearchResult className="cursor-pointer"> <ChainOfThoughtSearchResult className="cursor-pointer">
{path} {path}
@@ -412,6 +411,17 @@ function ToolCall({
icon={ListTodoIcon} icon={ListTodoIcon}
></ChainOfThoughtStep> ></ChainOfThoughtStep>
); );
} else if (name === "update_artifact") {
const title = typeof args.title === "string" ? args.title : undefined;
const type = args.type as string | undefined;
const label = title ?? (type === "code" ? "生成代码" : "生成文档");
return (
<ChainOfThoughtStep key={id} label={label} icon={NotebookPenIcon}>
<ChainOfThoughtSearchResult>
{type === "code" ? "代码文件" : "Markdown 文档"}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
} else { } else {
const description: string | undefined = (args as { description: string }) const description: string | undefined = (args as { description: string })
?.description; ?.description;
@@ -87,6 +87,20 @@ export function CanvasArtifactPanel() {
); );
} }
// While streaming but no partial content yet — show a loading placeholder
// so the template picker doesn't flash during generation.
if (isStreaming && !artifact) {
return (
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
<div className="text-center space-y-1">
<p className="text-sm font-medium text-foreground">AI 正在生成…</p>
<p className="text-xs">内容将在右侧显示</p>
</div>
</div>
);
}
if (!artifact) { if (!artifact) {
return ( return (
<div className="h-full bg-muted/10"> <div className="h-full bg-muted/10">
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
import type { CanvasArtifactText } from "../../types/artifact"; import type { CanvasArtifactText } from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection"; import { useCanvasSelection } from "../../hooks/useCanvasSelection";
@@ -11,6 +14,37 @@ interface TextArtifactRendererProps {
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) { export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
const { handleTextPointerUp } = useCanvasSelection(); const { handleTextPointerUp } = useCanvasSelection();
const scrollRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const autoScrollRef = useRef(true);
// Auto-scroll to bottom while streaming, unless user has scrolled up
useEffect(() => {
if (!isStreaming) {
autoScrollRef.current = true;
setShowScrollBtn(false);
return;
}
if (!autoScrollRef.current) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [isStreaming, content.fullMarkdown]);
const handleScroll = useCallback(() => {
if (!isStreaming) return;
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
setShowScrollBtn(!atBottom);
}, [isStreaming]);
const handleScrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
autoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
if (!content.fullMarkdown) { if (!content.fullMarkdown) {
return ( return (
@@ -23,124 +57,142 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
} }
return ( return (
<div <div className="relative h-full">
className="h-full overflow-y-auto" <div
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)} ref={scrollRef}
> className="h-full overflow-y-auto"
<div className="max-w-3xl mx-auto px-8 py-8"> onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
<ReactMarkdown onScroll={handleScroll}
remarkPlugins={[remarkGfm]} >
components={{ <div className="max-w-3xl mx-auto px-8 py-8">
h1: ({ children }) => ( <ReactMarkdown
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2"> remarkPlugins={[remarkGfm]}
{children} components={{
</h1> h1: ({ children }) => (
), <h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
h2: ({ children }) => ( {children}
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground"> </h1>
{children} ),
</h2> h2: ({ children }) => (
), <h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
h3: ({ children }) => ( {children}
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground"> </h2>
{children} ),
</h3> h3: ({ children }) => (
), <h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
h4: ({ children }) => ( {children}
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground"> </h3>
{children} ),
</h4> h4: ({ children }) => (
), <h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
p: ({ children }) => ( {children}
<p className="mb-4 leading-7 text-sm text-foreground/90"> </h4>
{children} ),
</p> p: ({ children }) => (
), <p className="mb-4 leading-7 text-sm text-foreground/90">
ul: ({ children }) => ( {children}
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90"> </p>
{children} ),
</ul> ul: ({ children }) => (
), <ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
ol: ({ children }) => ( {children}
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90"> </ul>
{children} ),
</ol> ol: ({ children }) => (
), <ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
li: ({ children }) => ( {children}
<li className="leading-7">{children}</li> </ol>
), ),
blockquote: ({ children }) => ( li: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm"> <li className="leading-7">{children}</li>
{children} ),
</blockquote> blockquote: ({ children }) => (
), <blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
pre: ({ children }) => ( {children}
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto"> </blockquote>
{children} ),
</pre> pre: ({ children }) => (
), <pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
code: ({ className, children }) => { {children}
const isBlock = Boolean(className?.startsWith("language-")); </pre>
if (isBlock) { ),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
if (isBlock) {
return (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
);
}
return ( return (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed"> <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children} {children}
</code> </code>
); );
} },
return ( table: ({ children }) => (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground"> <div className="mb-4 overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm border-collapse">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">{children}</thead>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
{children} {children}
</code> </th>
); ),
}, td: ({ children }) => (
table: ({ children }) => ( <td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
<div className="mb-4 overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm border-collapse">
{children} {children}
</table> </td>
</div> ),
), hr: () => <hr className="my-6 border-border" />,
thead: ({ children }) => ( a: ({ href, children }) => (
<thead className="bg-muted/50">{children}</thead> <a
), href={href}
th: ({ children }) => ( className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border"> target="_blank"
{children} rel="noopener noreferrer"
</th> >
), {children}
td: ({ children }) => ( </a>
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0"> ),
{children} strong: ({ children }) => (
</td> <strong className="font-semibold text-foreground">
), {children}
hr: () => <hr className="my-6 border-border" />, </strong>
a: ({ href, children }) => ( ),
<a em: ({ children }) => (
href={href} <em className="italic text-foreground/80">{children}</em>
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors" ),
target="_blank" }}
rel="noopener noreferrer" >
> {content.fullMarkdown}
{children} </ReactMarkdown>
</a> {isStreaming && (
), <span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
strong: ({ children }) => ( )}
<strong className="font-semibold text-foreground"> </div>
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-foreground/80">{children}</em>
),
}}
>
{content.fullMarkdown}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div> </div>
{isStreaming && showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div> </div>
); );
} }
@@ -1,16 +1,16 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { SendHorizonal, Sparkles, X } from "lucide-react";
import { useCallback, useState, type KeyboardEvent } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MessageList } from "@/components/workspace/messages";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { AgentThreadState } from "@/core/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection"; import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream"; import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream";
import type { CanvasMessage } from "../../types/thread";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact"; import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import { import {
buildMessageWithSelection, buildMessageWithSelection,
@@ -18,178 +18,6 @@ import {
resolveCodeSelection, resolveCodeSelection,
} from "../../utils/selection"; } from "../../utils/selection";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
options?: ClarificationOption[];
allow_custom?: boolean;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripThinkBlocks(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
const c = msg.additional_kwargs?.clarification;
if (typeof c !== "object" || c === null) return null;
return c as ClarificationPayload;
}
function getTextContent(msg: CanvasMessage): string {
return typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content);
}
// ---------------------------------------------------------------------------
// ClarificationCard
// ---------------------------------------------------------------------------
function ClarificationCard({
message,
disabled,
onSubmit,
}: {
message: CanvasMessage;
disabled: boolean;
onSubmit: (answer: string) => void;
}) {
const payload = getClarificationPayload(message)!;
const [customAnswer, setCustomAnswer] = useState("");
const [submitted, setSubmitted] = useState(false);
const options = (payload.options ?? []).filter(
(o) => typeof o.label === "string" && o.label.trim(),
);
const busy = disabled || submitted;
function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || busy) return;
setSubmitted(true);
onSubmit(trimmed);
}
const fallback = stripThinkBlocks(getTextContent(message));
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallback}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={busy}
onClick={() => submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span className="text-sm">{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={busy}
className="min-h-20 resize-none text-sm"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={busy || !customAnswer.trim()}
onClick={() => submitAnswer(customAnswer)}
>
<SendHorizonal className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// AiMessageContent — renders a single AI message bubble
// ---------------------------------------------------------------------------
function AiMessageContent({
message,
isActiveStream,
isStreaming,
onClarificationSubmit,
}: {
message: CanvasMessage;
isActiveStream: boolean;
isStreaming: boolean;
onClarificationSubmit: (answer: string) => void;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
const clarification = getClarificationPayload(message);
if (clarification) {
return (
<ClarificationCard
message={message}
disabled={isStreaming}
onSubmit={onClarificationSubmit}
/>
);
}
const displayText = stripThinkBlocks(getTextContent(message));
if (!displayText) return null;
return (
<MarkdownContent
content={displayText}
isLoading={isActiveStream}
rehypePlugins={rehypePlugins}
className="text-sm"
/>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ChatWelcome // ChatWelcome
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -217,15 +45,14 @@ function ChatWelcome() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CanvasChatPanel() { export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext(); const { artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream(); const { thread, sendMessage, threadId } = useCanvasThreadStream();
const { selection, clearSelection } = useCanvasSelection(); const { selection, clearSelection } = useCanvasSelection();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const hasMessages = messages.length > 0;
const hasSelection = hasActiveSelection(selection); const hasSelection = hasActiveSelection(selection);
const isStreaming = thread.isLoading;
const hasMessages = thread.messages.length > 0;
// Resolve selected code snippet when artifact is a code artifact // Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact); const currentContent = getCurrentArtifactContent(artifact);
@@ -252,11 +79,6 @@ export function CanvasChatPanel() {
return null; return null;
})(); })();
// Scroll to bottom whenever messages change or streaming state updates
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
async function handleSend() { async function handleSend() {
const text = input.trim(); const text = input.trim();
if (!text || isStreaming) return; if (!text || isStreaming) return;
@@ -277,80 +99,30 @@ export function CanvasChatPanel() {
} }
} }
// Index of the last AI message — used to decide which message gets streaming animation const handleClarificationSubmit = useCallback(
const lastAiIndex = (() => { async (answer: string) => {
for (let i = messages.length - 1; i >= 0; i--) { await sendMessage(answer);
if (messages[i]!.type !== "human") return i; },
} [sendMessage],
return -1; );
})();
// Whether the last AI message has any visible text (not just think blocks / empty) // MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
const lastAiHasVisibleContent = (() => { // BaseStream<CanvasThreadValues> — structurally compatible for message rendering
if (lastAiIndex < 0) return false; // since MessageList only reads thread.messages, isLoading, and isThreadLoading.
const msg = messages[lastAiIndex]!; const threadForList = thread as unknown as BaseStream<AgentThreadState>;
if (getClarificationPayload(msg)) return true;
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
})();
return ( return (
<div className="h-full flex flex-col bg-background"> <div className="h-full flex flex-col bg-background">
{/* Message list */} {/* Message list */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{hasMessages ? ( {hasMessages || isStreaming || thread.isThreadLoading ? (
<div className="p-4 space-y-4"> <MessageList
{messages.map((msg, i) => { className="size-full"
const isHuman = msg.type === "human"; threadId={threadId ?? "canvas"}
thread={threadForList}
return ( paddingBottom={0}
<div onClarificationSubmit={handleClarificationSubmit}
key={msg.id ?? i} />
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
>
{!isHuman && (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
)}
{isHuman ? (
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
{getTextContent(msg)}
</div>
) : (
<div className="max-w-[85%] min-w-0">
<AiMessageContent
message={msg}
isActiveStream={isStreaming && i === lastAiIndex}
isStreaming={isStreaming}
onClarificationSubmit={(answer) => void sendMessage(answer)}
/>
</div>
)}
{isHuman && (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3 h-3" />
</div>
)}
</div>
);
})}
{isStreaming && !lastAiHasVisibleContent && (
<div className="flex gap-2 justify-start">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">正在生成…</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
) : ( ) : (
<ChatWelcome /> <ChatWelcome />
)} )}
@@ -382,7 +154,6 @@ export function CanvasChatPanel() {
<div className="border-t p-3 shrink-0"> <div className="border-t p-3 shrink-0">
<div className="flex gap-2 items-end"> <div className="flex gap-2 items-end">
<Textarea <Textarea
ref={textareaRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -401,11 +172,7 @@ export function CanvasChatPanel() {
disabled={!input.trim() || isStreaming} disabled={!input.trim() || isStreaming}
onClick={() => void handleSend()} onClick={() => void handleSend()}
> >
{isStreaming ? ( <SendHorizonal className="h-4 w-4" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizonal className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -0,0 +1,237 @@
// Canvas-specific variant of useThreadStream (core/threads/hooks.ts).
// Copied and adapted for canvas_agent so canvas writing-mode interactions
// can be customized independently from the main lead_agent flow.
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { getAPIClient } from "@/core/api";
import { useUpdateSubtask } from "@/core/tasks/context";
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
import type { StreamingArtifactPartial } from "../api/runs";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasThreadValues } from "../types/thread";
import { getCurrentArtifactContent } from "../utils/artifact";
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) return error;
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message");
if (typeof message === "string" && message.trim()) return message;
}
return "Request failed.";
}
/**
* Canvas-specific stream hook — a direct copy of the main agent's useThreadStream,
* configured for canvas_agent. Modify sendMessage here to inject canvas-specific
* context (writing_artifact_path, canvas_mode, highlighted text, etc.).
*/
export function useCanvasThreadStream() {
const navigate = useNavigate();
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
const { artifact, setArtifact, setIsStreaming, setStreamingArtifact } = useCanvasGraphContext();
const updateSubtask = useUpdateSubtask();
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const sendInFlightRef = useRef(false);
const prevMsgCountRef = useRef(0);
const [onStreamThreadId, setOnStreamThreadId] = useState<string | null>(
() => threadId,
);
const threadIdRef = useRef<string | null>(threadId ?? null);
useEffect(() => {
const normalized = threadId ?? null;
setOnStreamThreadId(normalized);
threadIdRef.current = normalized;
if (!normalized) {
sendInFlightRef.current = false;
prevMsgCountRef.current = 0;
setOptimisticMessages([]);
}
}, [threadId]);
const thread = useStream<CanvasThreadValues>({
client: getAPIClient(),
assistantId: CANVAS_AGENT_ID,
threadId: onStreamThreadId,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
setOnStreamThreadId(meta.thread_id);
threadIdRef.current = meta.thread_id;
setThreadId(meta.thread_id);
navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
void getAPIClient()
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
.catch(() => {});
},
onUpdateEvent(data) {
const updates: Array<Partial<CanvasThreadValues> | null> = Object.values(
data || {},
);
for (const update of updates) {
if (update?.artifact) {
setArtifact(update.artifact as CanvasArtifact);
}
}
},
onCustomEvent(event: unknown) {
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
(event as { type: unknown }).type === "task_running"
) {
const e = event as {
type: "task_running";
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
return;
}
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
(event as { type: unknown }).type === "llm_retry" &&
"message" in event &&
typeof (event as { message: unknown }).message === "string"
) {
toast((event as { message: string }).message);
}
},
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
},
});
// Keep CanvasGraphContext.isStreaming in sync so the artifact panel and
// quick-action bar can react to streaming state.
useEffect(() => {
setIsStreaming(thread.isLoading);
}, [thread.isLoading, setIsStreaming]);
// Detect update_artifact tool calls from streaming messages to populate
// streamingArtifact so the right panel shows content before the final
// state update fires via onUpdateEvent.
useEffect(() => {
if (!thread.isLoading) {
setStreamingArtifact(null);
return;
}
const msgs = thread.messages;
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i];
if (msg.type !== "ai") continue;
const toolCalls = (msg as AIMessage).tool_calls;
if (!toolCalls?.length) continue;
const call = toolCalls.find((tc) => tc.name === "update_artifact");
if (!call?.args) continue;
const args = call.args as Record<string, unknown>;
const partial: StreamingArtifactPartial = {
type: args.type as "text" | "code" | undefined,
title: typeof args.title === "string" ? args.title : undefined,
content: typeof args.content === "string" ? args.content : undefined,
language: typeof args.language === "string" ? args.language : undefined,
};
if (partial.title !== undefined || partial.content !== undefined) {
setStreamingArtifact(partial);
return;
}
}
}, [thread.messages, thread.isLoading, setStreamingArtifact]);
// Clear optimistic messages once the server stream begins delivering real ones.
useEffect(() => {
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
) {
setOptimisticMessages([]);
}
}, [thread.messages.length, optimisticMessages.length]);
/**
* Canvas writing-mode sendMessage — copied from ChatPage's handleSubmit flow.
* Modify here to inject writing_artifact_path, canvas_mode, highlighted
* text/code, or any other canvas-specific context the agent needs.
*/
const sendMessage = useCallback(
async (text: string, extraContext?: Record<string, unknown>) => {
const trimmed = text.trim();
if (!trimmed || sendInFlightRef.current) return;
sendInFlightRef.current = true;
prevMsgCountRef.current = thread.messages.length;
setOptimisticMessages([
{
type: "human",
id: `opt-human-${Date.now()}`,
content: [{ type: "text", text: trimmed }],
additional_kwargs: {},
},
]);
// Pass current artifact info so the backend can instruct the LLM to
// continue editing the same artifact instead of creating a new one.
const currentContent = artifact ? getCurrentArtifactContent(artifact) : null;
const artifactContext = currentContent
? {
canvas_artifact_title: currentContent.title,
canvas_artifact_type: currentContent.type,
}
: {};
try {
await thread.submit(
{
messages: [
{ type: "human", content: [{ type: "text", text: trimmed }] },
],
},
{
threadId: threadIdRef.current ?? undefined,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
...(modelName ? { configurable: { model_name: modelName } } : {}),
},
context: {
...extraContext,
...artifactContext,
thread_id: threadIdRef.current ?? "",
writing_mode: true,
},
},
);
} catch {
setOptimisticMessages([]);
} finally {
sendInFlightRef.current = false;
}
},
[thread, modelName, artifact],
);
const mergedThread =
optimisticMessages.length > 0
? ({
...thread,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
return { thread: mergedThread, sendMessage, threadId };
}
@@ -152,10 +152,12 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name", "model_name",
"mode", "mode",
"thinking_enabled", "thinking_enabled",
"reasoning_effort", "reasoning_effort",
"writing_mode", "writing_mode",
"writing_artifact_path", "writing_artifact_path",
"is_plan_mode", "canvas_artifact_title",
"canvas_artifact_type",
"is_plan_mode",
"subagent_enabled", "subagent_enabled",
"max_concurrent_subagents", "max_concurrent_subagents",
"agent_name", "agent_name",
@@ -5,7 +5,7 @@ import logging
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from deerflow.agents.canvas_agent.prompt import CANVAS_SYSTEM_PROMPT from deerflow.agents.canvas_agent.prompt import build_canvas_system_prompt
from deerflow.agents.canvas_agent.tools import update_artifact_tool from deerflow.agents.canvas_agent.tools import update_artifact_tool
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
@@ -49,7 +49,19 @@ def make_canvas_agent(config: RunnableConfig):
if thinking_enabled and model_config and not model_config.supports_thinking: if thinking_enabled and model_config and not model_config.supports_thinking:
thinking_enabled = False thinking_enabled = False
logger.info("Create CanvasAgent -> model_name: %s, thinking_enabled: %s", model_name, thinking_enabled) canvas_artifact_title = cfg.get("canvas_artifact_title") or None
canvas_artifact_type = cfg.get("canvas_artifact_type") or None
system_prompt = build_canvas_system_prompt(
artifact_title=canvas_artifact_title,
artifact_type=canvas_artifact_type,
)
logger.info(
"Create CanvasAgent -> model_name: %s, thinking_enabled: %s, artifact_title: %s",
model_name,
thinking_enabled,
canvas_artifact_title,
)
middlewares = [ middlewares = [
DanglingToolCallMiddleware(), DanglingToolCallMiddleware(),
@@ -63,6 +75,6 @@ def make_canvas_agent(config: RunnableConfig):
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=app_config), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=app_config),
tools=[update_artifact_tool, ask_clarification_tool], tools=[update_artifact_tool, ask_clarification_tool],
middleware=middlewares, middleware=middlewares,
system_prompt=CANVAS_SYSTEM_PROMPT, system_prompt=system_prompt,
state_schema=ThreadState, state_schema=ThreadState,
) )
@@ -1,4 +1,18 @@
CANVAS_SYSTEM_PROMPT = """You are a Canvas AI assistant that helps users create and edit documents and code. def build_canvas_system_prompt(
artifact_title: str | None = None,
artifact_type: str | None = None,
) -> str:
existing_artifact_block = ""
if artifact_title:
type_label = "代码" if artifact_type == "code" else "文档"
existing_artifact_block = f"""
<existing_artifact>
用户当前有一个{type_label}制品,标题为 "{artifact_title}"。
请继续编辑该制品——除非用户明确要求新建,否则不要创建新制品。
</existing_artifact>
"""
return f"""You are a Canvas AI assistant that helps users create and edit documents and code.
<canvas_behavior> <canvas_behavior>
You work with an **artifact** — a document or code file shown in the canvas panel on the right. You work with an **artifact** — a document or code file shown in the canvas panel on the right.
@@ -14,7 +28,7 @@ Rules:
The current artifact state is injected into your context. When editing, build on the existing content exactly. The current artifact state is injected into your context. When editing, build on the existing content exactly.
When creating from scratch, produce a complete, high-quality artifact in one shot. When creating from scratch, produce a complete, high-quality artifact in one shot.
</artifact_context> </artifact_context>
{existing_artifact_block}
<selection_rewrite> <selection_rewrite>
When the user's message contains a <selection> block, you MUST: When the user's message contains a <selection> block, you MUST:
1. Identify the exact selection in the current artifact content. 1. Identify the exact selection in the current artifact content.
@@ -38,3 +52,7 @@ When the user triggers a quick action, apply it to the full artifact unless spec
- optimize_code: Improve performance and readability - optimize_code: Improve performance and readability
</quick_actions> </quick_actions>
""" """
# Backward-compatible constant for callers that don't pass artifact context.
CANVAS_SYSTEM_PROMPT = build_canvas_system_prompt()