智能选中文字

This commit is contained in:
董宏凯 committed 2026-05-16 22:00:34 +08:00
1 parent e8a31b5d37
commit da4a08dfe1
34 files changed
+3208 -541

No files matched your search

+256 -204
View File
@@ -1,260 +1,312 @@
# Open Canvas 集成进度文档 # Open Canvas 集成进度文档
> 更新时间:2026-05-16 > 最后更新:2026-05-16
> 当前分支:dhk-1 > 当前分支:dhk-1
--- ---
## 一、整体进度概览 ## 一、整体进度概览
### Canvas 系统(双栏写作/编码工作区)
| 阶段 | 名称 | 状态 | | 阶段 | 名称 | 状态 |
|------|------|------| |------|------|------|
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 | | 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 | | 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) | | 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 | | 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 | | 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 前端阶段四 | API 适配层 | ✅ 已完成 | | 阶段六 | 前后端流式同步 | ✅ 已完成 |
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 | | 阶段七 | 版本历史导航 | ✅ 已完成 |
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 | | 阶段八 | 局部选区改写(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 前端路由接入 ### 2.1 前端核心文件结构
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
```tsx
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 在 PageLayout 内部添加:
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
```
子路由:
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
### 2.2 前端目录结构(已建立,26 个文件)
``` ```
frontend-web/src/open-canvas/ frontend-web/src/open-canvas/
api/ ├── api/
client.ts # stub,Phase 5 实现 │ ├── client.ts # LangGraph SDK 客户端实例
models.ts # stub,Phase 5 实现 │ ├── models.ts # 模型列表 API
threads.ts # stub,Phase 5 实现 │ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
runs.ts # stub,Phase 5 实现 │ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
components/ │ └── threads.ts # createCanvasThread、getCanvasThread
layout/ ├── components/
CanvasShell.tsx # 顶层布局壳,flex-col │ ├── artifact/
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示 │ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels) │ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
chat/ │ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框 │ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
artifact/ │ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器 │ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航 │ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航 │ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
CanvasEmptyState.tsx # 空状态:5 种快速开始模板 │ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown) │ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror) │ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
contexts/ │ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
CanvasThreadContext.tsx # threadId、modelName │ ├── chat/
CanvasGraphContext.tsx # messages、artifact、isStreaming │ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态 │ ├── layout/
hooks/ │ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
useCanvasStream.ts # stub,Phase 5 实现 │ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
useCanvasArtifact.ts # stub,Phase 6 实现 │ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
useCanvasSelection.ts # stub,Phase 7 实现 │ └── selectors/
useCanvasQuickActions.ts # stub,Phase 8 实现 │ └── CanvasModelSelector.tsx # 模型下拉选择器
pages/ ├── contexts/
CanvasHomePage.tsx # 欢迎页 + 快速开始入口 │ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell │ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
routes/ │ ├── CanvasThreadContext.tsx # threadId、modelName
OpenCanvasRoutes.tsx # React Router 子路由 │ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
types/ ├── editor/
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode │ ├── TiptapEditor.tsx # EditorContent 包装器
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata │ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
model.ts # CanvasModel(含 supports_canvas_* 能力字段) ├── hooks/
selection.ts # CanvasTextSelection、CanvasCodeSelection │ ├── useCanvasArtifact.ts # artifact 初始化工具
quick-action.ts # QuickAction、QuickActionId │ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
utils/ │ ├── useCanvasSelection.ts # 文字/代码选区状态
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具 │ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
selection.ts # stub │ ├── useCanvasThread.ts # 线程加载工具
thread.ts # stub │ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
stream.ts # stub │ ├── useWritingEditor.ts # Tiptap editor 实例管理
quick-actions.ts # stub │ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
constants/ ├── pages/
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_* │ ├── 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 offline-backend-20260512/backend/
export type CanvasArtifact = { ├── packages/harness/deerflow/agents/
currentIndex: number; // 从 1 开始 │ ├── canvas_agent/
contents: CanvasArtifactContent[]; │ │ ├── __init__.py
}; │ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
export type CanvasArtifactText = { │ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
index: number; type: "text"; title: string; fullMarkdown: string; │ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
}; └── app/gateway/
├── app.py # 注册 writing router
export type CanvasArtifactCode = { └── routers/
index: number; type: "code"; title: string; └── writing.py # POST /api/writing/rewrite(SSE 流式)
language: ProgrammingLanguageOptions; code: string;
};
``` ```
### 2.4 Context 结构 **langgraph.json** 注册了两个 graph:
- `lead_agent` → 主聊天 Agent
- `canvas_agent` → Canvas 写作 Agent
```tsx ### 2.3 关键 Context 层级
// CanvasThreadPage.tsx 的装配顺序
<CanvasThreadProvider initialThreadId={thread_id}> ```
<CanvasGraphProvider> CanvasThreadPage
<CanvasUIProvider> ├── CanvasThreadProvider # threadId、modelName
<CanvasShell /> │ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
</CanvasUIProvider> │ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
</CanvasGraphProvider> │ │ └── CanvasShell
</CanvasThreadProvider> │ │ └── 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 **解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
// ✅ 正确写法
import type { GroupImperativeHandle } from "react-resizable-panels";
const layoutRef = useRef<GroupImperativeHandle>(null);
<ResizablePanelGroup
orientation="horizontal" // ← 不是 direction
groupRef={layoutRef} // ← 不是 ref
>
<ResizablePanel id="chat" ... /> // ← 必须有 id
<ResizablePanel id="artifact" ... /> // ← 必须有 id
</ResizablePanelGroup>
// 切换折叠
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
```
### 3.2 Flex 高度链(防止底部内容被裁剪)
``` ```
PageLayout(h-screen) useCanvasStream(旧,已停用) ← 不再被调用,保留备用
└── main(flex-1) useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
└── 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.2 artifact 内容注入机制(重要)
### 3.3 折叠/展开按钮 **问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`, **解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
```tsx - 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
useEffect(() => { - 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
layoutRef.current?.setLayout( - 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
); ```typescript
}, [isChatCollapsed]); // utils/quick-actions.ts
export function buildQuickActionMessage(id, content) {
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
}
// core/messages/utils.ts
export function stripArtifactTag(content: string): string {
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
}
``` ```
### 3.3 ThinkTagParser(AI 改写思考过程解析)
后端支持两种思考格式:
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
### 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` | 主需求文档 | | react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 | | Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 | | Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 | | 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) | | 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 | | DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 | | loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
| `<artifact>` 标签在聊天面板里显示 | `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)| | `frontend-web/docs/openCanvas-progress.md` | **本文档** |
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 | | `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 | | `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models | | `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) | | `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 | | `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 项目。 我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
参考进度文档:frontend-web/docs/openCanvas-progress.md 详细进度见:frontend-web/docs/openCanvas-progress.md
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
请从第四节"下一步:后端 ThreadState 扩展"开始继续。 当前全部功能已完成,包括:
- Canvas 双栏布局、canvas_agent、版本历史
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
- 快捷动作(通过 CanvasStreamContext 统一流)
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
``` ```
+108 -100
View File
@@ -1,100 +1,108 @@
{ {
"name": "deer-flow-frontend-vite", "name": "deer-flow-frontend-vite",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@langchain/core": "^1.1.15", "@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3", "@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"@types/hast": "^3.0.4", "@tiptap/core": "^3.23.4",
"@uiw/codemirror-theme-basic": "^4.25.4", "@tiptap/extension-bubble-menu": "^3.23.4",
"@uiw/codemirror-theme-monokai": "^4.25.4", "@tiptap/extension-placeholder": "^3.23.4",
"@uiw/react-codemirror": "^4.25.4", "@tiptap/extension-underline": "^3.23.4",
"@xyflow/react": "^12.10.0", "@tiptap/pm": "^3.23.4",
"ai": "^6.0.33", "@tiptap/react": "^3.23.4",
"best-effort-json-parser": "^1.2.1", "@tiptap/starter-kit": "^3.23.4",
"canvas-confetti": "^1.9.4", "@types/hast": "^3.0.4",
"class-variance-authority": "^0.7.1", "@uiw/codemirror-theme-basic": "^4.25.4",
"clsx": "^2.1.1", "@uiw/codemirror-theme-monokai": "^4.25.4",
"cmdk": "^1.1.1", "@uiw/react-codemirror": "^4.25.4",
"codemirror": "^6.0.2", "@xyflow/react": "^12.10.0",
"date-fns": "^4.1.0", "ai": "^6.0.33",
"docx": "^9.5.1", "best-effort-json-parser": "^1.2.1",
"dotenv": "^17.2.3", "canvas-confetti": "^1.9.4",
"embla-carousel-react": "^8.6.0", "class-variance-authority": "^0.7.1",
"gsap": "^3.13.0", "clsx": "^2.1.1",
"hast": "^1.0.0", "cmdk": "^1.1.1",
"katex": "^0.16.28", "codemirror": "^6.0.2",
"lucide-react": "^0.562.0", "date-fns": "^4.1.0",
"motion": "^12.26.2", "docx": "^9.5.1",
"nanoid": "^5.1.6", "dotenv": "^17.2.3",
"next-themes": "^0.4.6", "embla-carousel-react": "^8.6.0",
"ogl": "^1.0.11", "gsap": "^3.13.0",
"react": "^19.0.0", "hast": "^1.0.0",
"react-dom": "^19.0.0", "katex": "^0.16.28",
"react-markdown": "^10.1.0", "lucide-react": "^0.562.0",
"react-resizable-panels": "^4.4.1", "motion": "^12.26.2",
"react-router-dom": "^7.9.4", "nanoid": "^5.1.6",
"react-syntax-highlighter": "^16.1.1", "next-themes": "^0.4.6",
"rehype-katex": "^7.0.1", "ogl": "^1.0.11",
"rehype-raw": "^7.0.0", "react": "^19.0.0",
"remark-gfm": "^4.0.1", "react-dom": "^19.0.0",
"remark-math": "^6.0.0", "react-markdown": "^10.1.0",
"shiki": "3.15.0", "react-resizable-panels": "^4.4.1",
"sonner": "^2.0.7", "react-router-dom": "^7.9.4",
"streamdown": "1.4.0", "react-syntax-highlighter": "^16.1.1",
"tailwind-merge": "^3.4.0", "rehype-katex": "^7.0.1",
"tdesign-react": "^1.12.3", "rehype-raw": "^7.0.0",
"tokenlens": "^1.3.1", "remark-gfm": "^4.0.1",
"unist-util-visit": "^5.0.0", "remark-math": "^6.0.0",
"use-stick-to-bottom": "^1.1.1", "shiki": "3.15.0",
"uuid": "^14.0.0", "sonner": "^2.0.7",
"zod": "^3.24.2" "streamdown": "1.4.0",
}, "tailwind-merge": "^3.4.0",
"devDependencies": { "tdesign-react": "^1.12.3",
"@tailwindcss/postcss": "^4.0.15", "tiptap-markdown": "^0.9.0",
"@types/gsap": "^3.0.0", "tokenlens": "^1.3.1",
"@types/node": "^20.14.10", "unist-util-visit": "^5.0.0",
"@types/react": "^19.0.0", "use-stick-to-bottom": "^1.1.1",
"@types/react-dom": "^19.0.0", "uuid": "^14.0.0",
"@vitejs/plugin-react": "^5.0.4", "zod": "^3.24.2"
"postcss": "^8.5.3", },
"tailwindcss": "^4.0.15", "devDependencies": {
"tw-animate-css": "^1.4.0", "@tailwindcss/postcss": "^4.0.15",
"typescript": "^5.8.2", "@types/gsap": "^3.0.0",
"vite": "^6.0.0", "@types/node": "^20.14.10",
"vite-plugin-source-identifier": "^1.1.2" "@types/react": "^19.0.0",
}, "@types/react-dom": "^19.0.0",
"packageManager": "pnpm@10.26.2" "@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"
}
@@ -28,6 +28,7 @@ import {
extractContentFromMessage, extractContentFromMessage,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
parseUploadedFiles, parseUploadedFiles,
stripArtifactTag,
stripUploadedFilesTag, stripUploadedFilesTag,
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
@@ -173,7 +174,7 @@ function MessageContent_({
const contentToDisplay = useMemo(() => { const contentToDisplay = useMemo(() => {
if (isHuman) { if (isHuman) {
return rawContent ? stripUploadedFilesTag(rawContent) : ""; return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
} }
return rawContent ?? ""; return rawContent ?? "";
}, [rawContent, isHuman]); }, [rawContent, isHuman]);
@@ -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;
}
+17
View File
@@ -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) { export function getLangGraphBaseURL(isMock?: boolean) {
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) { if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
return new URL( return new URL(
+45 -38
View File
@@ -14,20 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {} interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {} interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {} interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup = type MessageGroup =
| HumanMessageGroup | HumanMessageGroup
| AssistantProcessingGroup | AssistantProcessingGroup
| AssistantMessageGroup | AssistantMessageGroup
| AssistantPresentFilesGroup | AssistantPresentFilesGroup
| AssistantClarificationGroup | AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup | AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup; | AssistantSubagentGroup;
export function groupMessages<T>( export function groupMessages<T>(
messages: Message[], messages: Message[],
@@ -44,11 +44,11 @@ export function groupMessages<T>(
function lastOpenGroup() { function lastOpenGroup() {
const last = groups[groups.length - 1]; const last = groups[groups.length - 1];
if ( if (
last && last &&
last.type !== "human" && last.type !== "human" &&
last.type !== "assistant" && last.type !== "assistant" &&
last.type !== "assistant:clarification" && last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" last.type !== "assistant:setup-agent-approval"
) { ) {
return last; return last;
} }
@@ -70,23 +70,23 @@ export function groupMessages<T>(
} }
if (message.type === "tool") { if (message.type === "tool") {
if (isClarificationToolMessage(message)) { if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association, // Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display. // then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message); lastOpenGroup()?.messages.push(message);
groups.push({ groups.push({
id: message.id, id: message.id,
type: "assistant:clarification", type: "assistant:clarification",
messages: [message], messages: [message],
}); });
} else if (isSetupAgentApprovalToolMessage(message)) { } else if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message); lastOpenGroup()?.messages.push(message);
groups.push({ groups.push({
id: message.id, id: message.id,
type: "assistant:setup-agent-approval", type: "assistant:setup-agent-approval",
messages: [message], messages: [message],
}); });
} else { } else {
const open = lastOpenGroup(); const open = lastOpenGroup();
if (open) { if (open) {
open.messages.push(message); open.messages.push(message);
@@ -297,19 +297,19 @@ export function hasPresentFiles(message: Message) {
); );
} }
export function isClarificationToolMessage(message: Message) { export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification"; return message.type === "tool" && message.name === "ask_clarification";
} }
export function isSetupAgentApprovalToolMessage(message: Message) { export function isSetupAgentApprovalToolMessage(message: Message) {
return ( return (
message.type === "tool" && message.type === "tool" &&
message.name === "setup_agent" && message.name === "setup_agent" &&
typeof message.additional_kwargs?.setup_agent_approval === "object" && typeof message.additional_kwargs?.setup_agent_approval === "object" &&
message.additional_kwargs.setup_agent_approval !== null message.additional_kwargs.setup_agent_approval !== null
); );
} }
export function extractPresentFilesFromMessage(message: Message) { export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) { if (message.type !== "ai" || !hasPresentFiles(message)) {
return []; return [];
@@ -372,6 +372,13 @@ export function stripUploadedFilesTag(content: string): string {
.trim(); .trim();
} }
/** Strip injected <artifact ...>...</artifact> blocks from human message display. */
export function stripArtifactTag(content: string): string {
return content
.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "")
.trim();
}
export function parseUploadedFiles(content: string): FileInMessage[] { export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag // Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/; const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
+4
View File
@@ -4,6 +4,7 @@ function readEnv(
key: key:
| "VITE_BACKEND_BASE_URL" | "VITE_BACKEND_BASE_URL"
| "VITE_LANGGRAPH_BASE_URL" | "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY" | "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN", | "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue { ): EnvValue {
@@ -14,6 +15,9 @@ function readEnv(
export const env = { export const env = {
NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"), NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"),
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readEnv("VITE_LANGGRAPH_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"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
}; };
+108
View File
@@ -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<Uint8Array>): 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<RewriteStreamEvent> {
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<string, unknown>;
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<string, unknown>;
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
}
}
}
@@ -5,20 +5,76 @@ import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { CanvasArtifactText } from "../../types/artifact"; 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 { interface TextArtifactRendererProps {
content: CanvasArtifactText; content: CanvasArtifactText;
isStreaming?: boolean; 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) { export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
const { handleTextPointerUp } = useCanvasSelection(); const { artifact, setArtifact } = useCanvasGraphContext();
const { writingSelection } = useCanvasUIContext();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false); const [showScrollBtn, setShowScrollBtn] = useState(false);
const autoScrollRef = useRef(true); const autoScrollRef = useRef(true);
// Auto-scroll to bottom while streaming, unless user has scrolled up // ── AI rewrite flow state ──────────────────────────────────────────────
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(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(() => { useEffect(() => {
if (!isStreaming) { if (!isStreaming) {
autoScrollRef.current = true; autoScrollRef.current = true;
@@ -46,7 +102,70 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
setShowScrollBtn(false); 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 ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -56,142 +175,145 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
); );
} }
return ( // ── Streaming mode ────────────────────────────────────────────────────
<div className="relative h-full"> if (isStreaming) {
<div return (
ref={scrollRef} <div className="relative h-full flex flex-col overflow-hidden">
className="h-full overflow-y-auto" <div
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)} ref={scrollRef}
onScroll={handleScroll} className="flex-1 overflow-y-auto"
> onScroll={handleScroll}
<div className="max-w-3xl mx-auto px-8 py-8"> >
<ReactMarkdown <div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
remarkPlugins={[remarkGfm]} <ReactMarkdown
components={{ remarkPlugins={[remarkGfm]}
h1: ({ children }) => ( components={{
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2"> h1: ({ children }) => (
{children} <h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
</h1> {children}
), </h1>
h2: ({ children }) => ( ),
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground"> h2: ({ children }) => (
{children} <h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
</h2> {children}
), </h2>
h3: ({ children }) => ( ),
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground"> h3: ({ children }) => (
{children} <h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
</h3> {children}
), </h3>
h4: ({ children }) => ( ),
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground"> p: ({ children }) => (
{children} <p className="mb-4 leading-[1.8] text-[15px] text-foreground">
</h4> {children}
), </p>
p: ({ children }) => ( ),
<p className="mb-4 leading-7 text-sm text-foreground/90"> ul: ({ children }) => (
{children} <ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
</p> {children}
), </ul>
ul: ({ children }) => ( ),
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90"> ol: ({ children }) => (
{children} <ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
</ul> {children}
), </ol>
ol: ({ children }) => ( ),
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90"> li: ({ children }) => <li className="leading-7">{children}</li>,
{children} blockquote: ({ children }) => (
</ol> <blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
), {children}
li: ({ children }) => ( </blockquote>
<li className="leading-7">{children}</li> ),
), pre: ({ children }) => (
blockquote: ({ children }) => ( <pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm"> {children}
{children} </pre>
</blockquote> ),
), code: ({ className, children }) => {
pre: ({ children }) => ( const isBlock = Boolean(className?.startsWith("language-"));
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto"> return isBlock ? (
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
if (isBlock) {
return (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed"> <code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children} {children}
</code> </code>
) : (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
); );
} },
return ( }}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground"> >
{children} {content.fullMarkdown}
</code> </ReactMarkdown>
);
},
table: ({ children }) => (
<div className="mb-4 overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm border-collapse">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">{children}</thead>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
{children}
</td>
),
hr: () => <hr className="my-6 border-border" />,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-foreground/80">{children}</em>
),
}}
>
{content.fullMarkdown}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" /> <span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
</div>
</div>
{showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div>
);
}
// ── Edit mode ─────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)} )}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div> </div>
</div> </div>
{isStreaming && showScrollBtn && ( {/* Phase: AI action menu */}
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none"> {phase === "menu" && (
<Button <WritingAiMenu
size="sm" pos={panelPos}
variant="secondary" status={status}
className="pointer-events-auto shadow-md gap-1.5" error={error}
onClick={handleScrollToBottom} onAction={handleAction}
> onAbort={reset}
<ArrowDown className="w-3.5 h-3.5" /> onRetry={handleRetry}
回到底部 onClose={closeAll}
</Button> />
</div> )}
{/* Phase: result preview */}
{phase === "preview" && (
<WritingRewritePreview
pos={panelPos}
status={status}
originalText={writingSelection?.text ?? ""}
result={result}
thinking={thinking}
error={error}
action={lastActionRef.current?.action ?? "polish"}
tone={lastActionRef.current?.tone}
onReplace={handleReplace}
onInsert={handleInsert}
onRetry={handleRetry}
onAbort={reset}
onClose={closeAll}
onFollowUp={handleFollowUp}
/>
)} )}
</div> </div>
); );
@@ -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<HTMLInputElement>(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<HTMLDivElement>(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 */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 4),
width: menuWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
AI 改写
</span>
<button
type="button"
onClick={onClose}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* ── Loading state ──────────────────────────────────────── */}
{isLoading && (
<div className="flex flex-col items-center gap-4 px-4 py-6">
{/* Bouncing dots */}
<div className="flex items-center gap-2">
{[0, 160, 320].map((delay) => (
<span
key={delay}
className="h-1.5 w-1.5 rounded-full bg-primary animate-bouncing"
style={{ animationDelay: `${delay}ms` }}
/>
))}
<span className="ml-1 text-xs text-muted-foreground">AI 正在生成…</span>
</div>
{/* Stop button — breathing animation */}
<button
type="button"
onClick={onAbort}
className={cn(
"animate-breathe-stop",
"flex items-center gap-1.5 px-5 py-2 text-xs font-medium rounded-lg",
"border border-red-300/70 bg-red-50/60 text-red-600",
"dark:border-red-800/50 dark:bg-red-950/20 dark:text-red-400",
"hover:bg-red-100/90 dark:hover:bg-red-950/40 transition-colors",
)}
>
<Square className="h-3 w-3 fill-current" />
停止生成
</button>
</div>
)}
{/* ── Error state ─────────────────────────────────────────── */}
{isError && (
<div className="px-3 py-3 space-y-2">
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error ?? "改写失败"}
</p>
<button
type="button"
onClick={onRetry}
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<RefreshCw className="h-3 w-3" />
重试
</button>
</div>
)}
{/* ── Idle state: input + action list ─────────────────────── */}
{!isLoading && !isError && (
<>
{/* Custom instruction input */}
<div className="px-2.5 pt-2.5 pb-2">
<input
ref={inputRef}
type="text"
value={instruction}
onChange={(e) => 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",
)}
/>
</div>
{/* Divider */}
<div className="border-t border-border/50 mx-1" />
{/* Preset actions */}
<div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone";
const isTonesOpen = isTone && toneOpen;
return (
<div key={action}>
<button
type="button"
onClick={() => {
if (isTone) {
setToneOpen((v) => !v);
} else {
fireAction(action);
}
}}
className={cn(
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
isTonesOpen && "bg-accent text-accent-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{label}</span>
{isTone && (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isTonesOpen && "rotate-90",
)}
/>
)}
</button>
{/* Tone sub-list */}
{isTonesOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button
key={value}
type="button"
onClick={() => fireAction("tone", value)}
className="flex w-full items-center px-2 py-1.5 text-xs rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{toneLabel}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
</>,
document.body,
);
}
@@ -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 (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // keep editor focused
onClick();
}}
className={cn(
"inline-flex h-7 items-center gap-1 px-2 text-xs font-medium rounded transition-colors",
"hover:bg-black/8 dark:hover:bg-white/10",
active && "bg-black/10 dark:bg-white/15",
className,
)}
>
{children}
</button>
);
}
function BubbleSep() {
return <div className="w-px h-4 bg-black/15 dark:bg-white/25 mx-0.5 shrink-0" />;
}
// ── Main component ───────────────────────────────────────────────────────────
interface WritingBubbleMenuProps {
editor: Editor;
onAiRewrite: () => void;
}
export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) {
const { setWritingSelection } = useCanvasUIContext();
const [position, setPosition] = useState<MenuPosition | null>(null);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [computedStyle, setComputedStyle] = useState<React.CSSProperties>({
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(
<div
ref={menuRef}
style={computedStyle}
className="animate-in fade-in zoom-in-95 duration-100"
>
<div className="flex items-center gap-0 rounded-lg px-1 py-1 shadow-lg ring-1 ring-black/8 bg-popover text-foreground dark:bg-neutral-900 dark:ring-white/10">
{/* AI 改写 — primary */}
<BubbleButton
onClick={handleAiRewrite}
className="text-amber-500 hover:text-amber-600 font-semibold"
>
<Sparkles className="h-3.5 w-3.5" />
AI 改写
</BubbleButton>
<BubbleSep />
{/* Inline format */}
<BubbleButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
>
<Bold className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
>
<Italic className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive("underline")}
>
<Underline className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Heading */}
<BubbleButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
active={editor.isActive("heading", { level: 1 })}
>
<Heading1 className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Copy */}
<BubbleButton onClick={handleCopy}>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</BubbleButton>
</div>
</div>,
document.body,
);
}
@@ -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<HoverState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep a ref in sync so the document-level handler always reads fresh state
const hoverRef = useRef<HoverState | null>(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(
<button
type="button"
style={{
position: "fixed",
top: btnTop,
left: btnLeft,
width: BTN_SIZE,
height: BTN_SIZE,
zIndex: 9998,
}}
onMouseEnter={clearHideTimer}
onMouseLeave={scheduleHide}
onClick={handleClick}
className={cn(
"inline-flex items-center justify-center rounded-md",
"bg-background border border-border shadow-sm",
"text-muted-foreground hover:text-amber-500 hover:border-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30",
"transition-all duration-150",
"animate-in fade-in zoom-in-90 duration-100",
)}
title="AI 改写此段落"
>
<Sparkles className="h-3.5 w-3.5" />
</button>,
document.body,
);
}
@@ -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<RewriteAction, string> = {
polish: "润色",
expand: "扩写",
shorten: "缩写",
tone: "改语气",
};
const TONE_LABEL: Record<RewriteTone, string> = {
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<HTMLInputElement>(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<RewriteStatus>(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<HTMLDivElement>(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 */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 6),
width: panelWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/40">
<span className="text-xs font-semibold text-foreground">{actionLabel}</span>
<div className="flex items-center gap-1">
{isLoading && (
<button
type="button"
onClick={onAbort}
className="rounded p-1 hover:bg-red-100 dark:hover:bg-red-950/40 text-red-500 hover:text-red-600 transition-colors"
title="停止生成"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
)}
<button
type="button"
onClick={onRetry}
disabled={isLoading}
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
title="重试"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onClose}
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Body */}
<div className="p-3 space-y-2 max-h-96 overflow-y-auto">
{/* Thinking — collapsible with animation */}
{thinking && (
<div className="rounded-md border border-amber-200/60 bg-amber-50/40 dark:border-amber-800/40 dark:bg-amber-950/20 overflow-hidden">
{/* Toggle button */}
<button
type="button"
onClick={() => setThinkingOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-[10px] font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100/40 dark:hover:bg-amber-900/20 transition-colors"
>
<Brain className="h-3 w-3 shrink-0" />
<span className="flex-1 text-left">思考过程</span>
{isLoading && (
<span className="text-[9px] text-amber-600/70 dark:text-amber-500/60 mr-1">
思考中…
</span>
)}
<ChevronDown
className={cn(
"h-3 w-3 transition-transform duration-300",
thinkingOpen && "rotate-180",
)}
/>
</button>
{/* Animated content — grid-rows trick for smooth height transition */}
<div
style={{ gridTemplateRows: thinkingOpen ? "1fr" : "0fr" }}
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
>
<div className="overflow-hidden">
<div className="px-3 pb-2.5 pt-1.5 text-[11px] text-amber-800/80 dark:text-amber-300/70 leading-relaxed whitespace-pre-wrap border-t border-amber-200/40 dark:border-amber-800/30 max-h-44 overflow-y-auto">
{thinking}
{isLoading && (
<span className="inline-block w-0.5 h-3 bg-amber-500 animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
</div>
</div>
)}
{/* Result area */}
{/* Loading — no text yet */}
{isLoading && !hasResult && (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
AI 正在生成…
</div>
)}
{/* Error */}
{isError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive leading-relaxed">
{error ?? "改写失败,请重试"}
</div>
)}
{/* Result text — show while streaming and after success */}
{hasResult && (
<div className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground leading-relaxed whitespace-pre-wrap">
{result}
{isLoading && (
<span className="inline-block w-0.5 h-3.5 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
)}
</div>
{/* Footer actions — only after success */}
{isSuccess && (
<div className="flex items-center gap-2 px-3 py-2 border-t bg-muted/20">
<Button size="sm" className="flex-1 h-7 text-xs gap-1.5" onClick={onReplace}>
<ArrowRightLeft className="h-3 w-3" />
替换原文
</Button>
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs gap-1.5" onClick={onInsert}>
<ArrowDownToLine className="h-3 w-3" />
插入后面
</Button>
</div>
)}
{/* Follow-up chat input — shown after success */}
{isSuccess && (
<div className="flex items-center gap-2 px-3 py-2 border-t bg-background">
<input
ref={followUpInputRef}
type="text"
value={followUpInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleFollowUpSubmit}
disabled={!followUpInput.trim()}
className="shrink-0 rounded-md p-1.5 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
>
<SendHorizontal className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</>,
document.body,
);
}
@@ -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>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // prevent editor losing focus
onClick();
}}
disabled={disabled}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
active && "bg-accent text-accent-foreground font-semibold",
)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
);
}
interface WritingToolbarProps {
editor: Editor | null;
}
export function WritingToolbar({ editor }: WritingToolbarProps) {
if (!editor) return null;
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */}
<ToolbarButton
tooltip="正文"
active={editor.isActive("paragraph")}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<Pilcrow className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="一级标题"
active={editor.isActive("heading", { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="二级标题"
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="三级标题"
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3 className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Lists */}
<ToolbarButton
tooltip="无序列表"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="有序列表"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Blocks */}
<ToolbarButton
tooltip="引用"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="代码块"
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
<Code className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Inline marks */}
<ToolbarButton
tooltip="加粗 (Ctrl+B)"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="斜体 (Ctrl+I)"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="下划线 (Ctrl+U)"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<Underline className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="删除线"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
{/* Model selector — pushed to the right */}
<div className="ml-auto">
<CanvasModelSelector />
</div>
</div>
);
}
@@ -1,16 +1,47 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendHorizonal, Sparkles, X } from "lucide-react"; import {
import { useCallback, useState, type KeyboardEvent } from "react"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { Textarea } from "@/components/ui/textarea";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasStreamContext } from "../../contexts/CanvasStreamContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection"; import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact"; import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import { import {
buildMessageWithSelection, buildMessageWithSelection,
@@ -18,6 +49,19 @@ import {
resolveCodeSelection, resolveCodeSelection,
} from "../../utils/selection"; } from "../../utils/selection";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type CanvasMode = "flash" | "thinking" | "pro" | "ultra";
const MODE_LABELS: Record<CanvasMode, string> = {
flash: "快速",
thinking: "推理",
pro: "深入",
ultra: "超级",
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ChatWelcome // ChatWelcome
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,20 +84,48 @@ function ChatWelcome() {
); );
} }
// ---------------------------------------------------------------------------
// ModeIcon
// ---------------------------------------------------------------------------
function ModeIcon({
mode,
className,
}: {
mode: CanvasMode;
className?: string;
}) {
if (mode === "flash") return <ZapIcon className={className} />;
if (mode === "thinking") return <LightbulbIcon className={className} />;
if (mode === "pro") return <GraduationCapIcon className={className} />;
return <RocketIcon className={cn(className, "text-[#dabb5e]")} />;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CanvasChatPanel // CanvasChatPanel
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CanvasChatPanel() { export function CanvasChatPanel() {
const { artifact } = useCanvasGraphContext(); const { artifact } = useCanvasGraphContext();
const { thread, sendMessage, threadId } = useCanvasThreadStream(); const { thread, sendMessage, threadId } = useCanvasStreamContext();
const { modelName, setModelName } = useCanvasThreadContext();
const { selection, clearSelection } = useCanvasSelection(); const { selection, clearSelection } = useCanvasSelection();
const { models } = useModels();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [mode, setMode] = useState<CanvasMode>("pro");
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const hasSelection = hasActiveSelection(selection); const hasSelection = hasActiveSelection(selection);
const isStreaming = thread.isLoading; const isStreaming = thread.isLoading;
const hasMessages = thread.messages.length > 0; 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 // Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact); const currentContent = getCurrentArtifactContent(artifact);
const resolvedCode = const resolvedCode =
@@ -80,8 +152,12 @@ export function CanvasChatPanel() {
})(); })();
async function handleSend() { async function handleSend() {
if (isStreaming) {
await thread.stop();
return;
}
const text = input.trim(); const text = input.trim();
if (!text || isStreaming) return; if (!text) return;
setInput(""); setInput("");
const finalText = hasSelection const finalText = hasSelection
@@ -89,7 +165,7 @@ export function CanvasChatPanel() {
: text; : text;
clearSelection(); clearSelection();
await sendMessage(finalText); await sendMessage(finalText, { mode });
} }
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
@@ -101,9 +177,22 @@ export function CanvasChatPanel() {
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
async (answer: string) => { 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<AgentThreadState>. The canvas stream returns // MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
@@ -128,31 +217,32 @@ export function CanvasChatPanel() {
)} )}
</div> </div>
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2 shrink-0">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Input area */} {/* Input area */}
<div className="border-t p-3 shrink-0"> <div className="p-3 shrink-0">
<div className="flex gap-2 items-end"> <div className="rounded-2xl border border-border/70 bg-background shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] overflow-hidden transition-shadow focus-within:shadow-[0_2px_16px_-4px_rgba(0,0,0,0.14)] dark:shadow-none dark:focus-within:ring-1 dark:focus-within:ring-border">
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2.5">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Textarea */}
<Textarea <Textarea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
@@ -160,20 +250,157 @@ export function CanvasChatPanel() {
placeholder={ placeholder={
hasSelection hasSelection
? "针对选中内容发出指令…" ? "针对选中内容发出指令…"
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)" : "发消息…"
} }
className="min-h-[40px] max-h-[160px] resize-none text-sm" className="border-0 shadow-none rounded-none resize-none text-sm min-h-[60px] max-h-[160px] focus-visible:ring-0 px-3 pt-3 pb-2"
rows={1} rows={2}
disabled={isStreaming} disabled={isStreaming}
/> />
<Button
size="icon" {/* Toolbar */}
className="h-9 w-9 shrink-0" <div className="flex items-center gap-1 px-2 pb-2 pt-1 border-t border-border/40">
disabled={!input.trim() || isStreaming} {/* Mode selector */}
onClick={() => void handleSend()} <DropdownMenu>
> <DropdownMenuTrigger asChild>
<SendHorizonal className="h-4 w-4" /> <Button
</Button> variant="ghost"
size="sm"
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60"
>
<ModeIcon mode={mode} className="h-3 w-3" />
<span>{MODE_LABELS[mode]}</span>
<ChevronDownIcon className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
推理模式
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "flash" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("flash")}
>
<ZapIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">快速</span>
<span className="text-xs opacity-70">低延迟,即时响应</span>
</div>
{mode === "flash" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
{supportsThinking && (
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "thinking" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("thinking")}
>
<LightbulbIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">推理</span>
<span className="text-xs opacity-70">思维链推导</span>
</div>
{mode === "thinking" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "pro" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("pro")}
>
<GraduationCapIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">深入</span>
<span className="text-xs opacity-70">深度分析与研究</span>
</div>
{mode === "pro" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "ultra" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("ultra")}
>
<RocketIcon className={cn("h-3.5 w-3.5", mode === "ultra" && "text-[#dabb5e]")} />
<div className="flex flex-col flex-1">
<span className={cn("font-medium", mode === "ultra" && "text-[#dabb5e]")}>超级</span>
<span className="text-xs opacity-70">全力以赴,最强推理</span>
</div>
{mode === "ultra" && <CheckIcon className="h-3.5 w-3.5 ml-auto text-[#dabb5e]" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Model selector */}
{models.length > 0 && (
<ModelSelector open={modelDialogOpen} onOpenChange={setModelDialogOpen}>
<ModelSelectorTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60 max-w-[120px]"
>
<ModelSelectorName className="truncate text-xs font-normal">
{selectedModel?.display_name ?? "选择模型"}
</ModelSelectorName>
<ChevronDownIcon className="h-3 w-3 shrink-0 opacity-60" />
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent title="选择模型">
<ModelSelectorInput placeholder="搜索模型…" />
<ModelSelectorList>
{models.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
onSelect={() => handleModelSelect(m.name)}
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{m.model}
</span>
</div>
{m.name === modelName ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Send / Stop button */}
<Button
size="icon"
variant={isStreaming ? "secondary" : "default"}
className={cn(
"h-7 w-7 shrink-0 rounded-full",
isStreaming && "text-muted-foreground",
)}
onClick={() => void handleSend()}
disabled={!isStreaming && !input.trim()}
>
{isStreaming ? (
<Square className="h-3 w-3 fill-current" />
) : (
<SendHorizonal className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -8,6 +8,7 @@ import {
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel"; import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel"; import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
@@ -25,6 +26,7 @@ export function CanvasResizableLayout() {
}, [isChatCollapsed]); }, [isChatCollapsed]);
return ( return (
<CanvasStreamProvider>
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
orientation="horizontal" orientation="horizontal"
@@ -54,5 +56,6 @@ export function CanvasResizableLayout() {
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
</CanvasStreamProvider>
); );
} }
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { getCanvasThread } from "../../api/threads"; import { getCanvasThread } from "../../api/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext"; import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CanvasHeader } from "./CanvasHeader"; import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout"; import { CanvasResizableLayout } from "./CanvasResizableLayout";
@@ -42,9 +43,11 @@ export function CanvasShell() {
}, [threadId, setMessages, setArtifact, setThreadId]); }, [threadId, setMessages, setArtifact, setThreadId]);
return ( return (
<div className="h-full flex flex-col overflow-hidden bg-background"> <TooltipProvider delayDuration={300}>
<CanvasHeader /> <div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasResizableLayout /> <CanvasHeader />
</div> <CanvasResizableLayout />
</div>
</TooltipProvider>
); );
} }
@@ -0,0 +1,24 @@
import { createContext, useContext, type ReactNode } from "react";
import { useCanvasThreadStream } from "../hooks/useCanvasThreadStream";
type CanvasStreamContextValue = ReturnType<typeof useCanvasThreadStream>;
const CanvasStreamContext = createContext<CanvasStreamContextValue | null>(null);
export function CanvasStreamProvider({ children }: { children: ReactNode }) {
const value = useCanvasThreadStream();
return (
<CanvasStreamContext.Provider value={value}>
{children}
</CanvasStreamContext.Provider>
);
}
export function useCanvasStreamContext() {
const ctx = useContext(CanvasStreamContext);
if (!ctx)
throw new Error(
"useCanvasStreamContext must be used within CanvasStreamProvider",
);
return ctx;
}
@@ -10,6 +10,7 @@ import type {
CanvasCodeSelection, CanvasCodeSelection,
CanvasSelectionState, CanvasSelectionState,
CanvasTextSelection, CanvasTextSelection,
WritingSelection,
} from "../types/selection"; } from "../types/selection";
export interface CanvasUIContextValue { export interface CanvasUIContextValue {
@@ -17,6 +18,8 @@ export interface CanvasUIContextValue {
isEditing: boolean; isEditing: boolean;
isWebSearchOpen: boolean; isWebSearchOpen: boolean;
selection: CanvasSelectionState; selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void; setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void; setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void; setIsWebSearchOpen: (v: boolean) => void;
@@ -25,6 +28,7 @@ export interface CanvasUIContextValue {
/** Set code selection; clears any existing text selection. */ /** Set code selection; clears any existing text selection. */
setCodeSelection: (s: CanvasCodeSelection | null) => void; setCodeSelection: (s: CanvasCodeSelection | null) => void;
clearSelection: () => void; clearSelection: () => void;
setWritingSelection: (s: WritingSelection | null) => void;
} }
const EMPTY_SELECTION: CanvasSelectionState = { const EMPTY_SELECTION: CanvasSelectionState = {
@@ -40,6 +44,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false); const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [selection, setSelection] = const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION); useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
useState<WritingSelection | null>(null);
const setTextSelection = useCallback((s: CanvasTextSelection | null) => { const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
setSelection({ textSelection: s, codeSelection: null }); setSelection({ textSelection: s, codeSelection: null });
@@ -56,12 +62,14 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isEditing, isEditing,
isWebSearchOpen, isWebSearchOpen,
selection, selection,
writingSelection,
setIsChatCollapsed, setIsChatCollapsed,
setIsEditing, setIsEditing,
setIsWebSearchOpen, setIsWebSearchOpen,
setTextSelection, setTextSelection,
setCodeSelection, setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION), clearSelection: () => setSelection(EMPTY_SELECTION),
setWritingSelection,
}} }}
> >
{children} {children}
@@ -0,0 +1,79 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
interface HighlightRange {
from: number;
to: number;
}
const aiHighlightKey = new PluginKey<HighlightRange | null>("aiHighlight");
// Augment Tiptap's Commands interface so TS knows about our custom commands
declare module "@tiptap/core" {
interface Commands<ReturnType> {
aiHighlight: {
setAiHighlight: (from: number, to: number) => ReturnType;
clearAiHighlight: () => ReturnType;
};
}
}
export const AiHighlightExtension = Extension.create({
name: "aiHighlight",
addCommands() {
return {
setAiHighlight:
(from: number, to: number) =>
({ dispatch, tr }) => {
if (dispatch) dispatch(tr.setMeta(aiHighlightKey, { from, to }));
return true;
},
clearAiHighlight:
() =>
({ dispatch, tr }) => {
if (dispatch) dispatch(tr.setMeta(aiHighlightKey, null));
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin<HighlightRange | null>({
key: aiHighlightKey,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(aiHighlightKey);
// explicit set / clear via command
if (meta !== undefined) return meta as HighlightRange | null;
// map positions through doc changes so highlight survives edits
if (tr.docChanged && prev) {
return {
from: tr.mapping.map(prev.from),
to: tr.mapping.map(prev.to),
};
}
return prev;
},
},
props: {
decorations(state) {
const range = aiHighlightKey.getState(state);
if (!range) return DecorationSet.empty;
const { from, to } = range;
const docSize = state.doc.content.size;
if (from >= to || from < 0 || to > docSize) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.inline(from, to, { class: "ai-rewrite-highlight" }),
]);
},
},
}),
];
},
});
@@ -0,0 +1,11 @@
import type { Editor } from "@tiptap/react";
import { EditorContent } from "@tiptap/react";
interface TiptapEditorProps {
editor: Editor | null;
className?: string;
}
export function TiptapEditor({ editor, className }: TiptapEditorProps) {
return <EditorContent editor={editor} className={className} />;
}
@@ -0,0 +1,23 @@
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { AiHighlightExtension } from "./AiHighlightExtension";
export const buildExtensions = (placeholder = "开始写作…") => [
StarterKit.configure({
// Disable hard break so Enter creates new paragraph
hardBreak: false,
}),
Underline,
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty",
}),
Markdown.configure({
html: false,
transformCopiedText: true,
transformPastedText: true,
}),
AiHighlightExtension,
];
@@ -1,13 +1,13 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext"; import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasStreamContext } from "../contexts/CanvasStreamContext";
import type { QuickActionId } from "../types/quick-action"; import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact"; import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions"; import { buildQuickActionMessage } from "../utils/quick-actions";
import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() { export function useCanvasQuickActions() {
const { sendMessage } = useCanvasStream(); const { sendMessage } = useCanvasStreamContext();
const { artifact } = useCanvasGraphContext(); const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback( const triggerQuickAction = useCallback(
@@ -15,7 +15,7 @@ export function useCanvasQuickActions() {
const content = getCurrentArtifactContent(artifact); const content = getCurrentArtifactContent(artifact);
if (!content) return; if (!content) return;
const message = buildQuickActionMessage(actionId); const message = buildQuickActionMessage(actionId, content);
await sendMessage(message, { quick_action_id: actionId }); await sendMessage(message, { quick_action_id: actionId });
}, },
[sendMessage, artifact], [sendMessage, artifact],
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAPIClient } from "@/core/api"; import { getCanvasAPIClient } from "@/core/api/canvas-client";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants"; import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
@@ -61,7 +61,7 @@ export function useCanvasThreadStream() {
}, [threadId]); }, [threadId]);
const thread = useStream<CanvasThreadValues>({ const thread = useStream<CanvasThreadValues>({
client: getAPIClient(), client: getCanvasAPIClient(),
assistantId: CANVAS_AGENT_ID, assistantId: CANVAS_AGENT_ID,
threadId: onStreamThreadId, threadId: onStreamThreadId,
fetchStateHistory: { limit: 1 }, fetchStateHistory: { limit: 1 },
@@ -70,7 +70,7 @@ export function useCanvasThreadStream() {
threadIdRef.current = meta.thread_id; threadIdRef.current = meta.thread_id;
setThreadId(meta.thread_id); setThreadId(meta.thread_id);
navigate(`/page/canvas/${meta.thread_id}`, { replace: true }); navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
void getAPIClient() void getCanvasAPIClient()
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA }) .threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
.catch(() => {}); .catch(() => {});
}, },
@@ -183,8 +183,10 @@ export function useCanvasThreadStream() {
}, },
]); ]);
// Pass current artifact info so the backend can instruct the LLM to // Inject current artifact content into the human message so the LLM can see
// continue editing the same artifact instead of creating a new one. // what to edit. create_agent only passes state.messages to the LLM — state.artifact
// is NOT automatically visible. Skip injection when the caller already embedded
// the artifact (e.g. quick actions build the full message themselves).
const currentContent = artifact ? getCurrentArtifactContent(artifact) : null; const currentContent = artifact ? getCurrentArtifactContent(artifact) : null;
const artifactContext = currentContent const artifactContext = currentContent
? { ? {
@@ -193,11 +195,26 @@ export function useCanvasThreadStream() {
} }
: {}; : {};
const messageText =
currentContent && !trimmed.includes("<artifact")
? (() => {
const body =
currentContent.type === "code"
? currentContent.code
: currentContent.fullMarkdown;
const tag =
currentContent.type === "code"
? `language="${currentContent.language}"`
: "";
return `${trimmed}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
})()
: trimmed;
try { try {
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
{ type: "human", content: [{ type: "text", text: trimmed }] }, { type: "human", content: [{ type: "text", text: messageText }] },
], ],
}, },
{ {
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useEditor } from "@tiptap/react";
import { buildExtensions } from "../editor/TiptapExtensions";
interface UseWritingEditorOptions {
markdown: string;
onChange?: (markdown: string) => void;
editable?: boolean;
placeholder?: string;
}
export function useWritingEditor({
markdown,
onChange,
editable = true,
placeholder,
}: UseWritingEditorOptions) {
const prevMarkdownRef = useRef<string>(markdown);
const editor = useEditor({
extensions: buildExtensions(placeholder),
editable,
content: markdown,
onUpdate({ editor }) {
if (!onChange) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
onChange(md);
},
});
// Sync external markdown changes (e.g. after streaming completes)
useEffect(() => {
if (!editor) return;
if (markdown === prevMarkdownRef.current) return;
prevMarkdownRef.current = markdown;
const { from, to } = editor.state.selection;
editor.commands.setContent(markdown, { emitUpdate: false });
const docSize = editor.state.doc.content.size;
if (from <= docSize && to <= docSize) {
editor.commands.setTextSelection({ from, to });
}
}, [editor, markdown]);
// Sync editable flag
useEffect(() => {
if (!editor) return;
editor.setEditable(editable);
}, [editor, editable]);
return editor;
}
@@ -0,0 +1,206 @@
import { useCallback, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import {
streamRewrite,
type RewriteAction,
type RewriteRequest,
type RewriteTone,
} from "../api/rewrite";
import { useCanvasUIContext } from "../contexts/CanvasUIContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
export type RewriteStatus = "idle" | "loading" | "success" | "error";
// ── <think>…</think> tag stripper ────────────────────────────────────────────
// Handles tags split across multiple streaming chunks.
class ThinkTagParser {
private buf = "";
private inside = false;
push(chunk: string): { thinking: string; text: string } {
this.buf += chunk;
let thinking = "";
let text = "";
// eslint-disable-next-line no-constant-condition
while (true) {
if (!this.inside) {
const idx = this.buf.indexOf("<think>");
if (idx !== -1) {
text += this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 7);
this.inside = true;
} else {
// hold last 6 chars to guard against a partial opening tag
const safe = Math.max(0, this.buf.length - 6);
text += this.buf.slice(0, safe);
this.buf = this.buf.slice(safe);
break;
}
} else {
const idx = this.buf.indexOf("</think>");
if (idx !== -1) {
thinking += this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 8);
this.inside = false;
} else {
// hold last 7 chars to guard against a partial closing tag
const safe = Math.max(0, this.buf.length - 7);
thinking += this.buf.slice(0, safe);
this.buf = this.buf.slice(safe);
break;
}
}
}
return { thinking, text };
}
flush(): { thinking: string; text: string } {
const out = {
thinking: this.inside ? this.buf : "",
text: this.inside ? "" : this.buf,
};
this.buf = "";
return out;
}
}
// ─────────────────────────────────────────────────────────────────────────────
export function useWritingRewrite(editor: Editor | null) {
const { writingSelection } = useCanvasUIContext();
const { threadId, modelName } = useCanvasThreadContext();
const [status, setStatus] = useState<RewriteStatus>("idle");
const [result, setResult] = useState<string | null>(null);
const [thinking, setThinking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Shared streaming runner — state setters from useState are stable references
const runStream = useCallback(
async (req: RewriteRequest, abort: AbortController) => {
const parser = new ThinkTagParser();
const applyParsed = (parsed: { thinking: string; text: string }) => {
if (parsed.thinking) setThinking((p) => (p ?? "") + parsed.thinking);
if (parsed.text) setResult((p) => (p ?? "") + parsed.text);
};
try {
let terminated = false;
for await (const event of streamRewrite(req, abort.signal)) {
if (abort.signal.aborted) break;
if (event.type === "thinking") {
setThinking((p) => (p ?? "") + event.chunk);
} else if (event.type === "text") {
applyParsed(parser.push(event.chunk));
} else if (event.type === "done") {
applyParsed(parser.flush());
setStatus("success");
terminated = true;
break;
} else if (event.type === "error") {
setError(event.message ?? "改写失败,请重试");
setStatus("error");
terminated = true;
break;
}
}
if (!terminated && !abort.signal.aborted) {
applyParsed(parser.flush());
setStatus("success");
}
} catch (e) {
if (abort.signal.aborted) return;
setError(e instanceof Error ? e.message : "改写失败,请重试");
setStatus("error");
}
},
[], // eslint-disable-line react-hooks/exhaustive-deps
);
const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
if (!writingSelection || !threadId || !editor) return;
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const { from, to, text } = writingSelection;
const docText = editor.state.doc.textContent;
const docSize = editor.state.doc.content.size;
const req: RewriteRequest = {
threadId,
selection: { from, to, text },
action,
tone,
modelName: modelName ?? undefined,
surroundingContext: {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
customInstruction: customInstruction || undefined,
};
setStatus("loading");
setResult(null);
setThinking(null);
setError(null);
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, threadId, modelName, editor, runStream],
);
const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
if (!writingSelection || !threadId || !editor) return;
const prevResult = result;
if (!prevResult) return;
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const { from, to, text } = writingSelection;
const docText = editor.state.doc.textContent;
const docSize = editor.state.doc.content.size;
const req: RewriteRequest = {
threadId,
selection: { from, to, text },
action,
tone,
modelName: modelName ?? undefined,
surroundingContext: {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
followUpInstruction: instruction,
previousResult: prevResult,
};
setStatus("loading");
setResult(null);
setThinking(null);
setError(null);
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, threadId, modelName, editor, result, runStream],
);
const reset = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStatus("idle");
setResult(null);
setThinking(null);
setError(null);
}, []);
return { status, result, thinking, error, rewrite, rewriteFollowUp, reset };
}
@@ -13,3 +13,10 @@ export type CanvasSelectionState = {
textSelection: CanvasTextSelection | null; textSelection: CanvasTextSelection | null;
codeSelection: CanvasCodeSelection | null; codeSelection: CanvasCodeSelection | null;
}; };
/** Tiptap editor selection — used by AI rewrite flow */
export type WritingSelection = {
from: number;
to: number;
text: string;
};
@@ -0,0 +1,51 @@
import type { Editor } from "@tiptap/react";
import type { WritingSelection } from "../types/selection";
/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
function textToNodes(schema: Editor["schema"], text: string) {
const paragraphs = text.split(/\n{2,}/);
return paragraphs
.map((p) => p.trim())
.filter(Boolean)
.map((p) => ({
type: "paragraph",
content: p ? [{ type: "text", text: p }] : [],
}));
}
/**
* Replace the selection range with the new text.
* Restores focus and places cursor after the inserted content.
*/
export function applyReplace(
editor: Editor,
selection: WritingSelection,
newText: string,
): void {
const { from, to } = selection;
const nodes = textToNodes(editor.schema, newText);
if (nodes.length === 0) return;
editor
.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, nodes)
.run();
}
/**
* Insert the new text as new paragraph(s) after the selection end.
* Original text is preserved.
*/
export function applyInsert(
editor: Editor,
selection: WritingSelection,
newText: string,
): void {
const { to } = selection;
const nodes = textToNodes(editor.schema, newText);
if (nodes.length === 0) return;
editor.chain().focus().insertContentAt(to, nodes).run();
}
@@ -1,16 +1,34 @@
import type { CanvasArtifactContent } from "../types/artifact";
import type { QuickActionId } from "../types/quick-action"; import type { QuickActionId } from "../types/quick-action";
import { isCodeArtifact } from "./artifact";
const QUICK_ACTION_MESSAGES: Record<string, string> = { const QUICK_ACTION_MESSAGES: Record<string, string> = {
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变", expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余", shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气", formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气", casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释", add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句", add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug", fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化", optimize_code: "请对当前代码进行性能和可读性优化",
}; };
export function buildQuickActionMessage(id: QuickActionId): string { /**
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`; * Builds the human message for a quick action.
* The current artifact content is always included so the agent knows what to edit —
* LangGraph's create_agent only passes state.messages to the LLM, not state.artifact.
*/
export function buildQuickActionMessage(
id: QuickActionId,
content: CanvasArtifactContent,
): string {
const instruction = QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
const artifactContent = isCodeArtifact(content) ? content.code : content.fullMarkdown;
const tag = isCodeArtifact(content) ? `language="${content.language}"` : "";
return `${instruction}
<artifact ${tag}>
${artifactContent}
</artifact>`;
} }
+174
View File
@@ -566,6 +566,180 @@
} }
} }
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
.tiptap-writing-editor .ProseMirror {
outline: none;
min-height: 320px;
caret-color: var(--primary);
padding-bottom: 6rem; /* ensure last paragraph isn't flush against the bottom */
}
.tiptap-writing-editor .ProseMirror p {
margin-bottom: 1rem;
line-height: 1.8;
font-size: 0.9375rem; /* 15px */
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror h1 {
font-size: 1.625rem;
font-weight: 700;
letter-spacing: -0.025em;
margin-top: 2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--foreground);
line-height: 1.25;
}
.tiptap-writing-editor .ProseMirror h1:first-child {
margin-top: 0;
}
.tiptap-writing-editor .ProseMirror h2 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.015em;
margin-top: 1.75rem;
margin-bottom: 0.625rem;
color: var(--foreground);
line-height: 1.35;
}
.tiptap-writing-editor .ProseMirror h3 {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--foreground);
line-height: 1.4;
}
.tiptap-writing-editor .ProseMirror h2:first-child,
.tiptap-writing-editor .ProseMirror h3:first-child {
margin-top: 0;
}
.tiptap-writing-editor .ProseMirror strong {
font-weight: 600;
}
.tiptap-writing-editor .ProseMirror em {
font-style: italic;
}
.tiptap-writing-editor .ProseMirror s {
text-decoration: line-through;
opacity: 0.6;
}
.tiptap-writing-editor .ProseMirror ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-bottom: 1rem;
font-size: 0.9375rem;
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-bottom: 1rem;
font-size: 0.9375rem;
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror li {
line-height: 1.8;
margin-bottom: 0.25rem;
}
.tiptap-writing-editor .ProseMirror li > p {
margin-bottom: 0.25rem;
}
.tiptap-writing-editor .ProseMirror blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
margin: 1.25rem 0;
font-style: italic;
font-size: 0.9375rem;
color: var(--muted-foreground);
}
.tiptap-writing-editor .ProseMirror pre {
background: var(--muted);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin: 1rem 0 1.25rem;
overflow-x: auto;
border: 1px solid var(--border);
}
.tiptap-writing-editor .ProseMirror pre code {
background: transparent;
padding: 0;
font-size: 0.8125rem; /* 13px */
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
color: var(--foreground);
line-height: 1.7;
}
.tiptap-writing-editor .ProseMirror code {
background: var(--muted);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.8125rem;
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
color: var(--foreground);
border: 1px solid var(--border);
}
.tiptap-writing-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
.tiptap-writing-editor .ProseMirror a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
transition: opacity 0.15s;
}
.tiptap-writing-editor .ProseMirror a:hover {
opacity: 0.75;
}
/* Placeholder */
.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
float: left;
height: 0;
opacity: 0.6;
}
/* Selection highlight — use color-mix to avoid the background+opacity mismatch */
.tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #f87171 35%, transparent);
}
/* AI rewrite — highlight the selected range while the menu / preview is open */
.ai-rewrite-highlight {
background: color-mix(in srgb, #fbbf24 30%, transparent);
border-radius: 2px;
transition: background 0.2s ease;
}
.dark .ai-rewrite-highlight {
background: color-mix(in srgb, #fbbf24 18%, transparent);
}
:root { :root {
--container-width-xs: calc(var(--spacing) * 72); --container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144); --container-width-sm: calc(var(--spacing) * 144);
@@ -43,6 +43,7 @@ from app.gateway.routers import (
thread_runs, thread_runs,
threads, threads,
uploads, uploads,
writing,
) )
from deerflow.config import app_config as deerflow_app_config from deerflow.config import app_config as deerflow_app_config
from deerflow.config.app_config import apply_logging_level from deerflow.config.app_config import apply_logging_level
@@ -671,6 +672,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions # Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router) app.include_router(suggestions.router)
# Writing rewrite API is mounted at /api/writing
app.include_router(writing.router)
# Scheduled task API is mounted at /api/scheduled-tasks # Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router) app.include_router(scheduled_tasks.router)
@@ -1,3 +1,3 @@
from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
__all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads"] __all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
@@ -0,0 +1,191 @@
"""Writing rewrite API — SSE streaming endpoint.
POST /api/writing/rewrite
Returns text/event-stream with frames:
event: thinking data: {"chunk": "..."}
event: text data: {"chunk": "..."}
event: done data: {}
event: error data: {"message": "..."}
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/writing", tags=["writing"])
# ── Prompts ───────────────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """你是一位专业的中文写作助手,擅长润色、扩写、缩写和调整文风。
规则:
- 只输出改写后的正文,不加任何前缀、解释或括号说明。
- 保持原文的语言(中文输入 → 中文输出,英文输入 → 英文输出)。
- 改写结果应与原文长度相近,除非任务要求扩写或缩写。
- 不要添加 markdown 标题或特殊格式,除非原文本身包含。
"""
_ACTION_INSTRUCTIONS: dict[str, str] = {
"polish": "请对以下文字进行润色,使其表达更流畅、自然,同时保留原意。",
"expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
"shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
"tone": "请调整以下文字的语气风格。",
}
_TONE_SUFFIX: dict[str, str] = {
"professional": "目标风格:专业、正式。",
"casual": "目标风格:轻松、口语化。",
"readable": "目标风格:通俗易懂、易于阅读。",
"academic": "目标风格:学术严谨、逻辑清晰。",
"expressive": "目标风格:生动有文采、富有感染力。",
}
# ── Request model ─────────────────────────────────────────────────────────────
class SelectionInfo(BaseModel):
from_: int = Field(..., alias="from", description="ProseMirror start offset")
to: int = Field(..., description="ProseMirror end offset")
text: str = Field(..., description="Selected plain text")
model_config = {"populate_by_name": True}
class SurroundingContext(BaseModel):
before: str = Field(default="", description="Text before the selection")
after: str = Field(default="", description="Text after the selection")
class RewriteRequest(BaseModel):
thread_id: str = Field(..., alias="threadId")
selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone")
tone: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName")
surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext")
custom_instruction: str | None = Field(default=None, alias="customInstruction")
follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction")
previous_result: str | None = Field(default=None, alias="previousResult")
model_config = {"populate_by_name": True}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
"""Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
thinking = ""
text = ""
content = getattr(chunk, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, str):
text += block
elif isinstance(block, dict):
btype = block.get("type", "")
if btype in ("thinking", "thinking_delta"):
thinking += block.get("thinking", "")
elif btype in ("text", "text_delta"):
text += block.get("text", "")
elif btype == "reasoning":
thinking += block.get("content", "")
# DeepSeek / OpenAI o-series: reasoning in additional_kwargs
if not thinking:
kwargs = getattr(chunk, "additional_kwargs", {}) or {}
r = kwargs.get("reasoning_content") or ""
if isinstance(r, str):
thinking = r
return thinking, text
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish"
instruction = _ACTION_INSTRUCTIONS[action]
if action == "tone" and body.tone and body.tone in _TONE_SUFFIX:
instruction = f"{instruction}\n{_TONE_SUFFIX[body.tone]}"
context_parts: list[str] = []
before = body.surrounding_context.before.strip()
after = body.surrounding_context.after.strip()
if before or after:
context_parts.append("文章上下文(仅供参考,不要改写):")
if before:
context_parts.append(f"[前文] {before[-300:]}")
if after:
context_parts.append(f"[后文] {after[:300]}")
context_parts.append("")
context_block = "\n".join(context_parts)
base_instruction = instruction
if body.custom_instruction:
base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}"
user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}"
if body.follow_up_instruction and body.previous_result:
messages = [
SystemMessage(content=_SYSTEM_PROMPT),
HumanMessage(content=user_message),
AIMessage(content=body.previous_result),
HumanMessage(content=body.follow_up_instruction),
]
else:
messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)]
# Auto-enable thinking when the selected model supports it
resolved_name = body.model_name or (config.models[0].name if config.models else None)
model_cfg = config.get_model_config(resolved_name) if resolved_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
async def generate():
try:
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
if text_delta:
yield _sse("text", {"chunk": text_delta})
yield _sse("done", {})
except Exception as e:
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
yield _sse("error", {"message": str(e)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
@@ -24,6 +24,48 @@ Rules:
- If the user is only asking a question (no edit needed), answer in text — skip the tool call. - If the user is only asking a question (no edit needed), answer in text — skip the tool call.
</canvas_behavior> </canvas_behavior>
<clarification_system>
Before creating a brand-new artifact or performing a major rewrite, verify you have enough
information to do the job well. When key details are missing, call `ask_clarification` FIRST
— do NOT start generating the artifact and ask questions mid-way.
**MUST clarify when (no existing artifact or major rewrite requested):**
1. OUTPUT TYPE IS VAGUE
- "帮我做个主页" / "make a homepage" → ask: tech stack (plain HTML / React / Vue?),
design style, which sections to include
- "写个报告" / "write a report" → ask: topic, target audience, expected length,
required sections
- "写个脚本" / "write a script" → ask: programming language, what it should do
2. REQUIRED CONTENT IS MISSING
- User wants a personal profile page but hasn't provided their name / bio / links
- User wants a data table but hasn't provided the data or schema
3. AMBIGUOUS APPROACH WITH SIGNIFICANTLY DIFFERENT OUTCOMES
- "Design a chat system" → ask: client–server or peer-to-peer, persistence needed?
- "Write an intro" → ask: formal essay or casual blog post?
**DO NOT clarify for:**
- Simple edits to an existing artifact: "make it bold", "fix the typo", "shorten paragraph 2"
- Formatting / style tweaks: "add comments", "use camelCase", "format the code"
- Quick actions: expand / shorten / formalize / casualize / add_comments / fix_bugs / optimize_code
- Requests where intent is clear from context (e.g., user pastes content and says "translate this")
- Follow-up turns where enough context already exists in the conversation
**Format for clarification (use options when practical):**
ask_clarification(
question="你希望这个主页使用什么技术栈?",
clarification_type="missing_info",
options=[
{{"id": "html", "label": "纯 HTML / CSS / JS", "description": "无框架,最简单"}},
{{"id": "react", "label": "React", "description": "组件化,适合复杂交互"}},
{{"id": "vue", "label": "Vue", "description": "渐进式框架,上手容易"}},
],
allow_custom=True,
)
CRITICAL: clarification ALWAYS comes before generating the artifact. Never produce a partial
artifact and then ask for clarification in the same response.
</clarification_system>
<artifact_context> <artifact_context>
The current artifact state is injected into your context. When editing, build on the existing content exactly. The current artifact state is injected into your context. When editing, build on the existing content exactly.
When creating from scratch, produce a complete, high-quality artifact in one shot. When creating from scratch, produce a complete, high-quality artifact in one shot.