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