openCanvas

This commit is contained in:
董宏凯 committed 2026-05-15 23:27:34 +08:00
1 parent 050b1ad78e
commit 1be600155b
18 files changed
+754 -101

No files matched your search

+4 -4
View File
@@ -1,6 +1,6 @@
# Open Canvas 集成进度文档
> 更新时间:2026-05-15
> 更新时间:2026-05-16
> 当前分支:dhk-1
---
@@ -18,10 +18,10 @@
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
| 前后端阶段八 | 快捷动作系统 | ⬜ 待开始 |
| 前后端阶段九 | 本地模型支持完善 | ⬜ 待开始 |
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
**下一个要做的任务**:前后端阶段八 — 快捷动作系统(CanvasEmptyState 模板触发 + 快捷动作按钮)
**当前状态**:全部 9 个阶段已完成 🎉
---
@@ -12,6 +12,8 @@ interface BackendModel {
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
supports_tool_calling?: boolean;
// Canvas-phase-9 fields (now returned by backend)
description?: string;
}
export async function loadCanvasModels(): Promise<CanvasModel[]> {
+135 -5
View File
@@ -12,38 +12,168 @@ export type CanvasRunInput = {
quick_action_id?: string;
};
/** Partial artifact data extracted from streaming tool_call_chunks */
export type StreamingArtifactPartial = {
type?: "text" | "code";
title?: string;
content?: string;
language?: string;
};
export type CanvasStreamCallbacks = {
onMessage?: (msg: CanvasMessage) => void;
onArtifact?: (artifact: CanvasArtifact) => void;
/** Called repeatedly during streaming as update_artifact args accumulate */
onStreamingArtifact?: (partial: StreamingArtifactPartial) => void;
onDone?: () => void;
onError?: (err: unknown) => void;
};
// ---------------------------------------------------------------------------
// Streaming JSON arg extraction helpers
// ---------------------------------------------------------------------------
function unescapeJsonFragment(raw: string): string {
// Remove trailing incomplete escape sequence
const safe = raw.replace(/\\[^"nrts\\bfu]?$/, "");
let out = "";
let i = 0;
while (i < safe.length) {
if (safe[i] === "\\" && i + 1 < safe.length) {
const next = safe[i + 1]!;
if (next === "n") { out += "\n"; i += 2; }
else if (next === "r") { out += "\r"; i += 2; }
else if (next === "t") { out += "\t"; i += 2; }
else if (next === '"') { out += '"'; i += 2; }
else if (next === "\\") { out += "\\"; i += 2; }
else if (next === "b") { out += "\b"; i += 2; }
else if (next === "f") { out += "\f"; i += 2; }
else { out += safe[i]!; i++; }
} else if (safe[i] === '"') {
// Unescaped closing quote — content value ends here
break;
} else {
out += safe[i]!;
i++;
}
}
return out;
}
/** Extract a fully-quoted string field, e.g. "type": "text" */
function extractStringField(args: string, field: string): string | undefined {
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, "");
const m = re.exec(args);
if (!m) return undefined;
return unescapeJsonFragment(m[1]!);
}
/** Extract the content field even from incomplete JSON (no closing quote yet) */
function extractContentField(args: string): string | undefined {
const keyIdx = args.indexOf('"content"');
if (keyIdx === -1) return undefined;
const afterKey = args.slice(keyIdx + '"content"'.length);
const colonQuote = /^\s*:\s*"/.exec(afterKey);
if (!colonQuote) return undefined;
const valueStart = colonQuote[0].length;
const raw = afterKey.slice(valueStart);
return unescapeJsonFragment(raw);
}
function extractPartialArtifactArgs(args: string): StreamingArtifactPartial {
return {
type: extractStringField(args, "type") as "text" | "code" | undefined,
title: extractStringField(args, "title"),
language: extractStringField(args, "language"),
content: extractContentField(args),
};
}
// ---------------------------------------------------------------------------
// Main stream function
// ---------------------------------------------------------------------------
export async function streamCanvasRun(
threadId: string,
input: CanvasRunInput,
callbacks: CanvasStreamCallbacks,
modelName?: string | null,
): Promise<void> {
const client = getAPIClient();
try {
const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, {
input,
streamMode: ["values", "messages-tuple"],
...(modelName ? { config: { configurable: { model_name: modelName } } } : {}),
});
// Accumulate tool call args by index position
const toolCallAccumulator: Record<number, { name: string; args: string }> = {};
for await (const chunk of stream) {
// Cast to string: SDK typings only list a subset of event names
const event = chunk.event as string;
const data = (chunk as { event: string; data: unknown }).data;
if (event === "values") {
const state = data as Record<string, unknown>;
if (state.artifact) {
callbacks.onArtifact?.(state.artifact as CanvasArtifact);
}
} else if (event === "messages/tuple") {
const [msg] = data as [CanvasMessage];
if (msg) {
callbacks.onMessage?.(msg);
} else if (event === "messages") {
const tuple = data as [unknown, unknown] | unknown;
const rawMsg = Array.isArray(tuple) ? tuple[0] : tuple;
if (!rawMsg || typeof rawMsg !== "object") continue;
const msg = rawMsg as Record<string, unknown>;
// Emit text content to chat panel
callbacks.onMessage?.(msg as unknown as CanvasMessage);
// ---- Streaming tool call args (tool_call_chunks) ----
const toolCallChunks = msg.tool_call_chunks;
if (Array.isArray(toolCallChunks) && toolCallChunks.length > 0) {
for (const tcChunk of toolCallChunks as Array<Record<string, unknown>>) {
const idx = (tcChunk.index as number | undefined) ?? 0;
if (!toolCallAccumulator[idx]) {
toolCallAccumulator[idx] = {
name: (tcChunk.name as string | undefined) ?? "",
args: "",
};
} else if (tcChunk.name) {
toolCallAccumulator[idx]!.name = tcChunk.name as string;
}
toolCallAccumulator[idx]!.args += (tcChunk.args as string | undefined) ?? "";
if (toolCallAccumulator[idx]!.name === "update_artifact") {
const partial = extractPartialArtifactArgs(toolCallAccumulator[idx]!.args);
if (partial.content !== undefined || partial.title !== undefined) {
callbacks.onStreamingArtifact?.(partial);
}
}
}
}
// ---- Complete tool_calls (non-streaming or final snapshot) ----
const toolCalls = msg.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls as Array<Record<string, unknown>>) {
if (tc.name === "update_artifact" && tc.args) {
try {
const args =
typeof tc.args === "string"
? (JSON.parse(tc.args) as Record<string, unknown>)
: (tc.args as Record<string, unknown>);
callbacks.onStreamingArtifact?.({
type: args.type as "text" | "code" | undefined,
title: args.title as string | undefined,
content: args.content as string | undefined,
language: args.language as string | undefined,
});
} catch {
// ignore
}
}
}
}
}
}
@@ -1,9 +1,15 @@
import { FileText, Loader2 } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import type { StreamingArtifactPartial } from "../../api/runs";
import {
getCurrentArtifactContent,
isCodeArtifact,
} from "../../utils/artifact";
import { CanvasArtifactHeader } from "./CanvasArtifactHeader";
import { CanvasEmptyState } from "./CanvasEmptyState";
import { CanvasQuickActionBar } from "./CanvasQuickActionBar";
import { TextArtifactRenderer } from "./TextArtifactRenderer";
import { CodeArtifactRenderer } from "./CodeArtifactRenderer";
import type {
@@ -11,11 +17,76 @@ import type {
CanvasArtifactText,
} from "../../types/artifact";
// ---------------------------------------------------------------------------
// StreamingOverlay — shown while update_artifact args are streaming in
// ---------------------------------------------------------------------------
function StreamingTextPreview({ partial }: { partial: StreamingArtifactPartial }) {
const hasContent = partial.content && partial.content.trim();
if (!hasContent) {
// Args started but content not yet arrived — show skeleton
return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
<div className="space-y-1 text-center">
{partial.title ? (
<p className="text-sm font-medium text-foreground">{partial.title}</p>
) : null}
<p className="text-xs">AI 正在生成文档…</p>
</div>
</div>
);
}
// Stream content into the real renderer (read-only, isStreaming = true)
if (partial.type === "code") {
const fakeContent: CanvasArtifactCode = {
index: 0,
type: "code",
title: partial.title ?? "代码",
language: (partial.language as CanvasArtifactCode["language"]) ?? "other",
code: partial.content ?? "",
};
return <CodeArtifactRenderer content={fakeContent} isStreaming />;
}
const fakeContent: CanvasArtifactText = {
index: 0,
type: "text",
title: partial.title ?? "文档",
fullMarkdown: partial.content ?? "",
};
return <TextArtifactRenderer content={fakeContent} isStreaming />;
}
// ---------------------------------------------------------------------------
// CanvasArtifactPanel
// ---------------------------------------------------------------------------
export function CanvasArtifactPanel() {
const { artifact } = useCanvasGraphContext();
const { artifact, streamingArtifact, isStreaming } = useCanvasGraphContext();
const [searchParams] = useSearchParams();
const initialType = searchParams.get("type") as "text" | "code" | null;
// While streaming and we have partial data, show the streaming preview
if (isStreaming && streamingArtifact) {
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b shrink-0 bg-muted/30">
<FileText className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium truncate flex-1">
{streamingArtifact.title ?? "正在生成…"}
</span>
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary shrink-0" />
</div>
<div className="flex-1 overflow-hidden">
<StreamingTextPreview partial={streamingArtifact} />
</div>
</div>
);
}
if (!artifact) {
return (
<div className="h-full bg-muted/10">
@@ -29,6 +100,7 @@ export function CanvasArtifactPanel() {
return (
<div className="h-full flex flex-col overflow-hidden">
<CanvasArtifactHeader />
<CanvasQuickActionBar />
<div className="flex-1 overflow-hidden">
{content && isCodeArtifact(content) ? (
<CodeArtifactRenderer content={content as CanvasArtifactCode} />
@@ -0,0 +1,34 @@
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasQuickActions } from "../../hooks/useCanvasQuickActions";
import type { QuickAction } from "../../types/quick-action";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import { QUICK_ACTIONS_CODE, QUICK_ACTIONS_TEXT } from "../../constants";
export function CanvasQuickActionBar() {
const { artifact, isStreaming } = useCanvasGraphContext();
const { triggerQuickAction } = useCanvasQuickActions();
const content = getCurrentArtifactContent(artifact);
if (!content) return null;
const actions: QuickAction[] = isCodeArtifact(content)
? QUICK_ACTIONS_CODE
: QUICK_ACTIONS_TEXT;
return (
<div className="border-b px-4 py-1.5 flex items-center gap-1.5 flex-wrap shrink-0 bg-background">
{actions.map((action) => (
<button
key={action.id}
disabled={isStreaming}
onClick={() => void triggerQuickAction(action.id)}
className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors
bg-background text-foreground hover:bg-accent hover:text-accent-foreground
disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{action.label}
</button>
))}
</div>
);
}
@@ -67,9 +67,10 @@ function getLanguageExtension(
interface CodeArtifactRendererProps {
content: CanvasArtifactCode;
isStreaming?: boolean;
}
export function CodeArtifactRenderer({ content }: CodeArtifactRendererProps) {
export function CodeArtifactRenderer({ content, isStreaming: _isStreaming }: CodeArtifactRendererProps) {
const { resolvedTheme } = useTheme();
const { handleCodeSelectionChange } = useCanvasSelection();
@@ -6,9 +6,10 @@ import { useCanvasSelection } from "../../hooks/useCanvasSelection";
interface TextArtifactRendererProps {
content: CanvasArtifactText;
isStreaming?: boolean;
}
export function TextArtifactRenderer({ content }: TextArtifactRendererProps) {
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
const { handleTextPointerUp } = useCanvasSelection();
if (!content.fullMarkdown) {
@@ -136,6 +137,9 @@ export function TextArtifactRenderer({ content }: TextArtifactRendererProps) {
>
{content.fullMarkdown}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
);
@@ -1,13 +1,16 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useRef, 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 { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import type { CanvasMessage } from "../../types/thread";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
@@ -15,6 +18,182 @@ 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
// ---------------------------------------------------------------------------
function ChatWelcome() {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 space-y-3">
@@ -33,12 +212,17 @@ function ChatWelcome() {
);
}
// ---------------------------------------------------------------------------
// CanvasChatPanel
// ---------------------------------------------------------------------------
export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream();
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);
@@ -68,12 +252,16 @@ 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;
setInput("");
// Embed selection context into the message when something is selected
const finalText = hasSelection
? buildMessageWithSelection(text, selection, resolvedCode)
: text;
@@ -89,6 +277,22 @@ 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;
})();
// 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;
})();
return (
<div className="h-full flex flex-col bg-background">
{/* Message list */}
@@ -97,10 +301,6 @@ export function CanvasChatPanel() {
<div className="p-4 space-y-4">
{messages.map((msg, i) => {
const isHuman = msg.type === "human";
const text =
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content);
return (
<div
@@ -112,15 +312,22 @@ export function CanvasChatPanel() {
<Sparkles className="w-3 h-3 text-primary" />
</div>
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
isHuman
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
}`}
>
{text}
</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" />
@@ -129,16 +336,20 @@ export function CanvasChatPanel() {
</div>
);
})}
{isStreaming && (
{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">
<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 />
@@ -155,9 +366,7 @@ export function CanvasChatPanel() {
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">
{selectionPreview}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
@@ -13,6 +13,7 @@ import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { getCurrentArtifactContent } from "../../utils/artifact";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
export function CanvasHeader() {
const navigate = useNavigate();
@@ -66,11 +67,7 @@ export function CanvasHeader() {
)}
</div>
{threadId && (
<span className="text-[11px] text-muted-foreground font-mono hidden sm:block shrink-0 max-w-[120px] truncate">
{threadId.slice(0, 8)}
</span>
)}
<CanvasModelSelector />
</div>
);
}
@@ -7,17 +7,24 @@ import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout";
export function CanvasShell() {
const { threadId } = useCanvasThreadContext();
const { threadId, setThreadId } = useCanvasThreadContext();
const { setMessages, setArtifact } = useCanvasGraphContext();
// Load existing thread state when navigating to a saved thread
// Load existing thread state when navigating to a saved thread.
// If the thread doesn't exist in the backend (e.g. pre-generated UUID from
// CanvasHomePage or stale URL after a DB reset), clear threadId so that
// the next sendMessage call creates a fresh thread instead of hitting 404.
useEffect(() => {
if (!threadId) return;
let cancelled = false;
getCanvasThread(threadId)
.then((values) => {
if (cancelled || !values) return;
if (cancelled) return;
if (!values) {
setThreadId(null);
return;
}
if (values.messages?.length) {
setMessages(values.messages);
}
@@ -32,7 +39,7 @@ export function CanvasShell() {
return () => {
cancelled = true;
};
}, [threadId, setMessages, setArtifact]);
}, [threadId, setMessages, setArtifact, setThreadId]);
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { Cpu } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { loadCanvasModels } from "../../api/models";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import type { CanvasModel } from "../../types/model";
export function CanvasModelSelector() {
const { modelName, setModelName } = useCanvasThreadContext();
const [models, setModels] = useState<CanvasModel[]>([]);
useEffect(() => {
loadCanvasModels()
.then((list) => {
setModels(list);
if (!modelName && list.length > 0) {
setModelName(list[0].name);
}
})
.catch(() => {
// fail silently — backend may be unavailable during dev
});
// only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (models.length === 0) return null;
const selected = models.find((m) => m.name === modelName) ?? models[0];
return (
<Select value={selected.name} onValueChange={setModelName}>
<SelectTrigger className="h-7 text-xs border-dashed w-auto min-w-[120px] max-w-[200px] gap-1.5">
<SelectValue>
<span className="flex items-center gap-1.5 truncate">
{selected.is_local && (
<Cpu className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="truncate">
{selected.display_name ?? selected.name}
</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{m.display_name ?? m.name}</span>
{m.is_local && (
<Badge variant="secondary" className="text-[10px] h-4 px-1 shrink-0">
本地
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -8,15 +8,18 @@ import {
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasMessage } from "../types/thread";
import type { StreamingArtifactPartial } from "../api/runs";
export interface CanvasGraphContextValue {
messages: CanvasMessage[];
artifact: CanvasArtifact | null;
streamingArtifact: StreamingArtifactPartial | null;
isStreaming: boolean;
setMessages: (messages: CanvasMessage[]) => void;
/** Upsert a message by id — concatenates string content for streaming deltas. */
appendMessage: (msg: CanvasMessage) => void;
setArtifact: (artifact: CanvasArtifact | null) => void;
setStreamingArtifact: (partial: StreamingArtifactPartial | null) => void;
setIsStreaming: (v: boolean) => void;
}
@@ -25,6 +28,8 @@ const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
export function CanvasGraphProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<CanvasMessage[]>([]);
const [artifact, setArtifact] = useState<CanvasArtifact | null>(null);
const [streamingArtifact, setStreamingArtifact] =
useState<StreamingArtifactPartial | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const appendMessage = useCallback((msg: CanvasMessage) => {
@@ -50,10 +55,12 @@ export function CanvasGraphProvider({ children }: { children: ReactNode }) {
value={{
messages,
artifact,
streamingArtifact,
isStreaming,
setMessages,
appendMessage,
setArtifact,
setStreamingArtifact,
setIsStreaming,
}}
>
@@ -1,2 +1,25 @@
// Phase 9: trigger quick actions and write quick_action_id to run input
export {};
import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions";
import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() {
const { sendMessage } = useCanvasStream();
const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback(
async (actionId: QuickActionId) => {
const content = getCurrentArtifactContent(artifact);
if (!content) return;
const message = buildQuickActionMessage(actionId);
await sendMessage(message, { quick_action_id: actionId });
},
[sendMessage, artifact],
);
return { triggerQuickAction };
}
@@ -11,8 +11,14 @@ import type { CanvasMessage } from "../types/thread";
export function useCanvasStream() {
const navigate = useNavigate();
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
const { messages, setMessages, appendMessage, setArtifact, setIsStreaming } =
useCanvasGraphContext();
const {
messages,
setMessages,
appendMessage,
setArtifact,
setStreamingArtifact,
setIsStreaming,
} = useCanvasGraphContext();
// Ref so callbacks inside streamCanvasRun always see the latest messages
// without adding `messages` to useCallback's dep array (avoids re-creation
@@ -51,24 +57,34 @@ export function useCanvasStream() {
};
// 4. Stream
await streamCanvasRun(activeThreadId, input, {
onMessage(msg) {
// Only surface AI text replies; skip tool messages
if (msg.type === "ai") {
appendMessage(msg);
}
await streamCanvasRun(
activeThreadId,
input,
{
onMessage(msg) {
if (msg.type === "ai" || msg.type === "AIMessageChunk") {
appendMessage(msg);
}
},
onStreamingArtifact(partial) {
setStreamingArtifact(partial);
},
onArtifact(artifact) {
setArtifact(artifact);
setStreamingArtifact(null);
},
onDone() {
setIsStreaming(false);
setStreamingArtifact(null);
},
onError(err) {
console.error("[canvas] stream error:", err);
setIsStreaming(false);
setStreamingArtifact(null);
},
},
onArtifact(artifact) {
setArtifact(artifact);
},
onDone() {
setIsStreaming(false);
},
onError(err) {
console.error("[canvas] stream error:", err);
setIsStreaming(false);
},
});
modelName ?? undefined,
);
},
// messagesRef replaces direct `messages` dep — intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -80,6 +96,7 @@ export function useCanvasStream() {
appendMessage,
setMessages,
setArtifact,
setStreamingArtifact,
setIsStreaming,
],
);
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { v4 as uuidv4 } from "uuid";
import { FileText, Code2, Sparkles } from "lucide-react";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
const QUICK_START_ITEMS = [
{
@@ -21,10 +22,30 @@ const QUICK_START_ITEMS = [
export default function CanvasHomePage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
function handleQuickStart(type: "text" | "code") {
const threadId = uuidv4();
navigate(`${threadId}?type=${type}`);
async function handleQuickStart(type: "text" | "code") {
if (loading) return;
setLoading(true);
try {
const threadId = await createCanvasThread();
navigate(`${threadId}?type=${type}`);
} catch (err) {
console.error("[canvas] failed to create thread:", err);
setLoading(false);
}
}
async function handleBlankCanvas() {
if (loading) return;
setLoading(true);
try {
const threadId = await createCanvasThread();
navigate(threadId);
} catch (err) {
console.error("[canvas] failed to create thread:", err);
setLoading(false);
}
}
return (
@@ -44,11 +65,16 @@ export default function CanvasHomePage() {
{QUICK_START_ITEMS.map(({ type, icon: Icon, title, description }) => (
<button
key={type}
onClick={() => handleQuickStart(type)}
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => void handleQuickStart(type)}
disabled={loading}
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
)}
</div>
<div className="space-y-1">
<p className="font-medium text-sm">{title}</p>
@@ -65,8 +91,12 @@ export default function CanvasHomePage() {
variant="ghost"
size="sm"
className="text-muted-foreground text-xs"
onClick={() => navigate(uuidv4())}
onClick={() => void handleBlankCanvas()}
disabled={loading}
>
{loading ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : null}
或直接开始空白 Canvas
</Button>
</div>
@@ -1,2 +1,16 @@
// Phase 9: map quick action ids to run inputs
export {};
import type { QuickActionId } from "../types/quick-action";
const QUICK_ACTION_MESSAGES: Record<string, string> = {
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化",
};
export function buildQuickActionMessage(id: QuickActionId): string {
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
}
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
router = APIRouter(prefix="/api", tags=["models"])
_PROVIDER_KEYWORDS: list[tuple[str, str]] = [
("langchain_openai", "openai"),
("langchain_anthropic", "anthropic"),
("langchain_google", "google"),
("langchain_groq", "groq"),
("langchain_mistralai", "mistral"),
("langchain_cohere", "cohere"),
("ollama", "ollama"),
("vllm", "vllm"),
]
_LOCAL_PROVIDERS = {"ollama", "vllm"}
def _derive_provider(use: str) -> str | None:
use_lower = use.lower()
for keyword, provider in _PROVIDER_KEYWORDS:
if keyword in use_lower:
return provider
return None
def _build_model_response(model: ModelConfig) -> "ModelResponse":
extra = model.model_extra or {}
provider = str(extra["provider"]) if "provider" in extra else _derive_provider(model.use)
is_local = bool(extra["is_local"]) if "is_local" in extra else (provider in _LOCAL_PROVIDERS)
supports_tool_calling = bool(extra["supports_tool_calling"]) if "supports_tool_calling" in extra else True
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
supports_vision=model.supports_vision,
supports_tool_calling=supports_tool_calling,
is_local=is_local,
provider=provider,
)
class ModelResponse(BaseModel):
"""Response model for model information."""
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
supports_vision: bool = Field(default=False, description="Whether model supports image inputs")
supports_tool_calling: bool = Field(default=True, description="Whether model supports tool/function calling")
is_local: bool = Field(default=False, description="Whether this is a locally-hosted model")
provider: str | None = Field(None, description="Model provider name (openai, anthropic, ollama, etc.)")
class TokenUsageResponse(BaseModel):
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
}
```
"""
models = [
ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
for model in config.models
]
models = [_build_model_response(model) for model in config.models]
return ModelsListResponse(
models=models,
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
@@ -122,11 +157,4 @@ async def get_model(model_name: str, config: AppConfig = Depends(get_config)) ->
if model is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
return _build_model_response(model)
@@ -97,6 +97,10 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
_DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json.
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
# Whitelist of run-context keys that the langgraph-compat layer forwards from
# ``body.context`` into the run config. ``config["context"]`` exists in
@@ -140,12 +144,15 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
def resolve_agent_factory(assistant_id: str | None):
"""Resolve the agent factory callable from config.
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
injected into ``configurable`` or ``context`` — see
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
same factory; the routing happens inside ``make_lead_agent`` when it reads
``cfg["agent_id"]``.
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
to their own factory. Custom agents share the ``lead_agent`` factory with
``agent_id`` injected into configurable/context by :func:`build_run_config`.
"""
if assistant_id == "canvas_agent":
from deerflow.agents.canvas_agent.agent import make_canvas_agent
return make_canvas_agent
from deerflow.agents.lead_agent.agent import make_lead_agent
return make_lead_agent
@@ -204,8 +211,9 @@ def build_run_config(
config["configurable"] = {"thread_id": thread_id}
# Inject custom agent id when the caller specified a non-default assistant.
# Honour an explicit agent_id in the active runtime options container.
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
# factory and must NOT receive an agent_id override.
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
normalized = assistant_id.strip()
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.")
@@ -225,7 +233,7 @@ def build_run_config(
def _collect_requested_agent_ids(body: Any) -> set[str]:
"""Collect agent ids from assistant_id/config/context for access checks."""
agent_ids: set[str] = set()
if body.assistant_id and body.assistant_id != _DEFAULT_ASSISTANT_ID:
if body.assistant_id and body.assistant_id not in _BUILTIN_GRAPH_IDS:
agent_ids.add(str(body.assistant_id))
def collect(container: Mapping[str, Any] | None) -> None: