代码修改

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;
}
export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null;
}
@@ -33,7 +33,7 @@ import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
@@ -201,8 +201,7 @@ function ToolCall({
isLoading?: boolean;
}) {
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
const artifacts = useArtifactsOptional();
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -329,43 +328,43 @@ function ToolCall({
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || selectedArtifact === artifactPath) {
return;
}
select(artifactPath, true);
setOpen(true);
}, 100);
}
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
return;
}
artifacts.select(artifactPath, true);
artifacts.setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath) {
return;
}
select(artifactPath);
setOpen(true);
}}
>
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) {
return;
}
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
@@ -412,6 +411,17 @@ function ToolCall({
icon={ListTodoIcon}
></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 {
const description: string | undefined = (args as { description: string })
?.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) {
return (
<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 remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
import type { CanvasArtifactText } from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
@@ -11,6 +14,37 @@ interface TextArtifactRendererProps {
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
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) {
return (
@@ -23,124 +57,142 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
}
return (
<div
className="h-full overflow-y-auto"
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
>
<div className="max-w-3xl mx-auto px-8 py-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
{children}
</h4>
),
p: ({ children }) => (
<p className="mb-4 leading-7 text-sm text-foreground/90">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
{children}
</ol>
),
li: ({ children }) => (
<li className="leading-7">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
if (isBlock) {
<div className="relative h-full">
<div
ref={scrollRef}
className="h-full overflow-y-auto"
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
onScroll={handleScroll}
>
<div className="max-w-3xl mx-auto px-8 py-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
{children}
</h4>
),
p: ({ children }) => (
<p className="mb-4 leading-7 text-sm text-foreground/90">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
{children}
</ol>
),
li: ({ children }) => (
<li className="leading-7">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
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 (
<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}
</code>
);
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
},
table: ({ children }) => (
<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}
</code>
);
},
table: ({ children }) => (
<div className="mb-4 overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm border-collapse">
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
{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}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
{children}
</td>
),
hr: () => <hr className="my-6 border-border" />,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{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" />
)}
</td>
),
hr: () => <hr className="my-6 border-border" />,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{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>
);
}
@@ -1,16 +1,16 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendHorizonal, Sparkles, X } from "lucide-react";
import { useCallback, useState, type KeyboardEvent } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { MessageList } from "@/components/workspace/messages";
import type { AgentThreadState } from "@/core/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import type { CanvasMessage } from "../../types/thread";
import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
@@ -18,178 +18,6 @@ import {
resolveCodeSelection,
} 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
// ---------------------------------------------------------------------------
@@ -217,15 +45,14 @@ function ChatWelcome() {
// ---------------------------------------------------------------------------
export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream();
const { artifact } = useCanvasGraphContext();
const { thread, sendMessage, threadId } = useCanvasThreadStream();
const { selection, clearSelection } = useCanvasSelection();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const hasMessages = messages.length > 0;
const hasSelection = hasActiveSelection(selection);
const isStreaming = thread.isLoading;
const hasMessages = thread.messages.length > 0;
// Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact);
@@ -252,11 +79,6 @@ export function CanvasChatPanel() {
return null;
})();
// Scroll to bottom whenever messages change or streaming state updates
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
async function handleSend() {
const text = input.trim();
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 lastAiIndex = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.type !== "human") return i;
}
return -1;
})();
const handleClarificationSubmit = useCallback(
async (answer: string) => {
await sendMessage(answer);
},
[sendMessage],
);
// Whether the last AI message has any visible text (not just think blocks / empty)
const lastAiHasVisibleContent = (() => {
if (lastAiIndex < 0) return false;
const msg = messages[lastAiIndex]!;
if (getClarificationPayload(msg)) return true;
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
})();
// MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
// BaseStream<CanvasThreadValues> — structurally compatible for message rendering
// since MessageList only reads thread.messages, isLoading, and isThreadLoading.
const threadForList = thread as unknown as BaseStream<AgentThreadState>;
return (
<div className="h-full flex flex-col bg-background">
{/* Message list */}
<div className="flex-1 overflow-y-auto min-h-0">
{hasMessages ? (
<div className="p-4 space-y-4">
{messages.map((msg, i) => {
const isHuman = msg.type === "human";
return (
<div
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>
{hasMessages || isStreaming || thread.isThreadLoading ? (
<MessageList
className="size-full"
threadId={threadId ?? "canvas"}
thread={threadForList}
paddingBottom={0}
onClarificationSubmit={handleClarificationSubmit}
/>
) : (
<ChatWelcome />
)}
@@ -382,7 +154,6 @@ export function CanvasChatPanel() {
<div className="border-t p-3 shrink-0">
<div className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
@@ -401,11 +172,7 @@ export function CanvasChatPanel() {
disabled={!input.trim() || isStreaming}
onClick={() => void handleSend()}
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizonal className="h-4 w-4" />
)}
<SendHorizonal className="h-4 w-4" />
</Button>
</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",
"mode",
"thinking_enabled",
"reasoning_effort",
"writing_mode",
"writing_artifact_path",
"is_plan_mode",
"reasoning_effort",
"writing_mode",
"writing_artifact_path",
"canvas_artifact_title",
"canvas_artifact_type",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
"agent_name",
@@ -5,7 +5,7 @@ import logging
from langchain.agents import create_agent
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.middlewares.clarification_middleware import ClarificationMiddleware
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:
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 = [
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),
tools=[update_artifact_tool, ask_clarification_tool],
middleware=middlewares,
system_prompt=CANVAS_SYSTEM_PROMPT,
system_prompt=system_prompt,
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>
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.
When creating from scratch, produce a complete, high-quality artifact in one shot.
</artifact_context>
{existing_artifact_block}
<selection_rewrite>
When the user's message contains a <selection> block, you MUST:
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
</quick_actions>
"""
# Backward-compatible constant for callers that don't pass artifact context.
CANVAS_SYSTEM_PROMPT = build_canvas_system_prompt()