diff --git a/frontend-web/docs/openCanvas-progress.md b/frontend-web/docs/openCanvas-progress.md index 012bb8ea..5e74c279 100644 --- a/frontend-web/docs/openCanvas-progress.md +++ b/frontend-web/docs/openCanvas-progress.md @@ -1,260 +1,312 @@ # Open Canvas 集成进度文档 -> 更新时间:2026-05-16 +> 最后更新:2026-05-16 > 当前分支:dhk-1 --- ## 一、整体进度概览 +### Canvas 系统(双栏写作/编码工作区) + | 阶段 | 名称 | 状态 | |------|------|------| -| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 | -| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 | -| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) | -| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 | -| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 | -| 前端阶段四 | API 适配层 | ✅ 已完成 | -| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 | -| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 | -| 前后端阶段七 | 局部选区改写 | ✅ 已完成 | -| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 | -| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 | +| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 | +| 阶段二 | Canvas 双栏布局 | ✅ 已完成 | +| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 | +| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 | +| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 | +| 阶段六 | 前后端流式同步 | ✅ 已完成 | +| 阶段七 | 版本历史导航 | ✅ 已完成 | +| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 | +| 阶段九 | 快捷动作系统 | ✅ 已完成 | +| 阶段十 | 本地模型选择支持 | ✅ 已完成 | -**当前状态**:全部 9 个阶段已完成 🎉 +### AI Writing Editor(文本 artifact 编辑器内置 AI 改写) + +| 步骤 | 名称 | 状态 | +|------|------|------| +| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 | +| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 | +| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 | +| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 | +| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 | +| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 | +| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 | +| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 | + +**当前状态:全部功能已完成 🎉** --- ## 二、已完成工作详情 -### 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 个文件) +### 2.1 前端核心文件结构 ``` 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_* +├── api/ +│ ├── client.ts # LangGraph SDK 客户端实例 +│ ├── models.ts # 模型列表 API +│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse) +│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用) +│ └── threads.ts # createCanvasThread、getCanvasThread +├── components/ +│ ├── artifact/ +│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航 +│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态 +│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换 +│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板 +│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏 +│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器 +│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程 +│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone) +│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现) +│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮 +│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开) +│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器) +│ ├── chat/ +│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框 +│ ├── layout/ +│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示 +│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider) +│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态 +│ └── selectors/ +│ └── CanvasModelSelector.tsx # 模型下拉选择器 +├── contexts/ +│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming +│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage) +│ ├── CanvasThreadContext.tsx # threadId、modelName +│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection +├── editor/ +│ ├── TiptapEditor.tsx # EditorContent 包装器 +│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown +├── hooks/ +│ ├── useCanvasArtifact.ts # artifact 初始化工具 +│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext) +│ ├── useCanvasSelection.ts # 文字/代码选区状态 +│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用) +│ ├── useCanvasThread.ts # 线程加载工具 +│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流) +│ ├── useWritingEditor.ts # Tiptap editor 实例管理 +│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController) +├── pages/ +│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始 +│ └── CanvasThreadPage.tsx # Provider 装配层 +├── routes/ +│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由 +├── types/ +│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode +│ ├── model.ts # CanvasModel +│ ├── quick-action.ts # QuickAction、QuickActionId +│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection +│ └── thread.ts # CanvasMessage、CanvasThreadValues +└── utils/ + ├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务) + ├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等 + ├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入) + ├── selection.ts # buildMessageWithSelection + ├── stream.ts # 流式工具 + └── thread.ts # 线程工具 ``` -### 2.3 核心类型定义 +### 2.2 后端核心文件结构 -```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; -}; +``` +offline-backend-20260512/backend/ +├── packages/harness/deerflow/agents/ +│ ├── canvas_agent/ +│ │ ├── __init__.py +│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂 +│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions +│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本 +│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict) +└── app/gateway/ + ├── app.py # 注册 writing router + └── routers/ + └── writing.py # POST /api/writing/rewrite(SSE 流式) ``` -### 2.4 Context 结构 +**langgraph.json** 注册了两个 graph: +- `lead_agent` → 主聊天 Agent +- `canvas_agent` → Canvas 写作 Agent -```tsx -// CanvasThreadPage.tsx 的装配顺序 - - - - - - - +### 2.3 关键 Context 层级 + +``` +CanvasThreadPage + ├── CanvasThreadProvider # threadId、modelName + │ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming + │ │ └── CanvasUIProvider # isChatCollapsed、writingSelection + │ │ └── CanvasShell + │ │ └── CanvasResizableLayout + │ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例 + │ │ ├── CanvasChatPanel # 读 useCanvasStreamContext() + │ │ └── CanvasArtifactPanel + │ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext() ``` --- -## 三、已解决的技术难题 +## 三、关键架构决策与注意事项 -### 3.1 react-resizable-panels v4.6.2 API 差异 +### 3.1 流式架构:单例流(重要) -此版本与旧版 API 不同,必须使用: +**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 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 高度链(防止底部内容被裁剪) +**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。 ``` -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) +useCanvasStream(旧,已停用) ← 不再被调用,保留备用 +useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享 ``` -**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。 +### 3.2 artifact 内容注入机制(重要) -### 3.3 折叠/展开按钮 +**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。 -`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`, -`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`: +**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `` 标签嵌入人类消息末尾。 -```tsx -useEffect(() => { - layoutRef.current?.setLayout( - isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 } - ); -}, [isChatCollapsed]); +- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `` 时) +- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容 +- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到 + +```typescript +// utils/quick-actions.ts +export function buildQuickActionMessage(id, content) { + const body = isCodeArtifact(content) ? content.code : content.fullMarkdown; + return `${instruction}\n\n\n${body}\n`; +} + +// core/messages/utils.ts +export function stripArtifactTag(content: string): string { + return content.replace(/]*>[\s\S]*?<\/artifact>/g, "").trim(); +} ``` +### 3.3 ThinkTagParser(AI 改写思考过程解析) + +后端支持两种思考格式: +- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输 +- DeepSeek:把思考过程内联在文本里:`...content` + +前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `` 标签。 + +### 3.4 版本历史:本地切换 + +`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。 + +### 3.5 Writing Editor 流程(三阶段状态机) + +``` +idle → menu → preview → idle + ↑ ↓ + openAiMenu closeAll +``` + +- **idle**:正常编辑 +- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone) +- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮) + --- -## 四、下一步:后端 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 注册位置 | +| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 | +| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` | +| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 | +| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 | +| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 | +| DeepSeek 在文本流里混入 `` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 | +| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state | +| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 | +| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `` 标签,显示时剥离 | +| `` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` | --- -## 六、架构决策记录 +## 五、当前已知限制(非 bug,设计取舍) -| 决策 | 内容 | +1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本 +2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理 +3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效 +4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受 + +--- + +## 六、快速开始下一次工作 + +### 启动服务 + +```bash +# 后端(从 offline-backend-20260512/backend/) +make dev # 启动 Gateway API on port 8001 + +# 前端(从 frontend-web/) +pnpm dev # 启动 Vite on port 5174 +``` + +### 访问路径 + +- 主页:`http://localhost:5174` +- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas` + +### 类型检查 + +```bash +cd frontend-web && pnpm typecheck +# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行) +``` + +--- + +## 七、如需继续开发的潜在方向 + +| 方向 | 描述 | 优先级 | +|------|------|------| +| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 | +| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 | +| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 | +| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 | +| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 | +| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 | + +--- + +## 八、关键文件速查 + +| 文件 | 说明 | |------|------| -| 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 级局部流 | +| `frontend-web/docs/openCanvas-progress.md` | **本文档** | +| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context | +| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) | +| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 | +| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 | +| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 | +| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 | +| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 | +| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) | +| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 | +| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool | +| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 | +| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 | +| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 | --- -## 七、开始新对话的推荐提示词 +## 九、开始新对话的推荐提示词 ``` -我们在集成 open-canvas 到 DeerFlow 项目。 -参考进度文档:frontend-web/docs/openCanvas-progress.md -目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。 -请从第四节"下一步:后端 ThreadState 扩展"开始继续。 +我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。 +详细进度见:frontend-web/docs/openCanvas-progress.md + +当前全部功能已完成,包括: +- Canvas 双栏布局、canvas_agent、版本历史 +- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开) +- 快捷动作(通过 CanvasStreamContext 统一流) +- artifact 内容注入(消息里嵌入 标签,显示时剥离) + +请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。 ``` diff --git a/frontend-web/package.json b/frontend-web/package.json index a9319e1d..8c9b705a 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -1,100 +1,108 @@ -{ - "name": "deer-flow-frontend-vite", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/lang-python": "^6.2.1", - "@codemirror/language-data": "^6.5.2", - "@langchain/core": "^1.1.15", - "@langchain/langgraph-sdk": "^1.5.3", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-hover-card": "^1.1.15", - "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toggle": "^1.1.10", - "@radix-ui/react-toggle-group": "^1.1.11", - "@radix-ui/react-tooltip": "^1.2.8", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@tanstack/react-query": "^5.90.17", - "@types/hast": "^3.0.4", - "@uiw/codemirror-theme-basic": "^4.25.4", - "@uiw/codemirror-theme-monokai": "^4.25.4", - "@uiw/react-codemirror": "^4.25.4", - "@xyflow/react": "^12.10.0", - "ai": "^6.0.33", - "best-effort-json-parser": "^1.2.1", - "canvas-confetti": "^1.9.4", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "codemirror": "^6.0.2", - "date-fns": "^4.1.0", - "docx": "^9.5.1", - "dotenv": "^17.2.3", - "embla-carousel-react": "^8.6.0", - "gsap": "^3.13.0", - "hast": "^1.0.0", - "katex": "^0.16.28", - "lucide-react": "^0.562.0", - "motion": "^12.26.2", - "nanoid": "^5.1.6", - "next-themes": "^0.4.6", - "ogl": "^1.0.11", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^4.4.1", - "react-router-dom": "^7.9.4", - "react-syntax-highlighter": "^16.1.1", - "rehype-katex": "^7.0.1", - "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1", - "remark-math": "^6.0.0", - "shiki": "3.15.0", - "sonner": "^2.0.7", - "streamdown": "1.4.0", - "tailwind-merge": "^3.4.0", - "tdesign-react": "^1.12.3", - "tokenlens": "^1.3.1", - "unist-util-visit": "^5.0.0", - "use-stick-to-bottom": "^1.1.1", - "uuid": "^14.0.0", - "zod": "^3.24.2" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.0.15", - "@types/gsap": "^3.0.0", - "@types/node": "^20.14.10", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^5.0.4", - "postcss": "^8.5.3", - "tailwindcss": "^4.0.15", - "tw-animate-css": "^1.4.0", - "typescript": "^5.8.2", - "vite": "^6.0.0", - "vite-plugin-source-identifier": "^1.1.2" - }, - "packageManager": "pnpm@10.26.2" -} +{ + "name": "deer-flow-frontend-vite", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language-data": "^6.5.2", + "@langchain/core": "^1.1.15", + "@langchain/langgraph-sdk": "^1.5.3", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@tanstack/react-query": "^5.90.17", + "@tiptap/core": "^3.23.4", + "@tiptap/extension-bubble-menu": "^3.23.4", + "@tiptap/extension-placeholder": "^3.23.4", + "@tiptap/extension-underline": "^3.23.4", + "@tiptap/pm": "^3.23.4", + "@tiptap/react": "^3.23.4", + "@tiptap/starter-kit": "^3.23.4", + "@types/hast": "^3.0.4", + "@uiw/codemirror-theme-basic": "^4.25.4", + "@uiw/codemirror-theme-monokai": "^4.25.4", + "@uiw/react-codemirror": "^4.25.4", + "@xyflow/react": "^12.10.0", + "ai": "^6.0.33", + "best-effort-json-parser": "^1.2.1", + "canvas-confetti": "^1.9.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "codemirror": "^6.0.2", + "date-fns": "^4.1.0", + "docx": "^9.5.1", + "dotenv": "^17.2.3", + "embla-carousel-react": "^8.6.0", + "gsap": "^3.13.0", + "hast": "^1.0.0", + "katex": "^0.16.28", + "lucide-react": "^0.562.0", + "motion": "^12.26.2", + "nanoid": "^5.1.6", + "next-themes": "^0.4.6", + "ogl": "^1.0.11", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.4.1", + "react-router-dom": "^7.9.4", + "react-syntax-highlighter": "^16.1.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "shiki": "3.15.0", + "sonner": "^2.0.7", + "streamdown": "1.4.0", + "tailwind-merge": "^3.4.0", + "tdesign-react": "^1.12.3", + "tiptap-markdown": "^0.9.0", + "tokenlens": "^1.3.1", + "unist-util-visit": "^5.0.0", + "use-stick-to-bottom": "^1.1.1", + "uuid": "^14.0.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.15", + "@types/gsap": "^3.0.0", + "@types/node": "^20.14.10", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.4", + "postcss": "^8.5.3", + "tailwindcss": "^4.0.15", + "tw-animate-css": "^1.4.0", + "typescript": "^5.8.2", + "vite": "^6.0.0", + "vite-plugin-source-identifier": "^1.1.2" + }, + "packageManager": "pnpm@10.26.2" +} diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index da3acecf..62836ca9 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -28,6 +28,7 @@ import { extractContentFromMessage, extractReasoningContentFromMessage, parseUploadedFiles, + stripArtifactTag, stripUploadedFilesTag, type FileInMessage, } from "@/core/messages/utils"; @@ -173,7 +174,7 @@ function MessageContent_({ const contentToDisplay = useMemo(() => { if (isHuman) { - return rawContent ? stripUploadedFilesTag(rawContent) : ""; + return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; } return rawContent ?? ""; }, [rawContent, isHuman]); diff --git a/frontend-web/src/core/api/canvas-client.ts b/frontend-web/src/core/api/canvas-client.ts new file mode 100644 index 00000000..3c1c0f5c --- /dev/null +++ b/frontend-web/src/core/api/canvas-client.ts @@ -0,0 +1,57 @@ +"use client"; + +import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client"; + +import { getAuthorizationHeaderValue } from "../auth"; +import { getCanvasLangGraphBaseURL } from "../config"; + +import { sanitizeRunStreamOptions } from "./stream-mode"; + +/** + * Dedicated LangGraph client for the Canvas page. + * + * Keeping this separate from getAPIClient() means: + * - Canvas API calls can be pointed at a different server via VITE_CANVAS_LANGGRAPH_BASE_URL + * - Canvas-specific HTTP-level logic (headers, retries, middleware) lives here + * - Changes here never touch the main page's API path + */ +function createCanvasClient(): LangGraphClient { + const client = new LangGraphClient({ + apiUrl: getCanvasLangGraphBaseURL(), + onRequest(_url, init) { + const headers = new Headers(init.headers ?? {}); + const authHeader = getAuthorizationHeaderValue(); + if (authHeader && !headers.has("Authorization")) { + headers.set("Authorization", authHeader); + } + return { ...init, headers }; + }, + }); + + const originalRunStream = client.runs.stream.bind(client.runs); + client.runs.stream = ((threadId, assistantId, payload) => + originalRunStream( + threadId, + assistantId, + sanitizeRunStreamOptions(payload), + )) as typeof client.runs.stream; + + const originalJoinStream = client.runs.joinStream.bind(client.runs); + client.runs.joinStream = ((threadId, runId, options) => + originalJoinStream( + threadId, + runId, + sanitizeRunStreamOptions(options), + )) as typeof client.runs.joinStream; + + return client; +} + +let _canvasClient: LangGraphClient | null = null; + +export function getCanvasAPIClient(): LangGraphClient { + if (!_canvasClient) { + _canvasClient = createCanvasClient(); + } + return _canvasClient; +} diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 4dc61576..4d23bb10 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -18,6 +18,23 @@ export function getBackendBaseURL() { } } +/** + * Canvas page has its own LangGraph base URL so canvas-specific modifications + * (different server, different route prefix, etc.) can be made independently + * without touching the main page's API path. + * Falls back to the main LangGraph URL when VITE_CANVAS_LANGGRAPH_BASE_URL is unset. + */ +export function getCanvasLangGraphBaseURL(): string { + if (env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL) { + return new URL( + env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL, + getBaseOrigin(), + ).toString(); + } + // Fall back to the main LangGraph URL (same server, different assistantId) + return getLangGraphBaseURL(); +} + export function getLangGraphBaseURL(isMock?: boolean) { if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) { return new URL( diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index e1c1a478..6929f0a7 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -14,20 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {} interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {} -interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {} - -interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {} - -interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} +interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {} + +interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {} + +interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} type MessageGroup = | HumanMessageGroup | AssistantProcessingGroup | AssistantMessageGroup - | AssistantPresentFilesGroup - | AssistantClarificationGroup - | AssistantSetupAgentApprovalGroup - | AssistantSubagentGroup; + | AssistantPresentFilesGroup + | AssistantClarificationGroup + | AssistantSetupAgentApprovalGroup + | AssistantSubagentGroup; export function groupMessages( messages: Message[], @@ -44,11 +44,11 @@ export function groupMessages( function lastOpenGroup() { const last = groups[groups.length - 1]; if ( - last && - last.type !== "human" && - last.type !== "assistant" && - last.type !== "assistant:clarification" && - last.type !== "assistant:setup-agent-approval" + last && + last.type !== "human" && + last.type !== "assistant" && + last.type !== "assistant:clarification" && + last.type !== "assistant:setup-agent-approval" ) { return last; } @@ -70,23 +70,23 @@ export function groupMessages( } if (message.type === "tool") { - if (isClarificationToolMessage(message)) { + if (isClarificationToolMessage(message)) { // Add to the preceding processing group to preserve tool-call association, // then also open a standalone clarification group for prominent display. lastOpenGroup()?.messages.push(message); groups.push({ id: message.id, type: "assistant:clarification", - messages: [message], - }); - } else if (isSetupAgentApprovalToolMessage(message)) { - lastOpenGroup()?.messages.push(message); - groups.push({ - id: message.id, - type: "assistant:setup-agent-approval", - messages: [message], - }); - } else { + messages: [message], + }); + } else if (isSetupAgentApprovalToolMessage(message)) { + lastOpenGroup()?.messages.push(message); + groups.push({ + id: message.id, + type: "assistant:setup-agent-approval", + messages: [message], + }); + } else { const open = lastOpenGroup(); if (open) { open.messages.push(message); @@ -297,19 +297,19 @@ export function hasPresentFiles(message: Message) { ); } -export function isClarificationToolMessage(message: Message) { - return message.type === "tool" && message.name === "ask_clarification"; -} - -export function isSetupAgentApprovalToolMessage(message: Message) { - return ( - message.type === "tool" && - message.name === "setup_agent" && - typeof message.additional_kwargs?.setup_agent_approval === "object" && - message.additional_kwargs.setup_agent_approval !== null - ); -} - +export function isClarificationToolMessage(message: Message) { + return message.type === "tool" && message.name === "ask_clarification"; +} + +export function isSetupAgentApprovalToolMessage(message: Message) { + return ( + message.type === "tool" && + message.name === "setup_agent" && + typeof message.additional_kwargs?.setup_agent_approval === "object" && + message.additional_kwargs.setup_agent_approval !== null + ); +} + export function extractPresentFilesFromMessage(message: Message) { if (message.type !== "ai" || !hasPresentFiles(message)) { return []; @@ -372,6 +372,13 @@ export function stripUploadedFilesTag(content: string): string { .trim(); } +/** Strip injected ... blocks from human message display. */ +export function stripArtifactTag(content: string): string { + return content + .replace(/]*>[\s\S]*?<\/artifact>/g, "") + .trim(); +} + export function parseUploadedFiles(content: string): FileInMessage[] { // Match ... tag const uploadedFilesRegex = /([\s\S]*?)<\/uploaded_files>/; diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 7e7b7254..e387c0f2 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -4,6 +4,7 @@ function readEnv( key: | "VITE_BACKEND_BASE_URL" | "VITE_LANGGRAPH_BASE_URL" + | "VITE_CANVAS_LANGGRAPH_BASE_URL" | "VITE_STATIC_WEBSITE_ONLY" | "VITE_GITHUB_OAUTH_TOKEN", ): EnvValue { @@ -14,6 +15,9 @@ function readEnv( export const env = { NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"), NEXT_PUBLIC_LANGGRAPH_BASE_URL: readEnv("VITE_LANGGRAPH_BASE_URL"), + // Canvas page uses its own LangGraph base URL so it can be independently + // routed (e.g. a separate canvas-agent server). Falls back to the main URL. + NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), }; diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts new file mode 100644 index 00000000..41474426 --- /dev/null +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -0,0 +1,108 @@ +import { apiFetch } from "@/core/api/fetch-client"; +import { env } from "@/env"; + +const BASE = (env.NEXT_PUBLIC_BACKEND_BASE_URL as string | undefined) ?? "http://localhost:8001"; + +export type RewriteAction = "polish" | "expand" | "shorten" | "tone"; +export type RewriteTone = + | "professional" + | "casual" + | "readable" + | "academic" + | "expressive"; + +export interface RewriteRequest { + threadId: string; + selection: { from: number; to: number; text: string }; + action: RewriteAction; + tone?: RewriteTone; + modelName?: string; + surroundingContext?: { before?: string; after?: string }; + customInstruction?: string; + followUpInstruction?: string; + previousResult?: string; +} + +export type RewriteStreamEvent = + | { type: "thinking"; chunk: string } + | { type: "text"; chunk: string } + | { type: "done" } + | { type: "error"; message: string }; + +// ── SSE parser ──────────────────────────────────────────────────────────────── + +async function* parseSse(body: ReadableStream): AsyncGenerator<{ event: string; data: string }> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let currentEvent = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trimEnd(); + if (trimmed.startsWith("event: ")) { + currentEvent = trimmed.slice(7); + } else if (trimmed.startsWith("data: ")) { + yield { event: currentEvent, data: trimmed.slice(6) }; + currentEvent = ""; + } + // empty line resets event field (SSE spec) + else if (trimmed === "") { + currentEvent = ""; + } + } + } + } finally { + reader.releaseLock(); + } +} + +// ── Streaming entry point ───────────────────────────────────────────────────── + +export async function* streamRewrite( + req: RewriteRequest, + signal?: AbortSignal, +): AsyncGenerator { + const res = await apiFetch(`${BASE}/api/writing/rewrite`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + signal, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})) as Record; + const msg = (body?.detail as string | undefined) ?? (body?.message as string | undefined) ?? `HTTP ${res.status}`; + throw new Error(msg); + } + + if (!res.body) throw new Error("No response body"); + + for await (const { event, data } of parseSse(res.body)) { + if (signal?.aborted) return; + try { + const parsed = JSON.parse(data) as Record; + if (event === "thinking") { + yield { type: "thinking", chunk: parsed.chunk as string }; + } else if (event === "text") { + yield { type: "text", chunk: parsed.chunk as string }; + } else if (event === "done") { + yield { type: "done" }; + return; + } else if (event === "error") { + yield { type: "error", message: parsed.message as string }; + return; + } + } catch { + // skip malformed frame + } + } +} diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index db7cdba2..a2b12ad5 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -5,20 +5,76 @@ import remarkGfm from "remark-gfm"; import { Button } from "@/components/ui/button"; import type { CanvasArtifactText } from "../../types/artifact"; -import { useCanvasSelection } from "../../hooks/useCanvasSelection"; +import { TiptapEditor } from "../../editor/TiptapEditor"; +import { useWritingEditor } from "../../hooks/useWritingEditor"; +import { useWritingRewrite } from "../../hooks/useWritingRewrite"; +import { WritingToolbar } from "./WritingToolbar"; +import { WritingBubbleMenu } from "./WritingBubbleMenu"; +import { WritingParagraphMenu } from "./WritingParagraphMenu"; +import { WritingAiMenu } from "./WritingAiMenu"; +import { WritingRewritePreview } from "./WritingRewritePreview"; +import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; +import { applyReplace, applyInsert } from "../../utils/apply-rewrite"; +import type { RewriteAction, RewriteTone } from "../../api/rewrite"; interface TextArtifactRendererProps { content: CanvasArtifactText; isStreaming?: boolean; } +interface PanelPos { + x: number; + y: number; +} + +/** Phase of the AI rewrite flow */ +type FlowPhase = "idle" | "menu" | "preview"; + +interface LastAction { + action: RewriteAction; + tone?: RewriteTone; + customInstruction?: string; +} + export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) { - const { handleTextPointerUp } = useCanvasSelection(); + const { artifact, setArtifact } = useCanvasGraphContext(); + const { writingSelection } = useCanvasUIContext(); + const scrollRef = useRef(null); const [showScrollBtn, setShowScrollBtn] = useState(false); const autoScrollRef = useRef(true); - // Auto-scroll to bottom while streaming, unless user has scrolled up + // ── AI rewrite flow state ────────────────────────────────────────────── + const [phase, setPhase] = useState("idle"); + const [panelPos, setPanelPos] = useState({ x: 0, y: 0 }); + const lastActionRef = useRef(null); + + const handleEditorChange = useCallback( + (markdown: string) => { + if (!artifact) return; + setArtifact({ + ...artifact, + contents: artifact.contents.map((c) => + c.index === artifact.currentIndex && c.type === "text" + ? { ...c, fullMarkdown: markdown } + : c, + ), + }); + }, + [artifact, setArtifact], + ); + + const editor = useWritingEditor({ + markdown: content.fullMarkdown, + onChange: handleEditorChange, + editable: !isStreaming, + placeholder: "开始写作…", + }); + + const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } = useWritingRewrite(editor); + + // ── Streaming auto-scroll ────────────────────────────────────────────── useEffect(() => { if (!isStreaming) { autoScrollRef.current = true; @@ -46,7 +102,70 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende setShowScrollBtn(false); }, []); - if (!content.fullMarkdown) { + // ── AI rewrite flow handlers ─────────────────────────────────────────── + + /** Compute anchor position from current DOM selection */ + const computePos = useCallback((): PanelPos => { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const rect = sel.getRangeAt(0).getBoundingClientRect(); + if (rect.width || rect.height) { + return { x: rect.left, y: rect.bottom + 6 }; + } + } + return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 }; + }, []); + + const openAiMenu = useCallback(() => { + reset(); + setPanelPos(computePos()); + setPhase("menu"); + }, [reset, computePos]); + + const closeAll = useCallback(() => { + editor?.commands.clearAiHighlight(); + setPhase("idle"); + reset(); + }, [reset, editor]); + + const handleAction = useCallback( + (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => { + lastActionRef.current = { action, tone, customInstruction }; + rewrite(action, tone, customInstruction); + setPhase("preview"); + }, + [rewrite], + ); + + const handleRetry = useCallback(() => { + if (!lastActionRef.current) return; + const { action, tone, customInstruction } = lastActionRef.current; + rewrite(action, tone, customInstruction); + }, [rewrite]); + + const handleFollowUp = useCallback( + (instruction: string) => { + if (!lastActionRef.current) return; + const { action, tone } = lastActionRef.current; + rewriteFollowUp(instruction, action, tone); + }, + [rewriteFollowUp], + ); + + const handleReplace = useCallback(() => { + if (!editor || !writingSelection || !result) return; + applyReplace(editor, writingSelection, result); + closeAll(); + }, [editor, writingSelection, result, closeAll]); + + const handleInsert = useCallback(() => { + if (!editor || !writingSelection || !result) return; + applyInsert(editor, writingSelection, result); + closeAll(); + }, [editor, writingSelection, result, closeAll]); + + // ── Empty state ──────────────────────────────────────────────────────── + if (!content.fullMarkdown && !isStreaming) { return (

@@ -56,142 +175,145 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende ); } - return ( -

-
handleTextPointerUp(content.fullMarkdown)} - onScroll={handleScroll} - > -
- ( -

- {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 ( + // ── Streaming mode ──────────────────────────────────────────────────── + if (isStreaming) { + return ( +
    +
    +
    + ( +

    + {children} +

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

    + {children} +

    + ), + h3: ({ 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-")); + return isBlock ? ( {children} + ) : ( + + {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 && ( + }, + }} + > + {content.fullMarkdown} + +
    +
    + + {showScrollBtn && ( +
    + +
    + )} +
    + ); + } + + // ── Edit mode ───────────────────────────────────────────────────────── + return ( +
    + + +
    +
    + {editor && ( + <> + + + )} +
    - {isStreaming && showScrollBtn && ( -
    - -
    + {/* Phase: AI action menu */} + {phase === "menu" && ( + + )} + + {/* Phase: result preview */} + {phase === "preview" && ( + )}
    ); diff --git a/frontend-web/src/open-canvas/components/artifact/WritingAiMenu.tsx b/frontend-web/src/open-canvas/components/artifact/WritingAiMenu.tsx new file mode 100644 index 00000000..c3b4c587 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/WritingAiMenu.tsx @@ -0,0 +1,288 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + Sparkles, + Wand2, + Expand, + Shrink, + Mic2, + AlertCircle, + RefreshCw, + ChevronRight, + X, + Square, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { RewriteAction, RewriteTone } from "../../api/rewrite"; +import type { RewriteStatus } from "../../hooks/useWritingRewrite"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface MenuPos { + x: number; + y: number; +} + +// ── Tone options ─────────────────────────────────────────────────────────────── + +const TONE_OPTIONS: { value: RewriteTone; label: string }[] = [ + { value: "professional", label: "更专业" }, + { value: "casual", label: "更口语化" }, + { value: "readable", label: "更易读" }, + { value: "academic", label: "更学术" }, + { value: "expressive", label: "更有文采" }, +]; + +// ── Action rows ──────────────────────────────────────────────────────────────── + +const ACTION_ROWS: { + action: RewriteAction; + label: string; + icon: React.ElementType; +}[] = [ + { action: "polish", label: "润色", icon: Wand2 }, + { action: "expand", label: "扩写", icon: Expand }, + { action: "shorten", label: "缩写", icon: Shrink }, + { action: "tone", label: "改语气", icon: Mic2 }, +]; + +// ── Main component ───────────────────────────────────────────────────────────── + +interface WritingAiMenuProps { + pos: MenuPos; + status: RewriteStatus; + error: string | null; + onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void; + onAbort: () => void; + onRetry: () => void; + onClose: () => void; +} + +export function WritingAiMenu({ + pos, + status, + error, + onAction, + onAbort, + onRetry, + onClose, +}: WritingAiMenuProps) { + const [toneOpen, setToneOpen] = useState(false); + const [instruction, setInstruction] = useState(""); + const inputRef = useRef(null); + + // autoFocus is unreliable when the menu opens during a mousedown event sequence; + // explicit focus via setTimeout(0) runs after the browser finishes that sequence. + useEffect(() => { + const id = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(id); + }, []); + + const isLoading = status === "loading"; + const isError = status === "error"; + + // Measure actual height after render and compute safe position before browser paint + const menuWidth = 240; + const containerRef = useRef(null); + const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null); + + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + const h = el.getBoundingClientRect().height; + const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - menuWidth - 8); + const spaceBelow = window.innerHeight - pos.y - 8; + const spaceAbove = pos.y - 8; + let safeY: number; + if (spaceBelow >= h) { + safeY = pos.y + 4; + } else if (spaceAbove >= h) { + safeY = pos.y - h - 4; + } else if (spaceBelow >= spaceAbove) { + safeY = Math.max(8, window.innerHeight - h - 8); + } else { + safeY = 8; + } + setComputedPos({ left: safeX, top: safeY }); + }, [pos.x, pos.y]); + + const fireAction = (action: RewriteAction, tone?: RewriteTone) => { + onAction(action, tone, instruction.trim() || undefined); + }; + + const handleInputEnter = () => { + const trimmed = instruction.trim(); + onAction("polish", undefined, trimmed || undefined); + }; + + return createPortal( + <> + {/* Backdrop */} +
    + +
    + {/* Header */} +
    + + + AI 改写 + + +
    + + {/* ── Loading state ──────────────────────────────────────── */} + {isLoading && ( +
    + {/* Bouncing dots */} +
    + {[0, 160, 320].map((delay) => ( + + ))} + AI 正在生成… +
    + + {/* Stop button — breathing animation */} + +
    + )} + + {/* ── Error state ─────────────────────────────────────────── */} + {isError && ( +
    +

    + + {error ?? "改写失败"} +

    + +
    + )} + + {/* ── Idle state: input + action list ─────────────────────── */} + {!isLoading && !isError && ( + <> + {/* Custom instruction input */} +
    + setInstruction(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleInputEnter(); + } + }} + placeholder="告诉 AI 你想要什么…" + className={cn( + "w-full text-xs rounded-md px-2.5 py-1.5 outline-none transition-colors", + "bg-muted/50 border border-input focus:border-primary/60", + "placeholder:text-muted-foreground/50", + )} + /> +
    + + {/* Divider */} +
    + + {/* Preset actions */} +
    + {ACTION_ROWS.map(({ action, label, icon: Icon }) => { + const isTone = action === "tone"; + const isTonesOpen = isTone && toneOpen; + return ( +
    + + + {/* Tone sub-list */} + {isTonesOpen && ( +
    + {TONE_OPTIONS.map(({ value, label: toneLabel }) => ( + + ))} +
    + )} +
    + ); + })} +
    + + )} +
    + , + document.body, + ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/WritingBubbleMenu.tsx b/frontend-web/src/open-canvas/components/artifact/WritingBubbleMenu.tsx new file mode 100644 index 00000000..36b28d27 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/WritingBubbleMenu.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { Editor } from "@tiptap/react"; +import { + Sparkles, + Bold, + Italic, + Underline, + Heading1, + Copy, + Check, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface MenuPosition { + x: number; + y: number; // top of selection + bottom: number; // bottom of selection (used to flip menu below when near top) +} + +// ── Sub-components ─────────────────────────────────────────────────────────── + +interface BubbleButtonProps { + onClick: () => void; + active?: boolean; + className?: string; + children: React.ReactNode; +} + +function BubbleButton({ onClick, active, className, children }: BubbleButtonProps) { + return ( + + ); +} + +function BubbleSep() { + return
    ; +} + +// ── Main component ─────────────────────────────────────────────────────────── + +interface WritingBubbleMenuProps { + editor: Editor; + onAiRewrite: () => void; +} + +export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) { + const { setWritingSelection } = useCanvasUIContext(); + const [position, setPosition] = useState(null); + const [copied, setCopied] = useState(false); + const menuRef = useRef(null); + const [computedStyle, setComputedStyle] = useState({ + position: "fixed", left: 0, top: 0, zIndex: 9999, visibility: "hidden", + }); + + useLayoutEffect(() => { + if (!position || !menuRef.current) return; + const { width: mw, height: mh } = menuRef.current.getBoundingClientRect(); + // X: center on selection, clamp to viewport + const rawLeft = position.x - mw / 2; + const safeLeft = Math.max(8, Math.min(rawLeft, window.innerWidth - mw - 8)); + // Y: prefer above; flip below if too close to top + let safeTop: number; + if (position.y - mh - 8 >= 8) { + safeTop = position.y - mh - 8; + } else { + safeTop = Math.min(position.bottom + 8, window.innerHeight - mh - 8); + } + setComputedStyle({ position: "fixed", left: safeLeft, top: safeTop, zIndex: 9999, visibility: "visible" }); + }, [position]); + + // Track selection → compute menu position + useEffect(() => { + const updatePosition = () => { + const { from, to, empty } = editor.state.selection; + + if (empty || editor.isActive("codeBlock")) { + setPosition(null); + return; + } + + // Use the native DOM selection for pixel coordinates + const domSel = window.getSelection(); + if (!domSel || domSel.rangeCount === 0) { + setPosition(null); + return; + } + + const range = domSel.getRangeAt(0); + const rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) { + setPosition(null); + return; + } + + // Store selection for AI rewrite + const text = editor.state.doc.textBetween(from, to, "\n"); + if (text.trim()) { + setWritingSelection({ from, to, text }); + } + + setPosition({ + x: rect.left + rect.width / 2, + y: rect.top, + bottom: rect.bottom, + }); + }; + + editor.on("selectionUpdate", updatePosition); + editor.on("blur", () => setPosition(null)); + + return () => { + editor.off("selectionUpdate", updatePosition); + editor.off("blur", () => setPosition(null)); + }; + }, [editor, setWritingSelection]); + + const handleAiRewrite = useCallback(() => { + const { from, to, empty } = editor.state.selection; + if (!empty) { + editor.commands.setAiHighlight(from, to); + } + onAiRewrite(); + }, [onAiRewrite, editor]); + + const handleCopy = useCallback(() => { + const { from, to } = editor.state.selection; + const text = editor.state.doc.textBetween(from, to, "\n"); + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, [editor]); + + if (!position) return null; + + return createPortal( +
    +
    + {/* AI 改写 — primary */} + + + AI 改写 + + + + + {/* Inline format */} + editor.chain().focus().toggleBold().run()} + active={editor.isActive("bold")} + > + + + + editor.chain().focus().toggleItalic().run()} + active={editor.isActive("italic")} + > + + + + editor.chain().focus().toggleUnderline().run()} + active={editor.isActive("underline")} + > + + + + + + {/* Heading */} + + editor.chain().focus().toggleHeading({ level: 1 }).run() + } + active={editor.isActive("heading", { level: 1 })} + > + + + + + + {/* Copy */} + + {copied ? ( + + ) : ( + + )} + +
    +
    , + document.body, + ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/WritingParagraphMenu.tsx b/frontend-web/src/open-canvas/components/artifact/WritingParagraphMenu.tsx new file mode 100644 index 00000000..4c555949 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/WritingParagraphMenu.tsx @@ -0,0 +1,163 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { Editor } from "@tiptap/react"; +import { Sparkles } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; + +const REWRITABLE_TYPES = new Set(["paragraph", "heading", "blockquote"]); + +const BTN_SIZE = 24; +const BTN_GAP = 6; // gap between button right edge and text left edge +// Extra zone around the button that still counts as "near" (bridges the gap) +const HOVER_MARGIN = BTN_GAP + 8; +// Delay before hiding the button (ms) — long enough to cross the gap at slow speed +const HIDE_DELAY = 380; + +interface HoverState { + top: number; + left: number; + height: number; + nodeFrom: number; + nodeTo: number; + text: string; +} + +interface WritingParagraphMenuProps { + editor: Editor; + onAiRewrite: () => void; +} + +export function WritingParagraphMenu({ editor, onAiRewrite }: WritingParagraphMenuProps) { + const { setWritingSelection } = useCanvasUIContext(); + const [hover, setHover] = useState(null); + const hideTimerRef = useRef | null>(null); + // Keep a ref in sync so the document-level handler always reads fresh state + const hoverRef = useRef(null); + hoverRef.current = hover; + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + const scheduleHide = useCallback(() => { + clearHideTimer(); + hideTimerRef.current = setTimeout(() => setHover(null), HIDE_DELAY); + }, [clearHideTimer]); + + // Document-level mousemove: cancel hide when cursor is in the button's vicinity. + // This bridges the gap between the editor's text edge and the button. + useEffect(() => { + const handleDocMove = (e: MouseEvent) => { + const h = hoverRef.current; + if (!h) return; + + const btnLeft = h.left - BTN_SIZE - BTN_GAP; + const btnTop = h.top + h.height / 2 - BTN_SIZE / 2; + + const inZone = + e.clientX >= btnLeft - HOVER_MARGIN && + e.clientX <= h.left + HOVER_MARGIN && // extend slightly into text side + e.clientY >= btnTop - HOVER_MARGIN && + e.clientY <= btnTop + BTN_SIZE + HOVER_MARGIN; + + if (inZone) clearHideTimer(); + }; + + document.addEventListener("mousemove", handleDocMove); + return () => document.removeEventListener("mousemove", handleDocMove); + }, [clearHideTimer]); + + useEffect(() => { + const editorDom = editor.view.dom as HTMLElement; + + const handleMouseMove = (e: MouseEvent) => { + const pos = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }); + if (!pos) { scheduleHide(); return; } + + const $pos = editor.state.doc.resolve(pos.pos); + const depth = Math.min(1, $pos.depth); + if (depth === 0) { scheduleHide(); return; } + + const nodeStart = $pos.before(depth); + const node = editor.state.doc.nodeAt(nodeStart); + if (!node || !REWRITABLE_TYPES.has(node.type.name)) { + scheduleHide(); + return; + } + + const text = node.textContent.trim(); + if (!text) { scheduleHide(); return; } + + const domNode = editor.view.nodeDOM(nodeStart); + if (!domNode || !(domNode instanceof HTMLElement)) { + scheduleHide(); + return; + } + + const rect = domNode.getBoundingClientRect(); + clearHideTimer(); + setHover({ + top: rect.top, + left: rect.left, + height: rect.height, + nodeFrom: nodeStart + 1, + nodeTo: nodeStart + node.nodeSize - 1, + text, + }); + }; + + editorDom.addEventListener("mousemove", handleMouseMove); + editorDom.addEventListener("mouseleave", scheduleHide); + + return () => { + editorDom.removeEventListener("mousemove", handleMouseMove); + editorDom.removeEventListener("mouseleave", scheduleHide); + clearHideTimer(); + }; + }, [editor, clearHideTimer, scheduleHide]); + + const handleClick = useCallback(() => { + if (!hover) return; + editor.chain().focus().setTextSelection({ from: hover.nodeFrom, to: hover.nodeTo }).run(); + editor.commands.setAiHighlight(hover.nodeFrom, hover.nodeTo); + setWritingSelection({ from: hover.nodeFrom, to: hover.nodeTo, text: hover.text }); + onAiRewrite(); + }, [hover, editor, setWritingSelection, onAiRewrite]); + + if (!hover) return null; + + const btnTop = hover.top + hover.height / 2 - BTN_SIZE / 2; + const btnLeft = Math.max(4, hover.left - BTN_SIZE - BTN_GAP); + + return createPortal( + , + document.body, + ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/WritingRewritePreview.tsx b/frontend-web/src/open-canvas/components/artifact/WritingRewritePreview.tsx new file mode 100644 index 00000000..907ea73c --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/WritingRewritePreview.tsx @@ -0,0 +1,297 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + RefreshCw, X, ArrowRightLeft, ArrowDownToLine, + Loader2, ChevronDown, Brain, SendHorizontal, Square, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import type { RewriteAction, RewriteTone } from "../../api/rewrite"; +import type { RewriteStatus } from "../../hooks/useWritingRewrite"; + +const ACTION_LABEL: Record = { + polish: "润色", + expand: "扩写", + shorten: "缩写", + tone: "改语气", +}; + +const TONE_LABEL: Record = { + professional: "更专业", + casual: "更口语化", + readable: "更易读", + academic: "更学术", + expressive: "更有文采", +}; + +interface MenuPos { x: number; y: number } + +interface WritingRewritePreviewProps { + pos: MenuPos; + status: RewriteStatus; + originalText: string; + result: string | null; + thinking: string | null; + error: string | null; + action: RewriteAction; + tone?: RewriteTone; + onReplace: () => void; + onInsert: () => void; + onRetry: () => void; + onAbort: () => void; + onClose: () => void; + onFollowUp: (instruction: string) => void; +} + +export function WritingRewritePreview({ + pos, + status, + result, + thinking, + error, + action, + tone, + onReplace, + onInsert, + onRetry, + onAbort, + onClose, + onFollowUp, +}: WritingRewritePreviewProps) { + const isLoading = status === "loading"; + const isError = status === "error"; + const isSuccess = status === "success" && result !== null; + const hasResult = result !== null && result.length > 0; + + // ── Follow-up input ───────────────────────────────────────────────────── + const [followUpInput, setFollowUpInput] = useState(""); + const followUpInputRef = useRef(null); + + // Auto-focus the follow-up input when generation finishes + useEffect(() => { + if (status === "success") { + setTimeout(() => followUpInputRef.current?.focus(), 50); + } + }, [status]); + + const handleFollowUpSubmit = () => { + const instruction = followUpInput.trim(); + if (!instruction || isLoading) return; + setFollowUpInput(""); + onFollowUp(instruction); + }; + + // ── Thinking expand / collapse ────────────────────────────────────────── + const [thinkingOpen, setThinkingOpen] = useState(false); + const prevStatusRef = useRef(status); + + // Auto-expand when thinking content first streams in during loading + useEffect(() => { + if (isLoading && thinking) setThinkingOpen(true); + }, [isLoading, thinking]); + + // Auto-collapse when generation finishes (loading → success/error) + useEffect(() => { + const prev = prevStatusRef.current; + prevStatusRef.current = status; + if (prev === "loading" && status !== "loading") { + setThinkingOpen(false); + } + }, [status]); + + // ── Position ──────────────────────────────────────────────────────────── + const panelWidth = 480; + const containerRef = useRef(null); + const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null); + + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + const h = el.getBoundingClientRect().height; + const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - panelWidth - 8); + const spaceBelow = window.innerHeight - pos.y - 8; + const spaceAbove = pos.y - 8; + let safeY: number; + if (spaceBelow >= h) { + safeY = pos.y + 6; + } else if (spaceAbove >= h) { + safeY = pos.y - h - 6; + } else if (spaceBelow >= spaceAbove) { + safeY = Math.max(8, window.innerHeight - h - 8); + } else { + safeY = 8; + } + setComputedPos({ left: safeX, top: safeY }); + }, [pos.x, pos.y]); + + const actionLabel = tone + ? `${ACTION_LABEL[action]} · ${TONE_LABEL[tone]}` + : ACTION_LABEL[action]; + + return createPortal( + <> + {/* Backdrop */} +
    + +
    + {/* Header */} +
    + {actionLabel} +
    + {isLoading && ( + + )} + + +
    +
    + + {/* Body */} +
    + + {/* Thinking — collapsible with animation */} + {thinking && ( +
    + {/* Toggle button */} + + + {/* Animated content — grid-rows trick for smooth height transition */} +
    +
    +
    + {thinking} + {isLoading && ( + + )} +
    +
    +
    +
    + )} + + {/* Result area */} + {/* Loading — no text yet */} + {isLoading && !hasResult && ( +
    + + AI 正在生成… +
    + )} + + {/* Error */} + {isError && ( +
    + {error ?? "改写失败,请重试"} +
    + )} + + {/* Result text — show while streaming and after success */} + {hasResult && ( +
    + {result} + {isLoading && ( + + )} +
    + )} +
    + + {/* Footer actions — only after success */} + {isSuccess && ( +
    + + +
    + )} + + {/* Follow-up chat input — shown after success */} + {isSuccess && ( +
    + setFollowUpInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleFollowUpSubmit(); + } + }} + placeholder="继续优化,如:更简洁一些、语气更正式…" + className="flex-1 text-xs bg-muted/50 rounded-md px-2.5 py-1.5 outline-none border border-input focus:border-primary/50 transition-colors placeholder:text-muted-foreground/50" + /> + +
    + )} +
    + , + document.body, + ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx new file mode 100644 index 00000000..7eaaca0c --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx @@ -0,0 +1,182 @@ +import type { Editor } from "@tiptap/react"; +import { + Bold, + Italic, + Underline, + Strikethrough, + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Quote, + Code, + Pilcrow, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CanvasModelSelector } from "../selectors/CanvasModelSelector"; + +interface ToolbarButtonProps { + onClick: () => void; + active?: boolean; + disabled?: boolean; + tooltip: string; + children: React.ReactNode; +} + +function ToolbarButton({ onClick, active, disabled, tooltip, children }: ToolbarButtonProps) { + return ( + + + + + + {tooltip} + + + ); +} + +interface WritingToolbarProps { + editor: Editor | null; +} + +export function WritingToolbar({ editor }: WritingToolbarProps) { + if (!editor) return null; + + return ( +
    + {/* Block type */} + editor.chain().focus().setParagraph().run()} + > + + + + editor.chain().focus().toggleHeading({ level: 1 }).run()} + > + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + > + + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + > + + + + + + {/* Lists */} + editor.chain().focus().toggleBulletList().run()} + > + + + + editor.chain().focus().toggleOrderedList().run()} + > + + + + + + {/* Blocks */} + editor.chain().focus().toggleBlockquote().run()} + > + + + + editor.chain().focus().toggleCodeBlock().run()} + > + + + + + + {/* Inline marks */} + editor.chain().focus().toggleBold().run()} + > + + + + editor.chain().focus().toggleItalic().run()} + > + + + + editor.chain().focus().toggleUnderline().run()} + > + + + + editor.chain().focus().toggleStrike().run()} + > + + + + {/* Model selector — pushed to the right */} +
    + +
    +
    + ); +} diff --git a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx index 28ec72cc..9a1e3ab4 100644 --- a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx +++ b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx @@ -1,16 +1,47 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react"; -import { SendHorizonal, Sparkles, X } from "lucide-react"; -import { useCallback, useState, type KeyboardEvent } from "react"; +import { + CheckIcon, + ChevronDownIcon, + GraduationCapIcon, + LightbulbIcon, + RocketIcon, + SendHorizonal, + Sparkles, + Square, + X, + ZapIcon, +} from "lucide-react"; +import { useCallback, useMemo, useState, type KeyboardEvent } from "react"; +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorName, + ModelSelectorTrigger, +} from "@/components/ai-elements/model-selector"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Textarea } from "@/components/ui/textarea"; import { MessageList } from "@/components/workspace/messages"; +import { useModels } from "@/core/models/hooks"; import type { AgentThreadState } from "@/core/threads"; +import { cn } from "@/lib/utils"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; +import { useCanvasStreamContext } from "../../contexts/CanvasStreamContext"; +import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext"; import { useCanvasSelection } from "../../hooks/useCanvasSelection"; -import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream"; import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact"; import { buildMessageWithSelection, @@ -18,6 +49,19 @@ import { resolveCodeSelection, } from "../../utils/selection"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type CanvasMode = "flash" | "thinking" | "pro" | "ultra"; + +const MODE_LABELS: Record = { + flash: "快速", + thinking: "推理", + pro: "深入", + ultra: "超级", +}; + // --------------------------------------------------------------------------- // ChatWelcome // --------------------------------------------------------------------------- @@ -40,20 +84,48 @@ function ChatWelcome() { ); } +// --------------------------------------------------------------------------- +// ModeIcon +// --------------------------------------------------------------------------- + +function ModeIcon({ + mode, + className, +}: { + mode: CanvasMode; + className?: string; +}) { + if (mode === "flash") return ; + if (mode === "thinking") return ; + if (mode === "pro") return ; + return ; +} + // --------------------------------------------------------------------------- // CanvasChatPanel // --------------------------------------------------------------------------- export function CanvasChatPanel() { const { artifact } = useCanvasGraphContext(); - const { thread, sendMessage, threadId } = useCanvasThreadStream(); + const { thread, sendMessage, threadId } = useCanvasStreamContext(); + const { modelName, setModelName } = useCanvasThreadContext(); const { selection, clearSelection } = useCanvasSelection(); + const { models } = useModels(); + const [input, setInput] = useState(""); + const [mode, setMode] = useState("pro"); + const [modelDialogOpen, setModelDialogOpen] = useState(false); const hasSelection = hasActiveSelection(selection); const isStreaming = thread.isLoading; const hasMessages = thread.messages.length > 0; + const selectedModel = useMemo( + () => models.find((m) => m.name === modelName) ?? models[0], + [models, modelName], + ); + const supportsThinking = selectedModel?.supports_thinking ?? false; + // Resolve selected code snippet when artifact is a code artifact const currentContent = getCurrentArtifactContent(artifact); const resolvedCode = @@ -80,8 +152,12 @@ export function CanvasChatPanel() { })(); async function handleSend() { + if (isStreaming) { + await thread.stop(); + return; + } const text = input.trim(); - if (!text || isStreaming) return; + if (!text) return; setInput(""); const finalText = hasSelection @@ -89,7 +165,7 @@ export function CanvasChatPanel() { : text; clearSelection(); - await sendMessage(finalText); + await sendMessage(finalText, { mode }); } function handleKeyDown(e: KeyboardEvent) { @@ -101,9 +177,22 @@ export function CanvasChatPanel() { const handleClarificationSubmit = useCallback( async (answer: string) => { - await sendMessage(answer); + await sendMessage(answer, { mode }); }, - [sendMessage], + [sendMessage, mode], + ); + + const handleModelSelect = useCallback( + (name: string) => { + setModelName(name); + setModelDialogOpen(false); + // If selected model doesn't support thinking, fall back to flash + const model = models.find((m) => m.name === name); + if (!model?.supports_thinking && mode === "thinking") { + setMode("flash"); + } + }, + [models, mode, setModelName], ); // MessageList expects BaseStream. The canvas stream returns @@ -128,31 +217,32 @@ export function CanvasChatPanel() { )}
    - {/* Selection badge */} - {hasSelection && selectionPreview && ( -
    - - - {selection.textSelection ? "选中文本:" : "选中代码:"} - - {selectionPreview} - - -
    - )} - {/* Input area */} -
    -
    +
    +
    + {/* Selection badge */} + {hasSelection && selectionPreview && ( +
    + + + {selection.textSelection ? "选中文本:" : "选中代码:"} + + {selectionPreview} + + +
    + )} + + {/* Textarea */}