diff --git a/frontend-web/docs/openCanvas-progress.md b/frontend-web/docs/openCanvas-progress.md index 47265be2..012bb8ea 100644 --- a/frontend-web/docs/openCanvas-progress.md +++ b/frontend-web/docs/openCanvas-progress.md @@ -1,6 +1,6 @@ # Open Canvas 集成进度文档 -> 更新时间:2026-05-15 +> 更新时间:2026-05-16 > 当前分支:dhk-1 --- @@ -18,10 +18,10 @@ | 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 | | 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 | | 前后端阶段七 | 局部选区改写 | ✅ 已完成 | -| 前后端阶段八 | 快捷动作系统 | ⬜ 待开始 | -| 前后端阶段九 | 本地模型支持完善 | ⬜ 待开始 | +| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 | +| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 | -**下一个要做的任务**:前后端阶段八 — 快捷动作系统(CanvasEmptyState 模板触发 + 快捷动作按钮) +**当前状态**:全部 9 个阶段已完成 🎉 --- diff --git a/frontend-web/src/open-canvas/api/models.ts b/frontend-web/src/open-canvas/api/models.ts index b5346b74..a8e05b10 100644 --- a/frontend-web/src/open-canvas/api/models.ts +++ b/frontend-web/src/open-canvas/api/models.ts @@ -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 { diff --git a/frontend-web/src/open-canvas/api/runs.ts b/frontend-web/src/open-canvas/api/runs.ts index 290cb206..55b11309 100644 --- a/frontend-web/src/open-canvas/api/runs.ts +++ b/frontend-web/src/open-canvas/api/runs.ts @@ -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 { 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 = {}; + 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; 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; + + // 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>) { + 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>) { + if (tc.name === "update_artifact" && tc.args) { + try { + const args = + typeof tc.args === "string" + ? (JSON.parse(tc.args) as Record) + : (tc.args as Record); + 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 + } + } + } } } } diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx index 7bc32418..82a1bd2e 100644 --- a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx +++ b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx @@ -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 ( +
+ +
+ {partial.title ? ( +

{partial.title}

+ ) : null} +

AI 正在生成文档…

+
+
+ ); + } + + // 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 ; + } + + const fakeContent: CanvasArtifactText = { + index: 0, + type: "text", + title: partial.title ?? "文档", + fullMarkdown: partial.content ?? "", + }; + return ; +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ + + {streamingArtifact.title ?? "正在生成…"} + + +
+
+ +
+
+ ); + } + if (!artifact) { return (
@@ -29,6 +100,7 @@ export function CanvasArtifactPanel() { return (
+
{content && isCodeArtifact(content) ? ( diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasQuickActionBar.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasQuickActionBar.tsx new file mode 100644 index 00000000..4e18514d --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CanvasQuickActionBar.tsx @@ -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 ( +
+ {actions.map((action) => ( + + ))} +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx index 1ef69b08..349722cb 100644 --- a/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx @@ -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(); diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index 29691678..c7d6b3d7 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -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} + {isStreaming && ( + + )}
); diff --git a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx index 639461f2..d6664cb7 100644 --- a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx +++ b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx @@ -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(/[\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 ( +
+
+
需要你的确认
+ {payload.context ? ( +

{payload.context}

+ ) : null} +
+ {payload.question || fallback} +
+
+ + {options.length > 0 ? ( +
+ {options.map((option) => ( + + ))} +
+ ) : null} + + {payload.allow_custom !== false ? ( +
+