diff --git a/frontend-web/docs/openCanvas-progress.md b/frontend-web/docs/openCanvas-progress.md new file mode 100644 index 00000000..47265be2 --- /dev/null +++ b/frontend-web/docs/openCanvas-progress.md @@ -0,0 +1,260 @@ +# Open Canvas 集成进度文档 + +> 更新时间:2026-05-15 +> 当前分支:dhk-1 + +--- + +## 一、整体进度概览 + +| 阶段 | 名称 | 状态 | +|------|------|------| +| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 | +| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 | +| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) | +| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 | +| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 | +| 前端阶段四 | API 适配层 | ✅ 已完成 | +| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 | +| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 | +| 前后端阶段七 | 局部选区改写 | ✅ 已完成 | +| 前后端阶段八 | 快捷动作系统 | ⬜ 待开始 | +| 前后端阶段九 | 本地模型支持完善 | ⬜ 待开始 | + +**下一个要做的任务**:前后端阶段八 — 快捷动作系统(CanvasEmptyState 模板触发 + 快捷动作按钮) + +--- + +## 二、已完成工作详情 + +### 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..b5346b74 --- /dev/null +++ b/frontend-web/src/open-canvas/api/models.ts @@ -0,0 +1,37 @@ +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; +} + +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..290cb206 --- /dev/null +++ b/frontend-web/src/open-canvas/api/runs.ts @@ -0,0 +1,62 @@ +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; +}; + +export type CanvasStreamCallbacks = { + onMessage?: (msg: CanvasMessage) => void; + onArtifact?: (artifact: CanvasArtifact) => void; + onDone?: () => void; + onError?: (err: unknown) => void; +}; + +export async function streamCanvasRun( + threadId: string, + input: CanvasRunInput, + callbacks: CanvasStreamCallbacks, +): Promise { + const client = getAPIClient(); + try { + const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, { + input, + streamMode: ["values", "messages-tuple"], + }); + + 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); + } + } + } + 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..7bc32418 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx @@ -0,0 +1,41 @@ +import { useSearchParams } from "react-router-dom"; + +import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact"; +import { CanvasArtifactHeader } from "./CanvasArtifactHeader"; +import { CanvasEmptyState } from "./CanvasEmptyState"; +import { TextArtifactRenderer } from "./TextArtifactRenderer"; +import { CodeArtifactRenderer } from "./CodeArtifactRenderer"; +import type { + CanvasArtifactCode, + CanvasArtifactText, +} from "../../types/artifact"; + +export function CanvasArtifactPanel() { + const { artifact } = useCanvasGraphContext(); + const [searchParams] = useSearchParams(); + const initialType = searchParams.get("type") as "text" | "code" | null; + + 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/CodeArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx new file mode 100644 index 00000000..1ef69b08 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/CodeArtifactRenderer.tsx @@ -0,0 +1,120 @@ +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; +} + +export function CodeArtifactRenderer({ content }: 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..29691678 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -0,0 +1,142 @@ +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; +} + +export function TextArtifactRenderer({ content }: 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} +
    +
    +
    + ); +} 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..639461f2 --- /dev/null +++ b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx @@ -0,0 +1,205 @@ +import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react"; +import { 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 { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { useCanvasSelection } from "../../hooks/useCanvasSelection"; +import { useCanvasStream } from "../../hooks/useCanvasStream"; +import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact"; +import { + buildMessageWithSelection, + hasActiveSelection, + resolveCodeSelection, +} from "../../utils/selection"; + +function ChatWelcome() { + return ( +
    +
    + +
    +
    +

    开始创作

    +

    + 发送消息让 AI 帮你生成文档或代码, +
    + 或在右侧选择快速开始模板 +

    +
    +
    + ); +} + +export function CanvasChatPanel() { + const { messages, isStreaming, artifact } = useCanvasGraphContext(); + const { sendMessage } = useCanvasStream(); + const { selection, clearSelection } = useCanvasSelection(); + const [input, setInput] = useState(""); + const textareaRef = useRef(null); + + const hasMessages = messages.length > 0; + const hasSelection = hasActiveSelection(selection); + + // Resolve selected code snippet when artifact is a code artifact + const currentContent = getCurrentArtifactContent(artifact); + const resolvedCode = + selection.codeSelection && currentContent && isCodeArtifact(currentContent) + ? resolveCodeSelection( + currentContent.code, + selection.codeSelection.startCharIndex, + selection.codeSelection.endCharIndex, + ) + : undefined; + + // Build a short preview for the selection badge + const selectionPreview = (() => { + if (selection.textSelection?.selectedText) { + const t = selection.textSelection.selectedText; + return t.length > 40 ? t.slice(0, 40) + "…" : t; + } + if (resolvedCode) { + return resolvedCode.length > 40 + ? resolvedCode.slice(0, 40) + "…" + : resolvedCode; + } + return null; + })(); + + 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; + + clearSelection(); + await sendMessage(finalText); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + } + + return ( +
    + {/* Message list */} +
    + {hasMessages ? ( +
    + {messages.map((msg, i) => { + const isHuman = msg.type === "human"; + const text = + typeof msg.content === "string" + ? msg.content + : JSON.stringify(msg.content); + + return ( +
    + {!isHuman && ( +
    + +
    + )} +
    + {text} +
    + {isHuman && ( +
    + +
    + )} +
    + ); + })} + {isStreaming && ( +
    +
    + +
    +
    + +
    +
    + )} +
    + ) : ( + + )} +
    + + {/* Selection badge */} + {hasSelection && selectionPreview && ( +
    + + + {selection.textSelection ? "选中文本:" : "选中代码:"} + + + {selectionPreview} + + + +
    + )} + + {/* Input area */} +
    +
    +