diff --git a/frontend-web/docs/openCanvas-progress.md b/frontend-web/docs/openCanvas-progress.md new file mode 100644 index 00000000..012bb8ea --- /dev/null +++ b/frontend-web/docs/openCanvas-progress.md @@ -0,0 +1,260 @@ +# Open Canvas 集成进度文档 + +> 更新时间:2026-05-16 +> 当前分支:dhk-1 + +--- + +## 一、整体进度概览 + +| 阶段 | 名称 | 状态 | +|------|------|------| +| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 | +| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 | +| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) | +| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 | +| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 | +| 前端阶段四 | API 适配层 | ✅ 已完成 | +| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 | +| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 | +| 前后端阶段七 | 局部选区改写 | ✅ 已完成 | +| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 | +| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 | + +**当前状态**:全部 9 个阶段已完成 🎉 + +--- + +## 二、已完成工作详情 + +### 2.1 前端路由接入 + +修改了 `frontend-web/src/pages/PageRoutes.tsx`: + +```tsx +import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes"; +// 在 PageLayout 内部添加: +} /> +``` + +子路由: +- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始) +- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区) + +### 2.2 前端目录结构(已建立,26 个文件) + +``` +frontend-web/src/open-canvas/ + api/ + client.ts # stub,Phase 5 实现 + models.ts # stub,Phase 5 实现 + threads.ts # stub,Phase 5 实现 + runs.ts # stub,Phase 5 实现 + components/ + layout/ + CanvasShell.tsx # 顶层布局壳,flex-col + CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示 + CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels) + chat/ + CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框 + artifact/ + CanvasArtifactPanel.tsx # 右栏:空状态或渲染器 + CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航 + CanvasArtifactVersionNav.tsx # 版本 prev/next 导航 + CanvasEmptyState.tsx # 空状态:5 种快速开始模板 + TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown) + CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror) + contexts/ + CanvasThreadContext.tsx # threadId、modelName + CanvasGraphContext.tsx # messages、artifact、isStreaming + CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态 + hooks/ + useCanvasStream.ts # stub,Phase 5 实现 + useCanvasArtifact.ts # stub,Phase 6 实现 + useCanvasSelection.ts # stub,Phase 7 实现 + useCanvasQuickActions.ts # stub,Phase 8 实现 + pages/ + CanvasHomePage.tsx # 欢迎页 + 快速开始入口 + CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell + routes/ + OpenCanvasRoutes.tsx # React Router 子路由 + types/ + artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode + thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata + model.ts # CanvasModel(含 supports_canvas_* 能力字段) + selection.ts # CanvasTextSelection、CanvasCodeSelection + quick-action.ts # QuickAction、QuickActionId + utils/ + artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具 + selection.ts # stub + thread.ts # stub + stream.ts # stub + quick-actions.ts # stub + constants/ + index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_* +``` + +### 2.3 核心类型定义 + +```ts +// types/artifact.ts +export type CanvasArtifact = { + currentIndex: number; // 从 1 开始 + contents: CanvasArtifactContent[]; +}; + +export type CanvasArtifactText = { + index: number; type: "text"; title: string; fullMarkdown: string; +}; + +export type CanvasArtifactCode = { + index: number; type: "code"; title: string; + language: ProgrammingLanguageOptions; code: string; +}; +``` + +### 2.4 Context 结构 + +```tsx +// CanvasThreadPage.tsx 的装配顺序 + + + + + + + +``` + +--- + +## 三、已解决的技术难题 + +### 3.1 react-resizable-panels v4.6.2 API 差异 + +此版本与旧版 API 不同,必须使用: + +```tsx +// ✅ 正确写法 +import type { GroupImperativeHandle } from "react-resizable-panels"; +const layoutRef = useRef(null); + + + // ← 必须有 id + // ← 必须有 id + + +// 切换折叠 +layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组 +``` + +### 3.2 Flex 高度链(防止底部内容被裁剪) + +``` +PageLayout(h-screen) + └── main(flex-1) + └── CanvasShell div(h-full flex flex-col) + └── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键 + └── ResizablePanelGroup(h-full) + └── 面板内容(h-full) +``` + +**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。 + +### 3.3 折叠/展开按钮 + +`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`, +`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`: + +```tsx +useEffect(() => { + layoutRef.current?.setLayout( + isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 } + ); +}, [isChatCollapsed]); +``` + +--- + +## 四、下一步:后端 ThreadState 扩展 + +**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` + +**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物): + +```python +# 新增 TypedDict(建议单独定义) +class CanvasArtifactText(TypedDict): + index: int + type: Literal["text"] + title: str + fullMarkdown: str + +class CanvasArtifactCode(TypedDict): + index: int + type: Literal["code"] + title: str + language: str + code: str + +class CanvasArtifact(TypedDict): + currentIndex: int + contents: list[CanvasArtifactText | CanvasArtifactCode] + +# 在 ThreadState 中新增字段 +artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表) +highlighted_text: Optional[str] # 用户选中的文本片段 +highlighted_code: Optional[str] # 用户选中的代码片段 +canvas_mode: Optional[Literal["text", "code"]] +quick_action_id: Optional[str] # 快捷动作 id +web_search_enabled: bool # 默认 False +web_search_results: Optional[list[dict]] +``` + +**验收标准**: +- 旧线程无 `artifact` 字段时不报错 +- `pnpm typecheck` 无新增错误(前端字段已对齐) +- `make test` 全量回归通过 + +**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环 + +--- + +## 五、关键文件位置 + +| 文件 | 用途 | +|------|------| +| `frontend-web/docs/openCanvas.md` | 主需求文档 | +| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 | +| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 | +| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 | +| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) | +| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 | +| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 | + +--- + +## 六、架构决策记录 + +| 决策 | 内容 | +|------|------| +| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)| +| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 | +| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 | +| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models | +| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) | +| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 | + +--- + +## 七、开始新对话的推荐提示词 + +``` +我们在集成 open-canvas 到 DeerFlow 项目。 +参考进度文档:frontend-web/docs/openCanvas-progress.md +目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。 +请从第四节"下一步:后端 ThreadState 扩展"开始继续。 +``` diff --git a/frontend-web/src/open-canvas/api/client.ts b/frontend-web/src/open-canvas/api/client.ts new file mode 100644 index 00000000..ff660eb8 --- /dev/null +++ b/frontend-web/src/open-canvas/api/client.ts @@ -0,0 +1 @@ +export { getAPIClient } from "@/core/api"; diff --git a/frontend-web/src/open-canvas/api/models.ts b/frontend-web/src/open-canvas/api/models.ts new file mode 100644 index 00000000..a8e05b10 --- /dev/null +++ b/frontend-web/src/open-canvas/api/models.ts @@ -0,0 +1,39 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +import type { CanvasModel } from "../types/model"; + +interface BackendModel { + name: string; + display_name?: string; + provider?: string; + is_local?: boolean; + supports_vision?: boolean; + 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 { + const res = await apiFetch(`${getBackendBaseURL()}/api/models`); + const data = (await res.json()) as { models?: BackendModel[] }; + return (data.models ?? []).map( + (m): CanvasModel => ({ + name: m.name, + display_name: m.display_name ?? m.name, + provider: m.provider, + is_local: m.is_local, + supports_vision: m.supports_vision, + supports_thinking: m.supports_thinking, + supports_reasoning_effort: m.supports_reasoning_effort, + supports_tool_calling: m.supports_tool_calling ?? true, + supports_canvas_generation: true, + supports_canvas_rewrite: true, + supports_canvas_code_edit: true, + supports_streaming_text: true, + supports_streaming_tool_calls: m.supports_tool_calling ?? true, + }), + ); +} diff --git a/frontend-web/src/open-canvas/api/runs.ts b/frontend-web/src/open-canvas/api/runs.ts new file mode 100644 index 00000000..55b11309 --- /dev/null +++ b/frontend-web/src/open-canvas/api/runs.ts @@ -0,0 +1,192 @@ +import { getAPIClient } from "@/core/api"; + +import { CANVAS_AGENT_ID } from "../constants"; +import type { CanvasArtifact } from "../types/artifact"; +import type { CanvasMessage } from "../types/thread"; + +export type CanvasRunInput = { + messages: Array<{ type: string; content: string }>; + canvas_mode?: "text" | "code"; + highlighted_text?: string; + highlighted_code?: string; + 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) { + 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") { + 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 + } + } + } + } + } + } + callbacks.onDone?.(); + } catch (err) { + callbacks.onError?.(err); + } +} + +export async function stopCanvasRun( + threadId: string, + runId: string, +): Promise { + const client = getAPIClient(); + await client.runs.cancel(threadId, runId); +} diff --git a/frontend-web/src/open-canvas/api/threads.ts b/frontend-web/src/open-canvas/api/threads.ts new file mode 100644 index 00000000..d64a9008 --- /dev/null +++ b/frontend-web/src/open-canvas/api/threads.ts @@ -0,0 +1,35 @@ +import { getAPIClient } from "@/core/api"; + +import { CANVAS_THREAD_METADATA } from "../constants"; +import type { CanvasThreadMetadata, CanvasThreadValues } from "../types/thread"; + +export async function createCanvasThread( + metadata?: Partial, +): Promise { + const client = getAPIClient(); + const thread = await client.threads.create({ + metadata: { ...CANVAS_THREAD_METADATA, ...metadata }, + }); + return thread.thread_id; +} + +export async function getCanvasThread( + threadId: string, +): Promise { + const client = getAPIClient(); + try { + const state = + await client.threads.getState(threadId); + return (state.values as CanvasThreadValues) ?? null; + } catch { + return null; + } +} + +export async function updateCanvasThreadState( + threadId: string, + values: Partial, +): Promise { + const client = getAPIClient(); + await client.threads.updateState(threadId, { values }); +} diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactHeader.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactHeader.tsx new file mode 100644 index 00000000..cd41f456 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactHeader.tsx @@ -0,0 +1,34 @@ +import { FileText, Code2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; + +import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { getCurrentArtifactContent } from "../../utils/artifact"; +import { CanvasArtifactVersionNav } from "./CanvasArtifactVersionNav"; + +export function CanvasArtifactHeader() { + const { artifact } = useCanvasGraphContext(); + const content = getCurrentArtifactContent(artifact); + + if (!content) return null; + + const isCode = content.type === "code"; + const Icon = isCode ? Code2 : FileText; + + return ( +
+ + + + {content.title} + + +
+ + {isCode ? (content as { language: string }).language : "文本"} + + +
+
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx new file mode 100644 index 00000000..82a1bd2e --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx @@ -0,0 +1,113 @@ +import { FileText, Loader2 } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; + +import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +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 { + CanvasArtifactCode, + 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, 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 ( +
+ +
+ ); + } + + const content = getCurrentArtifactContent(artifact); + + return ( +
+ + +
+ {content && isCodeArtifact(content) ? ( + + ) : content ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactVersionNav.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactVersionNav.tsx new file mode 100644 index 00000000..2a34264e --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactVersionNav.tsx @@ -0,0 +1,50 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { setCurrentArtifactIndex } from "../../utils/artifact"; + +export function CanvasArtifactVersionNav() { + const { artifact, setArtifact } = useCanvasGraphContext(); + + if (!artifact || artifact.contents.length <= 1) return null; + + const total = artifact.contents.length; + const current = artifact.currentIndex; + const canGoPrev = current > 1; + const canGoNext = current < total; + + function go(delta: -1 | 1) { + if (!artifact) return; + setArtifact(setCurrentArtifactIndex(artifact, current + delta)); + } + + return ( +
+ + + {current}/{total} + + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasEmptyState.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasEmptyState.tsx new file mode 100644 index 00000000..b4b05497 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CanvasEmptyState.tsx @@ -0,0 +1,168 @@ +import { FileText, Code2, Globe, Atom, Terminal } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { createDraftArtifact } from "../../utils/artifact"; +import type { ProgrammingLanguageOptions } from "../../types/artifact"; + +const HTML_TEMPLATE = ` + + + + + 页面标题 + + + +

Hello World

+

在这里开始编写你的页面

+ +`; + +const REACT_TEMPLATE = `import React from "react"; + +interface Props { + // 在这里定义 props +} + +export default function MyComponent({}: Props) { + return ( +
+

My Component

+

在这里开始编写你的组件

+
+ ); +}`; + +const PYTHON_TEMPLATE = `def main(): + """主函数""" + print("Hello, World!") + + +if __name__ == "__main__": + main()`; + +interface QuickStartOption { + icon: LucideIcon; + title: string; + description: string; + type: "text" | "code"; + templateTitle: string; + content: string; + language?: ProgrammingLanguageOptions; +} + +const QUICK_START_OPTIONS: QuickStartOption[] = [ + { + icon: FileText, + title: "空白文档", + description: "从空白 Markdown 文档开始", + type: "text", + templateTitle: "未命名文档", + content: "", + }, + { + icon: Code2, + title: "空白代码", + description: "从空白代码文件开始", + type: "code", + templateTitle: "未命名代码", + content: "", + language: "python", + }, + { + icon: Globe, + title: "HTML 页面", + description: "基础 HTML 页面模板", + type: "code", + templateTitle: "HTML 页面", + content: HTML_TEMPLATE, + language: "html", + }, + { + icon: Atom, + title: "React 组件", + description: "TypeScript React 组件模板", + type: "code", + templateTitle: "React 组件", + content: REACT_TEMPLATE, + language: "typescript", + }, + { + icon: Terminal, + title: "Python 脚本", + description: "Python 入口脚本模板", + type: "code", + templateTitle: "Python 脚本", + content: PYTHON_TEMPLATE, + language: "python", + }, +]; + +interface CanvasEmptyStateProps { + initialType?: "text" | "code" | null; +} + +export function CanvasEmptyState({ initialType }: CanvasEmptyStateProps) { + const { setArtifact } = useCanvasGraphContext(); + + function handleSelect(option: QuickStartOption) { + setArtifact( + createDraftArtifact(option.type, { + title: option.templateTitle, + content: option.content, + language: option.language, + }), + ); + } + + const filtered = + initialType + ? QUICK_START_OPTIONS.filter( + (o) => !initialType || o.type === initialType || o.type === "text", + ) + : QUICK_START_OPTIONS; + + return ( +
+
+
+

选择模板快速开始

+

+ 或直接在左侧聊天区发送消息 +

+
+ +
+ {QUICK_START_OPTIONS.map((option) => { + const Icon = option.icon; + const isHighlighted = initialType && option.type === initialType && option.content === ""; + return ( + + ); + })} +
+
+
+ ); +} 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 new file mode 100644 index 00000000..349722cb --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx @@ -0,0 +1,121 @@ +import { html } from "@codemirror/lang-html"; +import { javascript } from "@codemirror/lang-javascript"; +import { json } from "@codemirror/lang-json"; +import { python } from "@codemirror/lang-python"; +import type { Extension } from "@codemirror/state"; +import type { ViewUpdate } from "@codemirror/view"; +import { basicLightInit } from "@uiw/codemirror-theme-basic"; +import { monokaiInit } from "@uiw/codemirror-theme-monokai"; +import CodeMirror from "@uiw/react-codemirror"; +import { useTheme } from "next-themes"; +import { useCallback, useMemo } from "react"; + +import type { + CanvasArtifactCode, + ProgrammingLanguageOptions, +} from "../../types/artifact"; +import { useCanvasSelection } from "../../hooks/useCanvasSelection"; + +// --------------------------------------------------------------------------- +// Themes — match code-editor.tsx defaults +// --------------------------------------------------------------------------- + +const darkTheme = monokaiInit({ + settings: { + background: "transparent", + gutterBackground: "transparent", + gutterForeground: "#555", + gutterActiveForeground: "#fff", + fontSize: "var(--text-sm)", + }, +}); + +const lightTheme = basicLightInit({ + settings: { + background: "transparent", + fontSize: "var(--text-sm)", + }, +}); + +// --------------------------------------------------------------------------- +// Language detection +// --------------------------------------------------------------------------- + +function getLanguageExtension( + lang: ProgrammingLanguageOptions, +): Extension | null { + switch (lang) { + case "typescript": + return javascript({ typescript: true, jsx: true }); + case "javascript": + return javascript({ jsx: true }); + case "python": + return python(); + case "html": + return html(); + case "json": + return json(); + // cpp, java, rust, sql, xml, clojure, csharp, php → plain text fallback + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface CodeArtifactRendererProps { + content: CanvasArtifactCode; + isStreaming?: boolean; +} + +export function CodeArtifactRenderer({ content, isStreaming: _isStreaming }: CodeArtifactRendererProps) { + const { resolvedTheme } = useTheme(); + const { handleCodeSelectionChange } = useCanvasSelection(); + + const extensions = useMemo(() => { + const ext = getLanguageExtension(content.language); + return ext ? [ext] : []; + }, [content.language]); + + const handleUpdate = useCallback( + (update: ViewUpdate) => { + if (!update.selectionSet) return; + const { from, to } = update.state.selection.main; + handleCodeSelectionChange(from, to); + }, + [handleCodeSelectionChange], + ); + + if (!content.code) { + return ( +
+

+ 空白代码,发送消息让 AI 填充内容 +

+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx new file mode 100644 index 00000000..c7d6b3d7 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -0,0 +1,146 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import type { CanvasArtifactText } from "../../types/artifact"; +import { useCanvasSelection } from "../../hooks/useCanvasSelection"; + +interface TextArtifactRendererProps { + content: CanvasArtifactText; + isStreaming?: boolean; +} + +export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) { + const { handleTextPointerUp } = useCanvasSelection(); + + if (!content.fullMarkdown) { + return ( +
+

+ 空白文档,发送消息让 AI 填充内容 +

+
+ ); + } + + return ( +
handleTextPointerUp(content.fullMarkdown)} + > +
+ ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + h4: ({ children }) => ( +

+ {children} +

+ ), + p: ({ children }) => ( +

+ {children} +

+ ), + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • {children}
  • + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +                {children}
    +              
    + ), + code: ({ className, children }) => { + const isBlock = Boolean(className?.startsWith("language-")); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () =>
    , + a: ({ href, children }) => ( + + {children} + + ), + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => ( + {children} + ), + }} + > + {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 new file mode 100644 index 00000000..d6664cb7 --- /dev/null +++ b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx @@ -0,0 +1,414 @@ +import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-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, + hasActiveSelection, + 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 ? ( +
    +