智能选中文字
This commit is contained in:
1 parent
e8a31b5d37
commit
da4a08dfe1
34 files changed
+3208
-541
No files matched your search
@@ -1,260 +1,312 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 更新时间:2026-05-16
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
|
||||
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
|
||||
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
|
||||
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 前端阶段四 | API 适配层 | ✅ 已完成 |
|
||||
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
|
||||
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
|
||||
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
|
||||
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
**当前状态**:全部 9 个阶段已完成 🎉
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端路由接入
|
||||
|
||||
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
|
||||
|
||||
```tsx
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
// 在 PageLayout 内部添加:
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
```
|
||||
|
||||
子路由:
|
||||
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
|
||||
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
|
||||
|
||||
### 2.2 前端目录结构(已建立,26 个文件)
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
api/
|
||||
client.ts # stub,Phase 5 实现
|
||||
models.ts # stub,Phase 5 实现
|
||||
threads.ts # stub,Phase 5 实现
|
||||
runs.ts # stub,Phase 5 实现
|
||||
components/
|
||||
layout/
|
||||
CanvasShell.tsx # 顶层布局壳,flex-col
|
||||
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
|
||||
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
|
||||
chat/
|
||||
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
|
||||
artifact/
|
||||
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
|
||||
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
|
||||
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
|
||||
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
|
||||
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
|
||||
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
|
||||
contexts/
|
||||
CanvasThreadContext.tsx # threadId、modelName
|
||||
CanvasGraphContext.tsx # messages、artifact、isStreaming
|
||||
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
|
||||
hooks/
|
||||
useCanvasStream.ts # stub,Phase 5 实现
|
||||
useCanvasArtifact.ts # stub,Phase 6 实现
|
||||
useCanvasSelection.ts # stub,Phase 7 实现
|
||||
useCanvasQuickActions.ts # stub,Phase 8 实现
|
||||
pages/
|
||||
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
|
||||
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx # React Router 子路由
|
||||
types/
|
||||
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
|
||||
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
|
||||
selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
quick-action.ts # QuickAction、QuickActionId
|
||||
utils/
|
||||
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
|
||||
selection.ts # stub
|
||||
thread.ts # stub
|
||||
stream.ts # stub
|
||||
quick-actions.ts # stub
|
||||
constants/
|
||||
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.3 核心类型定义
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```ts
|
||||
// types/artifact.ts
|
||||
export type CanvasArtifact = {
|
||||
currentIndex: number; // 从 1 开始
|
||||
contents: CanvasArtifactContent[];
|
||||
};
|
||||
|
||||
export type CanvasArtifactText = {
|
||||
index: number; type: "text"; title: string; fullMarkdown: string;
|
||||
};
|
||||
|
||||
export type CanvasArtifactCode = {
|
||||
index: number; type: "code"; title: string;
|
||||
language: ProgrammingLanguageOptions; code: string;
|
||||
};
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
### 2.4 Context 结构
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
```tsx
|
||||
// CanvasThreadPage.tsx 的装配顺序
|
||||
<CanvasThreadProvider initialThreadId={thread_id}>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<CanvasShell />
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、已解决的技术难题
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 react-resizable-panels v4.6.2 API 差异
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
此版本与旧版 API 不同,必须使用:
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
|
||||
|
||||
```tsx
|
||||
// ✅ 正确写法
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
const layoutRef = useRef<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 高度链(防止底部内容被裁剪)
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
PageLayout(h-screen)
|
||||
└── main(flex-1)
|
||||
└── CanvasShell div(h-full flex flex-col)
|
||||
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
|
||||
└── ResizablePanelGroup(h-full)
|
||||
└── 面板内容(h-full)
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
### 3.3 折叠/展开按钮
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
|
||||
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
layoutRef.current?.setLayout(
|
||||
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
|
||||
);
|
||||
}, [isChatCollapsed]);
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<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` | 主需求文档 |
|
||||
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
|
||||
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
|
||||
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
|
||||
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| 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)|
|
||||
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
|
||||
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
|
||||
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
|
||||
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
|
||||
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开始新对话的推荐提示词
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在集成 open-canvas 到 DeerFlow 项目。
|
||||
参考进度文档:frontend-web/docs/openCanvas-progress.md
|
||||
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
|
||||
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
+108
-100
@@ -1,100 +1,108 @@
|
||||
{
|
||||
"name": "deer-flow-frontend-vite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.33",
|
||||
"best-effort-json-parser": "^1.2.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "3.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
{
|
||||
"name": "deer-flow-frontend-vite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@tiptap/core": "^3.23.4",
|
||||
"@tiptap/extension-bubble-menu": "^3.23.4",
|
||||
"@tiptap/extension-placeholder": "^3.23.4",
|
||||
"@tiptap/extension-underline": "^3.23.4",
|
||||
"@tiptap/pm": "^3.23.4",
|
||||
"@tiptap/react": "^3.23.4",
|
||||
"@tiptap/starter-kit": "^3.23.4",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.33",
|
||||
"best-effort-json-parser": "^1.2.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "3.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
@@ -173,7 +174,7 @@ function MessageContent_({
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripUploadedFilesTag(rawContent) : "";
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,23 @@ export function getBackendBaseURL() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas page has its own LangGraph base URL so canvas-specific modifications
|
||||
* (different server, different route prefix, etc.) can be made independently
|
||||
* without touching the main page's API path.
|
||||
* Falls back to the main LangGraph URL when VITE_CANVAS_LANGGRAPH_BASE_URL is unset.
|
||||
*/
|
||||
export function getCanvasLangGraphBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL) {
|
||||
return new URL(
|
||||
env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL,
|
||||
getBaseOrigin(),
|
||||
).toString();
|
||||
}
|
||||
// Fall back to the main LangGraph URL (same server, different assistantId)
|
||||
return getLangGraphBaseURL();
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
|
||||
return new URL(
|
||||
|
||||
@@ -14,20 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
|
||||
|
||||
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
|
||||
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
| HumanMessageGroup
|
||||
| AssistantProcessingGroup
|
||||
| AssistantMessageGroup
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
messages: Message[],
|
||||
@@ -44,11 +44,11 @@ export function groupMessages<T>(
|
||||
function lastOpenGroup() {
|
||||
const last = groups[groups.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -70,23 +70,23 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "tool") {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
// Add to the preceding processing group to preserve tool-call association,
|
||||
// then also open a standalone clarification group for prominent display.
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:clarification",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
open.messages.push(message);
|
||||
@@ -297,19 +297,19 @@ export function hasPresentFiles(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
@@ -372,6 +372,13 @@ export function stripUploadedFilesTag(content: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Strip injected <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[] {
|
||||
// Match <uploaded_files>...</uploaded_files> tag
|
||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||
|
||||
@@ -4,6 +4,7 @@ function readEnv(
|
||||
key:
|
||||
| "VITE_BACKEND_BASE_URL"
|
||||
| "VITE_LANGGRAPH_BASE_URL"
|
||||
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
): EnvValue {
|
||||
@@ -14,6 +15,9 @@ function readEnv(
|
||||
export const env = {
|
||||
NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"),
|
||||
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readEnv("VITE_LANGGRAPH_BASE_URL"),
|
||||
// Canvas page uses its own LangGraph base URL so it can be independently
|
||||
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
||||
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
@@ -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 type { CanvasArtifactText } from "../../types/artifact";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { TiptapEditor } from "../../editor/TiptapEditor";
|
||||
import { useWritingEditor } from "../../hooks/useWritingEditor";
|
||||
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
|
||||
import { WritingToolbar } from "./WritingToolbar";
|
||||
import { WritingBubbleMenu } from "./WritingBubbleMenu";
|
||||
import { WritingParagraphMenu } from "./WritingParagraphMenu";
|
||||
import { WritingAiMenu } from "./WritingAiMenu";
|
||||
import { WritingRewritePreview } from "./WritingRewritePreview";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { applyReplace, applyInsert } from "../../utils/apply-rewrite";
|
||||
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
||||
|
||||
interface TextArtifactRendererProps {
|
||||
content: CanvasArtifactText;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface PanelPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Phase of the AI rewrite flow */
|
||||
type FlowPhase = "idle" | "menu" | "preview";
|
||||
|
||||
interface LastAction {
|
||||
action: RewriteAction;
|
||||
tone?: RewriteTone;
|
||||
customInstruction?: string;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { handleTextPointerUp } = useCanvasSelection();
|
||||
const { artifact, setArtifact } = useCanvasGraphContext();
|
||||
const { writingSelection } = useCanvasUIContext();
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
// Auto-scroll to bottom while streaming, unless user has scrolled up
|
||||
// ── AI rewrite flow state ──────────────────────────────────────────────
|
||||
const [phase, setPhase] = useState<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(() => {
|
||||
if (!isStreaming) {
|
||||
autoScrollRef.current = true;
|
||||
@@ -46,7 +102,70 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
setShowScrollBtn(false);
|
||||
}, []);
|
||||
|
||||
if (!content.fullMarkdown) {
|
||||
// ── AI rewrite flow handlers ───────────────────────────────────────────
|
||||
|
||||
/** Compute anchor position from current DOM selection */
|
||||
const computePos = useCallback((): PanelPos => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (rect.width || rect.height) {
|
||||
return { x: rect.left, y: rect.bottom + 6 };
|
||||
}
|
||||
}
|
||||
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
|
||||
}, []);
|
||||
|
||||
const openAiMenu = useCallback(() => {
|
||||
reset();
|
||||
setPanelPos(computePos());
|
||||
setPhase("menu");
|
||||
}, [reset, computePos]);
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
editor?.commands.clearAiHighlight();
|
||||
setPhase("idle");
|
||||
reset();
|
||||
}, [reset, editor]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
|
||||
lastActionRef.current = { action, tone, customInstruction };
|
||||
rewrite(action, tone, customInstruction);
|
||||
setPhase("preview");
|
||||
},
|
||||
[rewrite],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (!lastActionRef.current) return;
|
||||
const { action, tone, customInstruction } = lastActionRef.current;
|
||||
rewrite(action, tone, customInstruction);
|
||||
}, [rewrite]);
|
||||
|
||||
const handleFollowUp = useCallback(
|
||||
(instruction: string) => {
|
||||
if (!lastActionRef.current) return;
|
||||
const { action, tone } = lastActionRef.current;
|
||||
rewriteFollowUp(instruction, action, tone);
|
||||
},
|
||||
[rewriteFollowUp],
|
||||
);
|
||||
|
||||
const handleReplace = useCallback(() => {
|
||||
if (!editor || !writingSelection || !result) return;
|
||||
applyReplace(editor, writingSelection, result);
|
||||
closeAll();
|
||||
}, [editor, writingSelection, result, closeAll]);
|
||||
|
||||
const handleInsert = useCallback(() => {
|
||||
if (!editor || !writingSelection || !result) return;
|
||||
applyInsert(editor, writingSelection, result);
|
||||
closeAll();
|
||||
}, [editor, writingSelection, result, closeAll]);
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────
|
||||
if (!content.fullMarkdown && !isStreaming) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -56,142 +175,145 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-8 py-8">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-7 text-sm text-foreground/90">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-7">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
if (isBlock) {
|
||||
return (
|
||||
// ── Streaming mode ────────────────────────────────────────────────────
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ 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">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-[1.8] text-[15px] text-foreground">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-7">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
return isBlock ? (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</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}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
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 && (
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
<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>
|
||||
|
||||
{isStreaming && 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>
|
||||
{/* Phase: AI action menu */}
|
||||
{phase === "menu" && (
|
||||
<WritingAiMenu
|
||||
pos={panelPos}
|
||||
status={status}
|
||||
error={error}
|
||||
onAction={handleAction}
|
||||
onAbort={reset}
|
||||
onRetry={handleRetry}
|
||||
onClose={closeAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -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 { SendHorizonal, Sparkles, X } from "lucide-react";
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
RocketIcon,
|
||||
SendHorizonal,
|
||||
Sparkles,
|
||||
Square,
|
||||
X,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasStreamContext } from "../../contexts/CanvasStreamContext";
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import {
|
||||
buildMessageWithSelection,
|
||||
@@ -18,6 +49,19 @@ import {
|
||||
resolveCodeSelection,
|
||||
} from "../../utils/selection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CanvasMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const MODE_LABELS: Record<CanvasMode, string> = {
|
||||
flash: "快速",
|
||||
thinking: "推理",
|
||||
pro: "深入",
|
||||
ultra: "超级",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasChatPanel() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const { thread, sendMessage, threadId } = useCanvasThreadStream();
|
||||
const { thread, sendMessage, threadId } = useCanvasStreamContext();
|
||||
const { modelName, setModelName } = useCanvasThreadContext();
|
||||
const { selection, clearSelection } = useCanvasSelection();
|
||||
const { models } = useModels();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [mode, setMode] = useState<CanvasMode>("pro");
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
|
||||
const hasSelection = hasActiveSelection(selection);
|
||||
const isStreaming = thread.isLoading;
|
||||
const hasMessages = thread.messages.length > 0;
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.name === modelName) ?? models[0],
|
||||
[models, modelName],
|
||||
);
|
||||
const supportsThinking = selectedModel?.supports_thinking ?? false;
|
||||
|
||||
// Resolve selected code snippet when artifact is a code artifact
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
const resolvedCode =
|
||||
@@ -80,8 +152,12 @@ export function CanvasChatPanel() {
|
||||
})();
|
||||
|
||||
async function handleSend() {
|
||||
if (isStreaming) {
|
||||
await thread.stop();
|
||||
return;
|
||||
}
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
if (!text) return;
|
||||
setInput("");
|
||||
|
||||
const finalText = hasSelection
|
||||
@@ -89,7 +165,7 @@ export function CanvasChatPanel() {
|
||||
: text;
|
||||
|
||||
clearSelection();
|
||||
await sendMessage(finalText);
|
||||
await sendMessage(finalText, { mode });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -101,9 +177,22 @@ export function CanvasChatPanel() {
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
await sendMessage(answer);
|
||||
await sendMessage(answer, { mode });
|
||||
},
|
||||
[sendMessage],
|
||||
[sendMessage, mode],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(name: string) => {
|
||||
setModelName(name);
|
||||
setModelDialogOpen(false);
|
||||
// If selected model doesn't support thinking, fall back to flash
|
||||
const model = models.find((m) => m.name === name);
|
||||
if (!model?.supports_thinking && mode === "thinking") {
|
||||
setMode("flash");
|
||||
}
|
||||
},
|
||||
[models, mode, setModelName],
|
||||
);
|
||||
|
||||
// MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
|
||||
@@ -128,31 +217,32 @@ export function CanvasChatPanel() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="border-t p-3 shrink-0">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="p-3 shrink-0">
|
||||
<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
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -160,20 +250,157 @@ export function CanvasChatPanel() {
|
||||
placeholder={
|
||||
hasSelection
|
||||
? "针对选中内容发出指令…"
|
||||
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
|
||||
: "发消息…"
|
||||
}
|
||||
className="min-h-[40px] max-h-[160px] resize-none text-sm"
|
||||
rows={1}
|
||||
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={2}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={!input.trim() || isStreaming}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 pb-2 pt-1 border-t border-border/40">
|
||||
{/* Mode selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger 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"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
|
||||
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
|
||||
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function CanvasResizableLayout() {
|
||||
}, [isChatCollapsed]);
|
||||
|
||||
return (
|
||||
<CanvasStreamProvider>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
@@ -54,5 +56,6 @@ export function CanvasResizableLayout() {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</CanvasStreamProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
import { getCanvasThread } from "../../api/threads";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { CanvasHeader } from "./CanvasHeader";
|
||||
import { CanvasResizableLayout } from "./CanvasResizableLayout";
|
||||
|
||||
@@ -42,9 +43,11 @@ export function CanvasShell() {
|
||||
}, [threadId, setMessages, setArtifact, setThreadId]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<CanvasHeader />
|
||||
<CanvasResizableLayout />
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<CanvasHeader />
|
||||
<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,
|
||||
CanvasSelectionState,
|
||||
CanvasTextSelection,
|
||||
WritingSelection,
|
||||
} from "../types/selection";
|
||||
|
||||
export interface CanvasUIContextValue {
|
||||
@@ -17,6 +18,8 @@ export interface CanvasUIContextValue {
|
||||
isEditing: boolean;
|
||||
isWebSearchOpen: boolean;
|
||||
selection: CanvasSelectionState;
|
||||
/** Tiptap editor selection for AI rewrite flow */
|
||||
writingSelection: WritingSelection | null;
|
||||
setIsChatCollapsed: (v: boolean) => void;
|
||||
setIsEditing: (v: boolean) => void;
|
||||
setIsWebSearchOpen: (v: boolean) => void;
|
||||
@@ -25,6 +28,7 @@ export interface CanvasUIContextValue {
|
||||
/** Set code selection; clears any existing text selection. */
|
||||
setCodeSelection: (s: CanvasCodeSelection | null) => void;
|
||||
clearSelection: () => void;
|
||||
setWritingSelection: (s: WritingSelection | null) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: CanvasSelectionState = {
|
||||
@@ -40,6 +44,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
|
||||
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
|
||||
const [selection, setSelection] =
|
||||
useState<CanvasSelectionState>(EMPTY_SELECTION);
|
||||
const [writingSelection, setWritingSelection] =
|
||||
useState<WritingSelection | null>(null);
|
||||
|
||||
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
|
||||
setSelection({ textSelection: s, codeSelection: null });
|
||||
@@ -56,12 +62,14 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
|
||||
isEditing,
|
||||
isWebSearchOpen,
|
||||
selection,
|
||||
writingSelection,
|
||||
setIsChatCollapsed,
|
||||
setIsEditing,
|
||||
setIsWebSearchOpen,
|
||||
setTextSelection,
|
||||
setCodeSelection,
|
||||
clearSelection: () => setSelection(EMPTY_SELECTION),
|
||||
setWritingSelection,
|
||||
}}
|
||||
>
|
||||
{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 { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
|
||||
import { useCanvasStreamContext } from "../contexts/CanvasStreamContext";
|
||||
import type { QuickActionId } from "../types/quick-action";
|
||||
import { getCurrentArtifactContent } from "../utils/artifact";
|
||||
import { buildQuickActionMessage } from "../utils/quick-actions";
|
||||
import { useCanvasStream } from "./useCanvasStream";
|
||||
|
||||
export function useCanvasQuickActions() {
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { sendMessage } = useCanvasStreamContext();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
|
||||
const triggerQuickAction = useCallback(
|
||||
@@ -15,7 +15,7 @@ export function useCanvasQuickActions() {
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
if (!content) return;
|
||||
|
||||
const message = buildQuickActionMessage(actionId);
|
||||
const message = buildQuickActionMessage(actionId, content);
|
||||
await sendMessage(message, { quick_action_id: actionId });
|
||||
},
|
||||
[sendMessage, artifact],
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { getCanvasAPIClient } from "@/core/api/canvas-client";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
|
||||
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
|
||||
@@ -61,7 +61,7 @@ export function useCanvasThreadStream() {
|
||||
}, [threadId]);
|
||||
|
||||
const thread = useStream<CanvasThreadValues>({
|
||||
client: getAPIClient(),
|
||||
client: getCanvasAPIClient(),
|
||||
assistantId: CANVAS_AGENT_ID,
|
||||
threadId: onStreamThreadId,
|
||||
fetchStateHistory: { limit: 1 },
|
||||
@@ -70,7 +70,7 @@ export function useCanvasThreadStream() {
|
||||
threadIdRef.current = meta.thread_id;
|
||||
setThreadId(meta.thread_id);
|
||||
navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
|
||||
void getAPIClient()
|
||||
void getCanvasAPIClient()
|
||||
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
|
||||
.catch(() => {});
|
||||
},
|
||||
@@ -183,8 +183,10 @@ export function useCanvasThreadStream() {
|
||||
},
|
||||
]);
|
||||
|
||||
// Pass current artifact info so the backend can instruct the LLM to
|
||||
// continue editing the same artifact instead of creating a new one.
|
||||
// Inject current artifact content into the human message so the LLM can see
|
||||
// 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 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 {
|
||||
await thread.submit(
|
||||
{
|
||||
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;
|
||||
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 { isCodeArtifact } from "./artifact";
|
||||
|
||||
const QUICK_ACTION_MESSAGES: Record<string, string> = {
|
||||
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
|
||||
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
|
||||
formalize: "请将当前内容改写为正式、专业的语气",
|
||||
casualize: "请将当前内容改写为口语化、友好的语气",
|
||||
add_comments: "请为当前代码添加清晰实用的注释",
|
||||
add_logs: "请为当前代码在关键位置添加适当的日志语句",
|
||||
fix_bugs: "请检查并修复当前代码中的所有 Bug",
|
||||
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
|
||||
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
|
||||
formalize: "请将当前内容改写为正式、专业的语气",
|
||||
casualize: "请将当前内容改写为口语化、友好的语气",
|
||||
add_comments: "请为当前代码添加清晰实用的注释",
|
||||
add_logs: "请为当前代码在关键位置添加适当的日志语句",
|
||||
fix_bugs: "请检查并修复当前代码中的所有 Bug",
|
||||
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>`;
|
||||
}
|
||||
@@ -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 {
|
||||
--container-width-xs: calc(var(--spacing) * 72);
|
||||
--container-width-sm: calc(var(--spacing) * 144);
|
||||
|
||||
@@ -43,6 +43,7 @@ from app.gateway.routers import (
|
||||
thread_runs,
|
||||
threads,
|
||||
uploads,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
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
|
||||
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
|
||||
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"},
|
||||
)
|
||||
+42
@@ -24,6 +24,48 @@ Rules:
|
||||
- If the user is only asking a question (no edit needed), answer in text — skip the tool call.
|
||||
</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>
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user