Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
4761677676
74 files changed
+9770
-2887
No files matched your search
@@ -0,0 +1,726 @@
|
||||
# AI 写作助手后端接口与 Prompt 协议文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
|
||||
|
||||
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮后端职责
|
||||
|
||||
后端负责:
|
||||
|
||||
- 接收当前线程信息
|
||||
- 接收用户选区文本
|
||||
- 接收改写动作
|
||||
- 选取必要上下文
|
||||
- 调用模型生成候选改写结果
|
||||
- 返回纯净候选文本
|
||||
|
||||
后端本轮不负责:
|
||||
|
||||
- 直接编辑 Tiptap JSON
|
||||
- 直接操作前端编辑器选区
|
||||
- 直接替换前端文档内容
|
||||
- 做复杂富文本 patch
|
||||
- 做 tracked changes
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
### 3.1 纯文本协议优先
|
||||
|
||||
首版只让后端处理:
|
||||
|
||||
- 选区文本
|
||||
- 前后文文本
|
||||
- 动作类型
|
||||
- 风格参数
|
||||
|
||||
不让后端直接理解完整富文本 JSON。
|
||||
|
||||
### 3.2 前端负责应用,后端负责生成
|
||||
|
||||
- 前端负责选区获取
|
||||
- 前端负责候选结果展示
|
||||
- 前端负责替换 / 插入
|
||||
- 后端只负责“给出候选文本”
|
||||
|
||||
### 3.3 结果必须可直接落文档
|
||||
|
||||
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
|
||||
|
||||
- “当然可以,以下是润色版本:”
|
||||
- “我帮你改写如下:”
|
||||
- “下面是优化后的内容:”
|
||||
|
||||
这类内容必须禁止。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐接口设计
|
||||
|
||||
## 4.1 主接口
|
||||
|
||||
推荐新增统一接口:
|
||||
|
||||
```text
|
||||
POST /api/writing/rewrite
|
||||
```
|
||||
|
||||
由 `action` 决定具体重写类型。
|
||||
|
||||
## 4.2 为什么推荐统一接口
|
||||
|
||||
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
|
||||
|
||||
- 前端判断分支增多
|
||||
- 埋点与异常处理分散
|
||||
- 后续新增动作不方便扩展
|
||||
|
||||
统一接口更适合本轮需求。
|
||||
|
||||
---
|
||||
|
||||
## 5. 请求体定义
|
||||
|
||||
建议请求体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"threadId": "string",
|
||||
"documentId": "string",
|
||||
"selection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"action": "polish",
|
||||
"tone": "professional",
|
||||
"surroundingContext": {
|
||||
"before": "选区前文",
|
||||
"after": "选区后文"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 字段说明
|
||||
|
||||
### 6.1 `threadId`
|
||||
|
||||
用途:
|
||||
|
||||
- 关联当前 DeerFlow 线程
|
||||
- 必要时取线程上下文或会话元数据
|
||||
|
||||
### 6.2 `documentId`
|
||||
|
||||
用途:
|
||||
|
||||
- 标识当前右侧文档
|
||||
- 首版可选
|
||||
- 如果当前系统还没有独立文档 ID,可先允许为空
|
||||
|
||||
### 6.3 `selection.from` / `selection.to`
|
||||
|
||||
用途:
|
||||
|
||||
- 帮助前后端联调排查
|
||||
- 首版后端通常不需要真正使用偏移量
|
||||
- 建议保留,便于日志定位
|
||||
|
||||
### 6.4 `selection.text`
|
||||
|
||||
用途:
|
||||
|
||||
- 这是本轮最核心输入
|
||||
- 后端实际重写的主文本
|
||||
|
||||
### 6.5 `action`
|
||||
|
||||
允许值:
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone`
|
||||
|
||||
### 6.6 `tone`
|
||||
|
||||
仅当 `action = tone` 时使用。
|
||||
|
||||
建议允许值:
|
||||
|
||||
- `professional`
|
||||
- `casual`
|
||||
- `readable`
|
||||
- `academic`
|
||||
- `expressive`
|
||||
|
||||
### 6.7 `surroundingContext.before`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区前文
|
||||
- 帮助模型理解语气、主语、叙事连续性
|
||||
|
||||
建议:
|
||||
|
||||
- 控制长度
|
||||
- 不要塞太多无关上下文
|
||||
|
||||
### 6.8 `surroundingContext.after`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区后文
|
||||
- 帮助模型在扩写、改语气时与后续内容衔接
|
||||
|
||||
---
|
||||
|
||||
## 7. 返回体定义
|
||||
|
||||
建议返回体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": "AI 改写后的候选文本",
|
||||
"originalSelection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"metadata": {
|
||||
"action": "polish",
|
||||
"tone": null,
|
||||
"model": "your-model-name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 返回字段要求
|
||||
|
||||
## 8.1 `output`
|
||||
|
||||
必须:
|
||||
|
||||
- 只包含候选正文
|
||||
- 尽量不夹带解释语
|
||||
- 能直接被前端替换或插入
|
||||
|
||||
## 8.2 `originalSelection`
|
||||
|
||||
作用:
|
||||
|
||||
- 前端可以做校验
|
||||
- 联调时方便确认是针对哪段文本返回的
|
||||
|
||||
## 8.3 `metadata`
|
||||
|
||||
作用:
|
||||
|
||||
- 便于埋点
|
||||
- 便于日志记录
|
||||
- 便于问题追踪
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误响应规范
|
||||
|
||||
建议统一错误结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "WRITING_REWRITE_FAILED",
|
||||
"message": "rewrite failed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议错误码:
|
||||
|
||||
- `WRITING_REWRITE_FAILED`
|
||||
- `WRITING_INVALID_ACTION`
|
||||
- `WRITING_INVALID_TONE`
|
||||
- `WRITING_EMPTY_SELECTION`
|
||||
- `WRITING_MODEL_UNAVAILABLE`
|
||||
- `WRITING_CONTEXT_TOO_LARGE`
|
||||
|
||||
---
|
||||
|
||||
## 10. 动作语义定义
|
||||
|
||||
## 10.1 `polish`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持原意
|
||||
- 改善表达
|
||||
- 提升通顺度、自然度和可读性
|
||||
|
||||
不应:
|
||||
|
||||
- 引入新事实
|
||||
- 改写成完全不同内容
|
||||
|
||||
## 10.2 `expand`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持主旨
|
||||
- 扩充细节
|
||||
- 增强画面感、解释性或完整度
|
||||
|
||||
不应:
|
||||
|
||||
- 无限制发散
|
||||
- 添加明显脱离主题的新内容
|
||||
|
||||
## 10.3 `shorten`
|
||||
|
||||
目标:
|
||||
|
||||
- 压缩篇幅
|
||||
- 保留关键信息
|
||||
- 减少重复表达
|
||||
|
||||
不应:
|
||||
|
||||
- 丢失关键论点
|
||||
- 压缩到语义残缺
|
||||
|
||||
## 10.4 `tone`
|
||||
|
||||
目标:
|
||||
|
||||
- 不改变原意
|
||||
- 调整语言风格
|
||||
|
||||
不应:
|
||||
|
||||
- 改写事实
|
||||
- 改写逻辑结构到完全不同
|
||||
|
||||
---
|
||||
|
||||
## 11. Prompt 设计原则
|
||||
|
||||
## 11.1 通用系统约束
|
||||
|
||||
所有改写类 prompt 都应包含以下约束:
|
||||
|
||||
- 直接输出最终可用正文
|
||||
- 不输出标题,除非原文本身就是标题改写
|
||||
- 不输出解释
|
||||
- 不输出分析过程
|
||||
- 不输出“以下是结果”之类前缀
|
||||
- 保持与上下文语气一致
|
||||
|
||||
## 11.2 上下文使用原则
|
||||
|
||||
前后文只用于理解,不要求模型重写前后文。
|
||||
|
||||
明确告诉模型:
|
||||
|
||||
- 只改写选中文本
|
||||
- 前后文仅用于参考衔接
|
||||
|
||||
## 11.3 长度控制原则
|
||||
|
||||
- `polish`:长度与原文接近
|
||||
- `expand`:长度可明显增加,但不要失控
|
||||
- `shorten`:长度明显缩短,但要保留关键信息
|
||||
- `tone`:长度尽量与原文接近
|
||||
|
||||
---
|
||||
|
||||
## 12. Prompt 模板建议
|
||||
|
||||
## 12.1 润色模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
|
||||
|
||||
要求:
|
||||
1. 保留原文事实和核心意思。
|
||||
2. 不要添加原文没有的新事实。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出润色后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.2 扩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
|
||||
|
||||
要求:
|
||||
1. 保持原文主旨不变。
|
||||
2. 增加内容时要自然,不要跑题。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出扩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.3 缩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
|
||||
|
||||
要求:
|
||||
1. 不要丢掉核心信息。
|
||||
2. 去掉重复、冗余和拖沓表达。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出缩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.4 改语气模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
|
||||
|
||||
要求:
|
||||
1. 保持原文信息不变。
|
||||
2. 只调整表达风格,不改变事实和逻辑。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出改写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Tone 映射建议
|
||||
|
||||
建议前端传标准值,后端映射中文标签:
|
||||
|
||||
- `professional` -> 专业、正式
|
||||
- `casual` -> 口语化、轻松自然
|
||||
- `readable` -> 更易读、更清晰
|
||||
- `academic` -> 学术、严谨
|
||||
- `expressive` -> 更有文采、更有表现力
|
||||
|
||||
---
|
||||
|
||||
## 14. 上下文截取策略
|
||||
|
||||
## 14.1 推荐策略
|
||||
|
||||
前端传:
|
||||
|
||||
- 选区前固定长度文本
|
||||
- 选区后固定长度文本
|
||||
|
||||
例如:
|
||||
|
||||
- 前文 200 到 500 字
|
||||
- 后文 200 到 500 字
|
||||
|
||||
### 原因
|
||||
|
||||
- 足够帮助模型理解文气和衔接
|
||||
- 不会让请求太重
|
||||
|
||||
## 14.2 不建议
|
||||
|
||||
- 把整篇长文全量塞进去
|
||||
- 把整个线程消息都塞进改写接口
|
||||
|
||||
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
|
||||
|
||||
---
|
||||
|
||||
## 15. LangGraph 接入建议
|
||||
|
||||
## 15.1 推荐接入方式
|
||||
|
||||
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
|
||||
|
||||
建议二选一:
|
||||
|
||||
### 方案 A
|
||||
|
||||
新增专用 router:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
内部调用现有模型层 / agent 层。
|
||||
|
||||
### 方案 B
|
||||
|
||||
新增专用 graph node 或 tool:
|
||||
|
||||
- `rewrite_selection`
|
||||
|
||||
再由 router 调用该能力。
|
||||
|
||||
## 15.2 更推荐方案
|
||||
|
||||
推荐:
|
||||
|
||||
- 对外暴露统一 router
|
||||
- 内部走已有模型封装或 LangGraph 节点
|
||||
|
||||
这样前端接口更稳定,后续后端内部重构不会影响前端。
|
||||
|
||||
---
|
||||
|
||||
## 16. 后端模块拆解
|
||||
|
||||
## 16.1 模块 A:接口层
|
||||
|
||||
负责:
|
||||
|
||||
- 请求校验
|
||||
- 错误响应
|
||||
- 参数标准化
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一 rewrite API
|
||||
- action / tone 校验
|
||||
- 空选区校验
|
||||
|
||||
## 16.2 模块 B:上下文拼装层
|
||||
|
||||
负责:
|
||||
|
||||
- 拼装 before / selection / after
|
||||
- 必要时提取线程相关信息
|
||||
|
||||
需要完成:
|
||||
|
||||
- 上下文长度控制
|
||||
- 清洗空白和异常字符
|
||||
|
||||
## 16.3 模块 C:Prompt 层
|
||||
|
||||
负责:
|
||||
|
||||
- 选择模板
|
||||
- 填充 action 和 tone
|
||||
|
||||
需要完成:
|
||||
|
||||
- 4 类动作模板
|
||||
- tone 映射
|
||||
|
||||
## 16.4 模块 D:模型调用层
|
||||
|
||||
负责:
|
||||
|
||||
- 走现有模型配置体系
|
||||
- 执行大模型请求
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一调用入口
|
||||
- 模型不可用错误处理
|
||||
|
||||
## 16.5 模块 E:结果清洗层
|
||||
|
||||
负责:
|
||||
|
||||
- 去掉解释性前缀
|
||||
- 去掉多余包裹
|
||||
- 标准化返回
|
||||
|
||||
需要完成:
|
||||
|
||||
- 输出清洗
|
||||
- metadata 填充
|
||||
|
||||
---
|
||||
|
||||
## 17. 返回结果清洗建议
|
||||
|
||||
即使 prompt 已经约束,模型仍可能输出:
|
||||
|
||||
- “以下是润色后的内容:”
|
||||
- “当然,这里是改写结果:”
|
||||
- “优化如下:”
|
||||
|
||||
建议后端加一层轻量清洗:
|
||||
|
||||
- 去掉常见说明性前缀
|
||||
- 去掉包裹性 markdown 代码块
|
||||
- 去掉多余首尾空白
|
||||
|
||||
注意:
|
||||
|
||||
- 清洗必须保守
|
||||
- 不要误删正文
|
||||
|
||||
---
|
||||
|
||||
## 18. 日志与观测建议
|
||||
|
||||
建议记录:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `selection_length`
|
||||
- `before_length`
|
||||
- `after_length`
|
||||
- `model_name`
|
||||
- `latency_ms`
|
||||
- `success / fail`
|
||||
|
||||
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
|
||||
|
||||
---
|
||||
|
||||
## 19. 后端测试清单
|
||||
|
||||
## 19.1 单元测试
|
||||
|
||||
- action 校验
|
||||
- tone 校验
|
||||
- 空选区校验
|
||||
- prompt 选择逻辑
|
||||
- 输出清洗逻辑
|
||||
|
||||
## 19.2 集成测试
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone-professional`
|
||||
- `tone-casual`
|
||||
- `tone-readable`
|
||||
- `tone-academic`
|
||||
- `tone-expressive`
|
||||
|
||||
## 19.3 边界测试
|
||||
|
||||
- 选区过短
|
||||
- 选区过长
|
||||
- 前后文为空
|
||||
- 多段文本带换行
|
||||
- 模型超时
|
||||
- 模型不可用
|
||||
|
||||
---
|
||||
|
||||
## 20. 联调约束
|
||||
|
||||
## 20.1 前后端必须统一
|
||||
|
||||
- `action` 枚举值
|
||||
- `tone` 枚举值
|
||||
- 错误码
|
||||
- 返回体字段名
|
||||
|
||||
## 20.2 联调重点
|
||||
|
||||
- 多段落文本中换行保留是否正确
|
||||
- AI 返回内容是否适合直接落文档
|
||||
- 超时和失败时前端是否能优雅重试
|
||||
|
||||
## 20.3 联调建议顺序
|
||||
|
||||
1. 先 mock 返回固定文本
|
||||
2. 再接真实模型
|
||||
3. 先验证单段落
|
||||
4. 再验证多段落
|
||||
5. 最后验证大段文本
|
||||
|
||||
---
|
||||
|
||||
## 21. 优先级建议
|
||||
|
||||
### P0
|
||||
|
||||
- 统一 rewrite API
|
||||
- 4 类动作支持
|
||||
- 输出纯净正文
|
||||
- 错误结构标准化
|
||||
|
||||
### P1
|
||||
|
||||
- 输出清洗增强
|
||||
- 埋点与日志
|
||||
- 上下文截断策略优化
|
||||
|
||||
### P2
|
||||
|
||||
- 更细粒度风格控制
|
||||
- 结果质量二次校验
|
||||
|
||||
---
|
||||
|
||||
## 22. 完成定义
|
||||
|
||||
后端满足以下条件即可视为本轮完成:
|
||||
|
||||
- 前端能通过统一接口发起改写
|
||||
- 4 类动作均可稳定返回候选文本
|
||||
- 返回文本基本可直接用于替换或插入
|
||||
- 多段落文本能稳定处理
|
||||
- 错误结构规范统一
|
||||
|
||||
---
|
||||
|
||||
## 23. 结论
|
||||
|
||||
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
|
||||
|
||||
最稳妥的路线是:
|
||||
|
||||
- 统一接口
|
||||
- 统一动作枚举
|
||||
- 统一 prompt 模板
|
||||
- 统一错误结构
|
||||
- 统一输出约束
|
||||
|
||||
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
|
||||
@@ -0,0 +1,703 @@
|
||||
# AI 写作助手前端开发任务清单
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
|
||||
|
||||
目标是让前端团队按模块并行推进,最终完成:
|
||||
|
||||
- 保留左侧聊天区
|
||||
- 右侧升级为 `Tiptap` 写作编辑器
|
||||
- 支持选区浮层
|
||||
- 支持润色 / 扩写 / 缩写 / 改语气
|
||||
- 支持替换 / 插入
|
||||
- 支持标题 / 列表 / 引用 / 代码块工具条
|
||||
- UI 参考豆包 + Novel + open-canvas
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮前端改造范围
|
||||
|
||||
### 2.1 保留不动
|
||||
|
||||
- 左侧聊天主链路
|
||||
- 现有线程消息流
|
||||
- 现有模型选择器
|
||||
- 现有输入框的 `writing_mode` 总入口
|
||||
|
||||
### 2.2 重点改造
|
||||
|
||||
- 右侧 Artifact 面板承载方式
|
||||
- 新增独立写作工作区
|
||||
- 富文本编辑器内核
|
||||
- 选区浮层
|
||||
- AI 改写结果预览
|
||||
- 文档级工具条
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐目录结构
|
||||
|
||||
建议新增独立模块目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas-writing/
|
||||
routes/
|
||||
WritingRoutes.tsx
|
||||
pages/
|
||||
WritingWorkspacePage.tsx
|
||||
components/
|
||||
WritingShell.tsx
|
||||
WritingHeader.tsx
|
||||
WritingToolbar.tsx
|
||||
WritingEditorPane.tsx
|
||||
WritingBubbleMenu.tsx
|
||||
WritingAiMenu.tsx
|
||||
WritingRewritePreview.tsx
|
||||
WritingStatusBar.tsx
|
||||
WritingEmptyState.tsx
|
||||
editor/
|
||||
TiptapEditor.tsx
|
||||
TiptapExtensions.ts
|
||||
commands.ts
|
||||
selection.ts
|
||||
context/
|
||||
WritingEditorContext.tsx
|
||||
hooks/
|
||||
useWritingEditor.ts
|
||||
useWritingSelection.ts
|
||||
useWritingRewrite.ts
|
||||
useWritingToolbar.ts
|
||||
api/
|
||||
rewrite.ts
|
||||
documents.ts
|
||||
types/
|
||||
document.ts
|
||||
rewrite.ts
|
||||
utils/
|
||||
apply-rewrite.ts
|
||||
markdown.ts
|
||||
editor-style.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有代码落点
|
||||
|
||||
## 4.1 需要接入的现有文件
|
||||
|
||||
- `frontend-web/src/pages/PageRoutes.tsx`
|
||||
- 增加新写作路由入口
|
||||
|
||||
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
- 当前双栏布局参考来源
|
||||
- 后续决定是否复用布局容器,或在新页面中重建双栏
|
||||
|
||||
- `frontend-web/src/components/workspace/input-box.tsx`
|
||||
- 保留 `writing_mode`
|
||||
- 后续可在切换写作模式时跳转到新写作页面
|
||||
|
||||
- `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
- 仅保留给旧 Artifact 逻辑
|
||||
- 不要继续承载写作编辑器状态
|
||||
|
||||
## 4.2 不建议继续沿用的方式
|
||||
|
||||
- 不要把编辑器主状态存成 `string[] artifacts`
|
||||
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
|
||||
- 不要让 AI 改写结果先回聊天,再由用户手动复制
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务分阶段拆解
|
||||
|
||||
## 阶段 A:路由与页面壳
|
||||
|
||||
### 目标
|
||||
|
||||
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRoutes.tsx`
|
||||
- 新增 `WritingWorkspacePage.tsx`
|
||||
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
|
||||
- 确认新页面进入方式
|
||||
- 确定是:
|
||||
- 通过左侧聊天上下文进入
|
||||
- 通过 `writing_mode` 入口进入
|
||||
- 或二者都支持
|
||||
|
||||
### 交付物
|
||||
|
||||
- 新写作页面能被路由访问
|
||||
- 页面有基础左右布局和空态
|
||||
|
||||
### 验收
|
||||
|
||||
- 打开 `page/writing` 能进入工作区
|
||||
- 页面不报错
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:编辑器底座
|
||||
|
||||
### 目标
|
||||
|
||||
把右侧正式替换成富文本编辑器。
|
||||
|
||||
### 任务
|
||||
|
||||
- 安装 Tiptap 依赖
|
||||
- 建立 `TiptapEditor.tsx`
|
||||
- 建立 `TiptapExtensions.ts`
|
||||
- 配置节点:
|
||||
- paragraph
|
||||
- heading
|
||||
- bulletList
|
||||
- orderedList
|
||||
- listItem
|
||||
- blockquote
|
||||
- codeBlock
|
||||
- hardBreak
|
||||
- 配置 marks:
|
||||
- bold
|
||||
- italic
|
||||
- underline
|
||||
- strike
|
||||
- code
|
||||
- 建立编辑器初始化逻辑
|
||||
- 建立编辑器销毁逻辑
|
||||
- 定义最小文档状态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 可编辑正文区域
|
||||
- 初始文档可加载
|
||||
- 内容可读可写
|
||||
|
||||
### 验收
|
||||
|
||||
- 可以直接输入正文
|
||||
- 可以换行、加标题、切换列表
|
||||
- 不出现内容错位和明显闪烁
|
||||
|
||||
---
|
||||
|
||||
## 阶段 C:主工具栏
|
||||
|
||||
### 目标
|
||||
|
||||
右侧具备正式写作工具栏。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingToolbar.tsx`
|
||||
- 新增 `useWritingToolbar.ts`
|
||||
- 实现按钮:
|
||||
- 正文
|
||||
- H1
|
||||
- H2
|
||||
- H3
|
||||
- 有序列表
|
||||
- 无序列表
|
||||
- 引用
|
||||
- 代码块
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 下划线
|
||||
- 删除线
|
||||
- 处理 active 状态
|
||||
- 处理不可用状态
|
||||
- 处理光标块状态联动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 主工具栏可用
|
||||
|
||||
### 验收
|
||||
|
||||
- 工具条按钮能正确反映当前块状态
|
||||
- 操作后编辑器内容正确变化
|
||||
|
||||
---
|
||||
|
||||
## 阶段 D:选区状态与浮层
|
||||
|
||||
### 目标
|
||||
|
||||
实现豆包式选区浮层基础能力。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `useWritingSelection.ts`
|
||||
- 新增 `selection.ts`
|
||||
- 新增 `WritingBubbleMenu.tsx`
|
||||
- 基于 Tiptap 监听 selection 变化
|
||||
- 过滤掉空选区
|
||||
- 处理多段连续选区
|
||||
- 浮层按钮:
|
||||
- AI 改写
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 标题
|
||||
- 复制
|
||||
- 处理滚动定位
|
||||
- 处理窗口尺寸变化
|
||||
|
||||
### 交付物
|
||||
|
||||
- 选中内容后出现浮层
|
||||
|
||||
### 验收
|
||||
|
||||
- 单段可触发
|
||||
- 多段可触发
|
||||
- 浮层位置稳定
|
||||
|
||||
---
|
||||
|
||||
## 阶段 E:AI 改写菜单
|
||||
|
||||
### 目标
|
||||
|
||||
把选区浮层接到 AI 改写工作流。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingAiMenu.tsx`
|
||||
- 新增 `useWritingRewrite.ts`
|
||||
- 新增 `api/rewrite.ts`
|
||||
- 实现一级动作:
|
||||
- 润色
|
||||
- 扩写
|
||||
- 缩写
|
||||
- 改语气
|
||||
- 实现改语气二级菜单:
|
||||
- 更专业
|
||||
- 更口语化
|
||||
- 更易读
|
||||
- 更学术
|
||||
- 更有文采
|
||||
- 封装请求入参
|
||||
- 增加 loading 态
|
||||
- 增加 error 态
|
||||
- 增加 retry 入口
|
||||
|
||||
### 交付物
|
||||
|
||||
- AI 改写菜单和请求链路
|
||||
|
||||
### 验收
|
||||
|
||||
- 4 个动作都能发请求
|
||||
- 改语气的二级菜单可正常展开
|
||||
- 请求失败时有反馈
|
||||
|
||||
---
|
||||
|
||||
## 阶段 F:候选结果预览
|
||||
|
||||
### 目标
|
||||
|
||||
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRewritePreview.tsx`
|
||||
- 新增 `apply-rewrite.ts`
|
||||
- 显示原文与候选内容
|
||||
- 提供按钮:
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
- 用 transaction 应用结果
|
||||
- 处理应用成功后的焦点与滚动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 改写结果预览卡片或面板
|
||||
|
||||
### 验收
|
||||
|
||||
- 替换准确
|
||||
- 插入准确
|
||||
- 取消后文档保持原样
|
||||
|
||||
---
|
||||
|
||||
## 阶段 G:文档状态与容器体验
|
||||
|
||||
### 目标
|
||||
|
||||
补齐正式产品的壳层体验。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingShell.tsx`
|
||||
- 新增 `WritingHeader.tsx`
|
||||
- 新增 `WritingStatusBar.tsx`
|
||||
- 新增 `WritingEmptyState.tsx`
|
||||
- 补充文档标题区域
|
||||
- 补充文档状态提示
|
||||
- 补充空态和加载态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 页面完整壳层
|
||||
|
||||
### 验收
|
||||
|
||||
- 页面不是裸编辑器
|
||||
- 有完整的文档工作区感
|
||||
|
||||
---
|
||||
|
||||
## 阶段 H:视觉打磨
|
||||
|
||||
### 目标
|
||||
|
||||
让整体观感接近成熟产品。
|
||||
|
||||
### 任务
|
||||
|
||||
- 调整右侧阅读宽度
|
||||
- 调整字体大小和行高
|
||||
- 调整段距和标题层级
|
||||
- 调整工具栏阴影、边框、圆角
|
||||
- 调整浮层动效
|
||||
- 调整 AI 预览卡片质感
|
||||
- 优化深浅色模式兼容
|
||||
|
||||
### 交付物
|
||||
|
||||
- 完整视觉优化
|
||||
|
||||
### 验收
|
||||
|
||||
- 明显优于当前版本
|
||||
- 有豆包 / Novel 风格的编辑器质感
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件级开发清单
|
||||
|
||||
## 6.1 `WritingWorkspacePage.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面级容器
|
||||
- 获取线程 / 文档上下文
|
||||
- 组织左右布局
|
||||
|
||||
必须完成:
|
||||
|
||||
- 页面初始化
|
||||
- 与左侧聊天协同
|
||||
- 右侧编辑区挂载
|
||||
|
||||
## 6.2 `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 写作工作区总壳层
|
||||
- 布局标题区、工具栏、正文区、状态区
|
||||
|
||||
必须完成:
|
||||
|
||||
- 自适应布局
|
||||
- 桌面端优先
|
||||
- 窄屏降级
|
||||
|
||||
## 6.3 `TiptapEditor.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 包装 Tiptap EditorContent
|
||||
- 负责 editor 实例初始化
|
||||
|
||||
必须完成:
|
||||
|
||||
- `onUpdate`
|
||||
- `onSelectionUpdate`
|
||||
- 内容同步
|
||||
|
||||
## 6.4 `WritingToolbar.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 顶部主工具栏
|
||||
|
||||
必须完成:
|
||||
|
||||
- 所有基础格式化命令
|
||||
- 当前状态高亮
|
||||
|
||||
## 6.5 `WritingBubbleMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 选区浮层
|
||||
|
||||
必须完成:
|
||||
|
||||
- 跟随选区
|
||||
- 基础快捷操作
|
||||
- AI 改写入口
|
||||
|
||||
## 6.6 `WritingAiMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- AI 改写动作菜单
|
||||
|
||||
必须完成:
|
||||
|
||||
- 一级菜单
|
||||
- 改语气二级菜单
|
||||
- 动作触发
|
||||
|
||||
## 6.7 `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 展示候选内容
|
||||
|
||||
必须完成:
|
||||
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
|
||||
---
|
||||
|
||||
## 7. Hook 级开发清单
|
||||
|
||||
## 7.1 `useWritingEditor.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- editor 生命周期
|
||||
- 初始内容装载
|
||||
- 内容变更同步
|
||||
|
||||
## 7.2 `useWritingSelection.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 当前选区抽取
|
||||
- 选区文本缓存
|
||||
- 多段落纯文本拼接
|
||||
|
||||
## 7.3 `useWritingRewrite.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 改写请求
|
||||
- loading / success / error
|
||||
- 结果缓存
|
||||
- 重试
|
||||
|
||||
## 7.4 `useWritingToolbar.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- toolbar active 状态
|
||||
- editor command 映射
|
||||
|
||||
---
|
||||
|
||||
## 8. API 对接清单
|
||||
|
||||
前端需向后端约定以下接口能力:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
前端请求内容至少包括:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `selection.from`
|
||||
- `selection.to`
|
||||
- `selection.text`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `surroundingContext.before`
|
||||
- `surroundingContext.after`
|
||||
|
||||
前端需处理的返回字段:
|
||||
|
||||
- `output`
|
||||
- `originalSelection`
|
||||
- `metadata`
|
||||
|
||||
---
|
||||
|
||||
## 9. 状态管理建议
|
||||
|
||||
建议新增独立 Context:
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
建议持有:
|
||||
|
||||
- 当前文档内容
|
||||
- 当前 editor 实例
|
||||
- 当前选区
|
||||
- 当前改写请求状态
|
||||
- 当前候选结果
|
||||
- 当前 UI 面板状态
|
||||
|
||||
不建议继续复用旧 Artifact Context 的原因:
|
||||
|
||||
- 旧 Context 关注文件列表和文件路径
|
||||
- 新编辑器关注选区、结构、候选结果、应用动作
|
||||
- 关注点完全不同
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端联调检查点
|
||||
|
||||
## 10.1 与后端联调前
|
||||
|
||||
- 先用 mock 数据打通浮层和预览
|
||||
- 先验证替换 / 插入逻辑
|
||||
|
||||
## 10.2 与后端联调时
|
||||
|
||||
- 检查多段落选区拼接结果
|
||||
- 检查 AI 返回内容是否带说明性前缀
|
||||
- 检查替换后位置是否准确
|
||||
|
||||
## 10.3 联调后
|
||||
|
||||
- 验证大段文本性能
|
||||
- 验证撤销 / 重做
|
||||
- 验证快速连续触发是否会状态错乱
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端测试清单
|
||||
|
||||
## 11.1 功能测试
|
||||
|
||||
- 输入正文
|
||||
- 插入标题
|
||||
- 切换列表
|
||||
- 插入引用
|
||||
- 插入代码块
|
||||
- 加粗 / 斜体 / 下划线 / 删除线
|
||||
- 选区浮层显示
|
||||
- 选区浮层隐藏
|
||||
- AI 改写菜单打开
|
||||
- 改语气二级菜单打开
|
||||
- 改写结果替换
|
||||
- 改写结果插入
|
||||
- 改写结果取消
|
||||
|
||||
## 11.2 场景测试
|
||||
|
||||
- 单段短文本润色
|
||||
- 多段文本润色
|
||||
- 多段文本扩写
|
||||
- 多段文本缩写
|
||||
- 多段文本改语气
|
||||
- 连续两次改写同一段
|
||||
- 快速切换线程后重新进入
|
||||
|
||||
## 11.3 回归测试
|
||||
|
||||
- 左侧聊天是否正常
|
||||
- 模型切换是否正常
|
||||
- `writing_mode` 是否正常
|
||||
- 原有 artifact 模块是否仍可使用
|
||||
|
||||
---
|
||||
|
||||
## 12. 任务优先级
|
||||
|
||||
### P0
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
- 选区浮层
|
||||
- 改写菜单
|
||||
- 替换 / 插入
|
||||
|
||||
### P1
|
||||
|
||||
- 页面头部与状态
|
||||
- 优雅 loading / error / retry
|
||||
- 多段落稳定性
|
||||
- UI 打磨
|
||||
|
||||
### P2
|
||||
|
||||
- 空态优化
|
||||
- 动效优化
|
||||
- 快捷键支持
|
||||
|
||||
---
|
||||
|
||||
## 13. 建议分工
|
||||
|
||||
### 前端 1 号
|
||||
|
||||
负责:
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
|
||||
### 前端 2 号
|
||||
|
||||
负责:
|
||||
|
||||
- 选区状态
|
||||
- BubbleMenu
|
||||
- AI 菜单
|
||||
|
||||
### 前端 3 号
|
||||
|
||||
负责:
|
||||
|
||||
- 候选结果预览
|
||||
- 替换 / 插入
|
||||
- 状态栏与页面体验
|
||||
|
||||
### 联调负责人
|
||||
|
||||
负责:
|
||||
|
||||
- 前后端请求协议
|
||||
- 错误态规范
|
||||
- 多段落测试和回归
|
||||
|
||||
---
|
||||
|
||||
## 14. 前端完成定义
|
||||
|
||||
满足以下条件可视为前端完成:
|
||||
|
||||
- 右侧能作为正式编辑器独立使用
|
||||
- 选区后可直接发起 AI 改写
|
||||
- 改写后可替换或插入
|
||||
- 视觉上明显优于当前版本
|
||||
- 左侧聊天和现有系统不被破坏
|
||||
|
||||
---
|
||||
|
||||
## 15. 下一步建议
|
||||
|
||||
前端团队可以按以下顺序开工:
|
||||
|
||||
1. 先起页面和路由
|
||||
2. 再接 Tiptap 与主工具栏
|
||||
3. 再做选区浮层
|
||||
4. 再接 AI 菜单和结果预览
|
||||
5. 最后做视觉打磨
|
||||
|
||||
这个顺序风险最小,也最容易在中途验收。
|
||||
@@ -0,0 +1,457 @@
|
||||
# AI 写作助手源码与实现映射表
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把以下几类信息对应起来:
|
||||
|
||||
- 豆包 AI 写作助手里要借鉴的交互
|
||||
- Novel / open-canvas / Tiptap 中可参考的实现思路
|
||||
- DeerFlow 当前项目里已经存在的落点
|
||||
- 本次新建模块应该落到哪里
|
||||
|
||||
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 使用方式
|
||||
|
||||
开发时建议按以下顺序看:
|
||||
|
||||
1. 先看“目标能力”
|
||||
2. 再看“参考来源”
|
||||
3. 再看“DeerFlow 当前落点”
|
||||
4. 最后看“本次目标文件”
|
||||
|
||||
这样可以避免出现两种常见问题:
|
||||
|
||||
- 直接照搬外部项目结构,结果和你们现有架构冲突
|
||||
- 只看自己项目,不知道成熟产品交互应该怎么落
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体映射
|
||||
|
||||
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
|
||||
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
|
||||
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
|
||||
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
|
||||
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
|
||||
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
|
||||
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
|
||||
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
|
||||
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
|
||||
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 豆包功能到 DeerFlow 的映射
|
||||
|
||||
## 4.1 滑词 / 选区浮层
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 选中文本后出现轻量浮层
|
||||
- 浮层里有:
|
||||
- AI 改写
|
||||
- 文本样式
|
||||
- 复制
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 右侧还没有正式的选区级工具层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 的 `BubbleMenu`
|
||||
- 再叠加自定义菜单逻辑
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.2 多段落改写
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 不是只改单句
|
||||
- 能对连续几段一起润色、扩写、缩写、改语气
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 现有右侧编辑区没有正式的多段落选区改写模型
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 前端提取连续选区纯文本
|
||||
- 前端附带前后文
|
||||
- 后端返回候选纯文本
|
||||
- 前端决定替换或插入
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
|
||||
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.3 替换 / 插入
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 改写结果不是直接覆盖原文
|
||||
- 用户可选择:
|
||||
- 替换
|
||||
- 插入
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前没有候选文本应用层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 建立候选结果预览区
|
||||
- 基于 Tiptap transaction 应用文本
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.4 标题 / 列表 / 引用 / 代码块工具条
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 右侧本身就是正式文档编辑器
|
||||
- 可以快速切换文档结构
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前右侧偏预览,不是正式写作编辑器
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 节点和命令
|
||||
- 在主工具栏中集中承载
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. Novel 参考映射
|
||||
|
||||
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
|
||||
|
||||
- 编辑器主容器的产品感
|
||||
- 工具栏和空态的组织方式
|
||||
- 正文阅读宽度、排版、留白
|
||||
|
||||
## 5.2 对应到 DeerFlow 的实现位置
|
||||
|
||||
- 页面壳:`WritingShell.tsx`
|
||||
- 顶部文档区:`WritingHeader.tsx`
|
||||
- 编辑容器:`TiptapEditor.tsx`
|
||||
- 工具栏:`WritingToolbar.tsx`
|
||||
- 空态:`WritingEmptyState.tsx`
|
||||
|
||||
## 5.3 不建议直接照搬的部分
|
||||
|
||||
- Next.js 页面结构
|
||||
- 依赖其服务端 AI 调用方式
|
||||
- 与 Vercel AI SDK 深绑定的部分
|
||||
|
||||
---
|
||||
|
||||
## 6. open-canvas 参考映射
|
||||
|
||||
## 6.1 主要借鉴对象
|
||||
|
||||
open-canvas 这次最值得借鉴的不是页面外观,而是:
|
||||
|
||||
- AI 工作流组织方式
|
||||
- 改写结果应用节奏
|
||||
- 编辑器和聊天协作的模式
|
||||
|
||||
## 6.2 借鉴重点
|
||||
|
||||
### 借鉴 1:独立工作区思想
|
||||
|
||||
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
|
||||
|
||||
### 借鉴 2:画布状态独立维护
|
||||
|
||||
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
|
||||
|
||||
### 借鉴 3:AI 输出先预览,再应用
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 7. Tiptap 参考映射
|
||||
|
||||
## 7.1 Tiptap 在本项目中的职责
|
||||
|
||||
- 作为右侧正式富文本编辑器内核
|
||||
- 提供结构节点与 marks
|
||||
- 提供 BubbleMenu 能力
|
||||
- 提供 transaction 应用能力
|
||||
|
||||
## 7.2 建议直接参考的实现点
|
||||
|
||||
- `EditorContent` 的封装模式
|
||||
- `BubbleMenu` 的接入方式
|
||||
- heading / list / blockquote / codeBlock 扩展配置
|
||||
- `editor.chain().focus()` 命令调用模式
|
||||
- selection 变化监听
|
||||
|
||||
## 7.3 DeerFlow 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8. DeerFlow 现有文件到新模块的映射
|
||||
|
||||
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 汇总页面路由
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 挂入新的写作助手路由
|
||||
|
||||
### 后续动作
|
||||
|
||||
- 引入 `WritingRoutes`
|
||||
- 增加 `page/writing/*`
|
||||
|
||||
---
|
||||
|
||||
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 左聊天 + 右 Artifact 双栏布局
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 提供双栏工作区组织思路
|
||||
- 可复用布局比例和 panel 交互经验
|
||||
|
||||
### 不建议直接复用的部分
|
||||
|
||||
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 管理文件型 artifact 列表、选中项和开关状态
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 仅作为旧 Artifact 模块保留
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
### 原因
|
||||
|
||||
- 写作编辑器需要的不是文件路径状态
|
||||
- 而是文档状态、选区状态、候选结果状态、工具层状态
|
||||
|
||||
---
|
||||
|
||||
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 聊天输入区
|
||||
- 已有 `writing_mode` 入口
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 保留为用户进入写作模式的现有入口
|
||||
|
||||
### 本次改造建议
|
||||
|
||||
- 写作模式打开时,跳转或唤起新的写作工作区
|
||||
- 不再只停留在“生成 Markdown 文件”这个层面
|
||||
|
||||
---
|
||||
|
||||
## 9. 新模块间的依赖关系
|
||||
|
||||
## 9.1 页面层
|
||||
|
||||
- `WritingRoutes.tsx`
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面路由
|
||||
- 双栏或工作区布局
|
||||
- 上下文装配
|
||||
|
||||
## 9.2 编辑器层
|
||||
|
||||
- `TiptapEditor.tsx`
|
||||
- `TiptapExtensions.ts`
|
||||
- `commands.ts`
|
||||
- `selection.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 编辑器内核
|
||||
- 节点和 marks
|
||||
- 基础命令
|
||||
- 选区抽取
|
||||
|
||||
## 9.3 交互层
|
||||
|
||||
- `WritingToolbar.tsx`
|
||||
- `WritingBubbleMenu.tsx`
|
||||
- `WritingAiMenu.tsx`
|
||||
- `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 工具栏
|
||||
- 浮层
|
||||
- AI 菜单
|
||||
- 候选内容应用
|
||||
|
||||
## 9.4 状态层
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
- `useWritingEditor.ts`
|
||||
- `useWritingSelection.ts`
|
||||
- `useWritingRewrite.ts`
|
||||
- `useWritingToolbar.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 主状态
|
||||
- 选区状态
|
||||
- 改写状态
|
||||
- 工具栏状态
|
||||
|
||||
## 9.5 API 层
|
||||
|
||||
- `api/rewrite.ts`
|
||||
- `api/documents.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 对接后端写作改写接口
|
||||
- 对接文档加载与保存接口
|
||||
|
||||
---
|
||||
|
||||
## 10. 能力到文件的精确映射
|
||||
|
||||
| 能力 | 新文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
|
||||
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
|
||||
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
|
||||
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
|
||||
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
|
||||
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
|
||||
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
|
||||
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
|
||||
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
|
||||
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
|
||||
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
|
||||
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
|
||||
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发时的参考顺序
|
||||
|
||||
建议开发顺序如下:
|
||||
|
||||
1. 先看 `PageRoutes.tsx`,确定路由接法
|
||||
2. 再看 `chat-box.tsx`,理解当前左右布局
|
||||
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
|
||||
4. 再接 `TiptapEditor.tsx`
|
||||
5. 再做 `WritingToolbar.tsx`
|
||||
6. 再做 `WritingBubbleMenu.tsx`
|
||||
7. 再做 `WritingAiMenu.tsx`
|
||||
8. 最后做 `WritingRewritePreview.tsx`
|
||||
|
||||
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
|
||||
|
||||
---
|
||||
|
||||
## 12. 明确哪些是“参考”,哪些是“目标”
|
||||
|
||||
### 12.1 参考
|
||||
|
||||
- 豆包:交互体验参考
|
||||
- Novel:页面和编辑器质感参考
|
||||
- open-canvas:AI 工作流参考
|
||||
- Tiptap:编辑器技术实现参考
|
||||
|
||||
### 12.2 目标
|
||||
|
||||
目标不是复刻任何一个外部项目,而是:
|
||||
|
||||
- 在 DeerFlow 现有架构中
|
||||
- 用 React + Vite + 现有线程体系
|
||||
- 做出一个更成熟的 AI 写作编辑器模式
|
||||
|
||||
---
|
||||
|
||||
## 13. 结论
|
||||
|
||||
这份映射表的核心作用,是避免团队在开发时迷路。
|
||||
|
||||
真正推荐的姿势是:
|
||||
|
||||
- 用豆包定义目标体验
|
||||
- 用 Novel 定义页面质感
|
||||
- 用 open-canvas 定义 AI 工作流
|
||||
- 用 Tiptap 作为编辑器内核
|
||||
- 用 DeerFlow 现有路由、线程、模型体系做宿主
|
||||
|
||||
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 更新时间:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
|
||||
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
|
||||
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
|
||||
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 前端阶段四 | API 适配层 | ✅ 已完成 |
|
||||
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
|
||||
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
|
||||
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
|
||||
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
|
||||
|
||||
**当前状态**:全部 9 个阶段已完成 🎉
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 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/
|
||||
api/
|
||||
client.ts # stub,Phase 5 实现
|
||||
models.ts # stub,Phase 5 实现
|
||||
threads.ts # stub,Phase 5 实现
|
||||
runs.ts # stub,Phase 5 实现
|
||||
components/
|
||||
layout/
|
||||
CanvasShell.tsx # 顶层布局壳,flex-col
|
||||
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
|
||||
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
|
||||
chat/
|
||||
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
|
||||
artifact/
|
||||
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
|
||||
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
|
||||
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
|
||||
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
|
||||
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
|
||||
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
|
||||
contexts/
|
||||
CanvasThreadContext.tsx # threadId、modelName
|
||||
CanvasGraphContext.tsx # messages、artifact、isStreaming
|
||||
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
|
||||
hooks/
|
||||
useCanvasStream.ts # stub,Phase 5 实现
|
||||
useCanvasArtifact.ts # stub,Phase 6 实现
|
||||
useCanvasSelection.ts # stub,Phase 7 实现
|
||||
useCanvasQuickActions.ts # stub,Phase 8 实现
|
||||
pages/
|
||||
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
|
||||
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx # React Router 子路由
|
||||
types/
|
||||
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
|
||||
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
|
||||
selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
quick-action.ts # QuickAction、QuickActionId
|
||||
utils/
|
||||
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
|
||||
selection.ts # stub
|
||||
thread.ts # stub
|
||||
stream.ts # stub
|
||||
quick-actions.ts # stub
|
||||
constants/
|
||||
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
|
||||
```
|
||||
|
||||
### 2.3 核心类型定义
|
||||
|
||||
```ts
|
||||
// types/artifact.ts
|
||||
export type CanvasArtifact = {
|
||||
currentIndex: number; // 从 1 开始
|
||||
contents: CanvasArtifactContent[];
|
||||
};
|
||||
|
||||
export type CanvasArtifactText = {
|
||||
index: number; type: "text"; title: string; fullMarkdown: string;
|
||||
};
|
||||
|
||||
export type CanvasArtifactCode = {
|
||||
index: number; type: "code"; title: string;
|
||||
language: ProgrammingLanguageOptions; code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 2.4 Context 结构
|
||||
|
||||
```tsx
|
||||
// CanvasThreadPage.tsx 的装配顺序
|
||||
<CanvasThreadProvider initialThreadId={thread_id}>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<CanvasShell />
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、已解决的技术难题
|
||||
|
||||
### 3.1 react-resizable-panels v4.6.2 API 差异
|
||||
|
||||
此版本与旧版 API 不同,必须使用:
|
||||
|
||||
```tsx
|
||||
// ✅ 正确写法
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal" // ← 不是 direction
|
||||
groupRef={layoutRef} // ← 不是 ref
|
||||
>
|
||||
<ResizablePanel id="chat" ... /> // ← 必须有 id
|
||||
<ResizablePanel id="artifact" ... /> // ← 必须有 id
|
||||
</ResizablePanelGroup>
|
||||
|
||||
// 切换折叠
|
||||
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
|
||||
```
|
||||
|
||||
### 3.2 Flex 高度链(防止底部内容被裁剪)
|
||||
|
||||
```
|
||||
PageLayout(h-screen)
|
||||
└── main(flex-1)
|
||||
└── CanvasShell div(h-full flex flex-col)
|
||||
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
|
||||
└── ResizablePanelGroup(h-full)
|
||||
└── 面板内容(h-full)
|
||||
```
|
||||
|
||||
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
|
||||
|
||||
### 3.3 折叠/展开按钮
|
||||
|
||||
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
|
||||
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
layoutRef.current?.setLayout(
|
||||
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
|
||||
);
|
||||
}, [isChatCollapsed]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、下一步:后端 ThreadState 扩展
|
||||
|
||||
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
|
||||
|
||||
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
|
||||
|
||||
```python
|
||||
# 新增 TypedDict(建议单独定义)
|
||||
class CanvasArtifactText(TypedDict):
|
||||
index: int
|
||||
type: Literal["text"]
|
||||
title: str
|
||||
fullMarkdown: str
|
||||
|
||||
class CanvasArtifactCode(TypedDict):
|
||||
index: int
|
||||
type: Literal["code"]
|
||||
title: str
|
||||
language: str
|
||||
code: str
|
||||
|
||||
class CanvasArtifact(TypedDict):
|
||||
currentIndex: int
|
||||
contents: list[CanvasArtifactText | CanvasArtifactCode]
|
||||
|
||||
# 在 ThreadState 中新增字段
|
||||
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
|
||||
highlighted_text: Optional[str] # 用户选中的文本片段
|
||||
highlighted_code: Optional[str] # 用户选中的代码片段
|
||||
canvas_mode: Optional[Literal["text", "code"]]
|
||||
quick_action_id: Optional[str] # 快捷动作 id
|
||||
web_search_enabled: bool # 默认 False
|
||||
web_search_results: Optional[list[dict]]
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- 旧线程无 `artifact` 字段时不报错
|
||||
- `pnpm typecheck` 无新增错误(前端字段已对齐)
|
||||
- `make test` 全量回归通过
|
||||
|
||||
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
|
||||
|
||||
---
|
||||
|
||||
## 五、关键文件位置
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
|
||||
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
|
||||
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
|
||||
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
|
||||
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、架构决策记录
|
||||
|
||||
| 决策 | 内容 |
|
||||
|------|------|
|
||||
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
|
||||
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
|
||||
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
|
||||
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
|
||||
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
|
||||
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在集成 open-canvas 到 DeerFlow 项目。
|
||||
参考进度文档:frontend-web/docs/openCanvas-progress.md
|
||||
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
|
||||
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
|
||||
```
|
||||
@@ -21,6 +21,13 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useCuratorConfig,
|
||||
@@ -31,6 +38,7 @@ import {
|
||||
} from "@/core/curator/hooks";
|
||||
import type { CuratorConfig } from "@/core/curator/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SkillEvolutionPanel() {
|
||||
@@ -68,6 +76,7 @@ function StatusCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.status;
|
||||
const { status, isLoading } = useCuratorStatus();
|
||||
const { config } = useCuratorConfig();
|
||||
const runCurator = useRunCurator();
|
||||
|
||||
const lastRunDisplay = useMemo(() => {
|
||||
@@ -79,10 +88,25 @@ function StatusCard() {
|
||||
}
|
||||
}, [status?.last_run_at, tt.lastRunNever]);
|
||||
|
||||
// 总开关或 curator 子开关任一关闭时,手动触发跑不起来 —— 直接禁用按钮。
|
||||
const runDisabled = !config || !config.enabled || !config.curator.enabled;
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
await runCurator.mutateAsync();
|
||||
toast.success(tt.runStarted);
|
||||
const res = await runCurator.mutateAsync();
|
||||
if (res.triggered) {
|
||||
toast.success(tt.runStarted);
|
||||
} else {
|
||||
// 后端预检拦下了(开关未开 / 已有任务在跑)—— 给出对应原因,而不是误报"已开始"。
|
||||
const reasonMsg: Record<string, string> = {
|
||||
evolution_disabled: tt.runBlockedEvolutionDisabled,
|
||||
curator_disabled: tt.runBlockedCuratorDisabled,
|
||||
already_running: tt.runBlockedAlreadyRunning,
|
||||
};
|
||||
toast.warning(
|
||||
reasonMsg[res.reason ?? ""] ?? tt.runBlockedEvolutionDisabled,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -107,7 +131,8 @@ function StatusCard() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={runCurator.isPending}
|
||||
disabled={runCurator.isPending || runDisabled}
|
||||
title={runDisabled ? tt.runDisabledHint : undefined}
|
||||
>
|
||||
{runCurator.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
@@ -154,6 +179,73 @@ function StatusCard() {
|
||||
</span>
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow label={tt.lastActions} className="sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div>
|
||||
<div className="font-medium">{tt.llmSuggested}</div>
|
||||
{status.last_consolidation_suggestions.consolidations.length ===
|
||||
0 &&
|
||||
status.last_consolidation_suggestions.prunings.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{tt.noActions}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
|
||||
{status.last_consolidation_suggestions.consolidations.map(
|
||||
(c, i) => (
|
||||
<li key={`c${i}`}>
|
||||
{(c.from ?? "?") + " → " + (c.into ?? "?")}
|
||||
{c.reason ? `(${c.reason})` : ""}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{status.last_consolidation_suggestions.prunings.map(
|
||||
(p, i) => (
|
||||
<li key={`p${i}`}>
|
||||
{p.name ?? "?"}
|
||||
{p.reason ? `(${p.reason})` : ""}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{tt.actuallyDid}</div>
|
||||
{status.last_actual_actions.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{tt.noActions}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="mt-0.5 flex flex-col gap-1">
|
||||
{status.last_actual_actions.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span className="font-mono">{a.tool ?? "?"}</span>
|
||||
{typeof a.args?.name === "string" && (
|
||||
<span className="text-muted-foreground">
|
||||
{String(a.args.name)}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
a.status === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="px-1 text-[10px]"
|
||||
>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StatRow>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -178,11 +270,15 @@ function StatRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
|
||||
const DEFAULT_MODEL_VALUE = "__default__";
|
||||
|
||||
function ConfigCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.config;
|
||||
const { config, isLoading } = useCuratorConfig();
|
||||
const updateConfig = useUpdateCuratorConfig();
|
||||
const { models } = useModels();
|
||||
|
||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -311,22 +407,35 @@ function ConfigCard() {
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.moderationModelLabel}
|
||||
helper={tt.moderationModelHelper}
|
||||
label={tt.curatorModelLabel}
|
||||
helper={tt.curatorModelHelper}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
className="max-w-[20rem]"
|
||||
value={draft.moderation_model_name ?? ""}
|
||||
placeholder=""
|
||||
onChange={(e) =>
|
||||
<Select
|
||||
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
moderation_model_name:
|
||||
e.target.value.trim() === "" ? null : e.target.value,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="max-w-[20rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_MODEL_VALUE}>
|
||||
{tt.curatorModelDefault}
|
||||
</SelectItem>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldRow>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -28,11 +28,17 @@ export async function updateCuratorConfig(
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
|
||||
export interface RunCuratorResult {
|
||||
triggered: boolean;
|
||||
/** 未触发时的原因代码:evolution_disabled / curator_disabled / already_running。 */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export async function runCurator(): Promise<RunCuratorResult> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.json();
|
||||
return response.json() as Promise<RunCuratorResult>;
|
||||
}
|
||||
|
||||
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
||||
|
||||
@@ -3,14 +3,39 @@ export interface CuratorSubConfig {
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
review_timeout_seconds: number;
|
||||
/** curator 评审使用的模型名;null = 用主模型。 */
|
||||
model_name: string | null;
|
||||
}
|
||||
|
||||
export interface CuratorConfig {
|
||||
enabled: boolean;
|
||||
moderation_model_name: string | null;
|
||||
curator: CuratorSubConfig;
|
||||
}
|
||||
|
||||
export interface CuratorAction {
|
||||
tool: string | null;
|
||||
args: Record<string, unknown>;
|
||||
status: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface CuratorConsolidation {
|
||||
from?: string;
|
||||
into?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CuratorPruning {
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CuratorSuggestions {
|
||||
consolidations: CuratorConsolidation[];
|
||||
prunings: CuratorPruning[];
|
||||
}
|
||||
|
||||
export interface CuratorStatus {
|
||||
enabled: boolean;
|
||||
run_count: number;
|
||||
@@ -23,6 +48,10 @@ export interface CuratorStatus {
|
||||
stale: number;
|
||||
archived: number;
|
||||
};
|
||||
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
|
||||
last_consolidation_suggestions: CuratorSuggestions;
|
||||
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
|
||||
last_actual_actions: CuratorAction[];
|
||||
}
|
||||
|
||||
export interface CuratorLogRecord {
|
||||
|
||||
@@ -567,6 +567,16 @@ export const enUS: Translations = {
|
||||
total: "Total",
|
||||
runNow: "Run now",
|
||||
runStarted: "Background run started",
|
||||
runBlockedEvolutionDisabled:
|
||||
"Skill evolution is off — enable it below to run the curator.",
|
||||
runBlockedCuratorDisabled:
|
||||
"The curator is off — enable it below to run it manually.",
|
||||
runBlockedAlreadyRunning: "A curator run is already in progress.",
|
||||
runDisabledHint: "Enable skill evolution and the curator below first.",
|
||||
lastActions: "Last run",
|
||||
llmSuggested: "LLM suggested (self-report)",
|
||||
actuallyDid: "Actually done (tool calls)",
|
||||
noActions: "None",
|
||||
},
|
||||
config: {
|
||||
title: "Configuration",
|
||||
@@ -587,9 +597,10 @@ export const enUS: Translations = {
|
||||
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||
archiveAfterDaysHelper:
|
||||
"Stale skills inactive for this many additional days are archived.",
|
||||
moderationModelLabel: "Moderation model (optional)",
|
||||
moderationModelHelper:
|
||||
"Leave blank to use the default chat model.",
|
||||
curatorModelLabel: "Curator review model",
|
||||
curatorModelHelper:
|
||||
"Model for the curator's LLM review and skill security moderation. Defaults to the primary model.",
|
||||
curatorModelDefault: "Primary model (default)",
|
||||
save: "Save changes",
|
||||
saveSuccess: "Configuration updated",
|
||||
reset: "Reset",
|
||||
|
||||
@@ -490,6 +490,14 @@ export interface Translations {
|
||||
total: string;
|
||||
runNow: string;
|
||||
runStarted: string;
|
||||
runBlockedEvolutionDisabled: string;
|
||||
runBlockedCuratorDisabled: string;
|
||||
runBlockedAlreadyRunning: string;
|
||||
runDisabledHint: string;
|
||||
lastActions: string;
|
||||
llmSuggested: string;
|
||||
actuallyDid: string;
|
||||
noActions: string;
|
||||
};
|
||||
config: {
|
||||
title: string;
|
||||
@@ -504,8 +512,9 @@ export interface Translations {
|
||||
staleAfterDaysHelper: string;
|
||||
archiveAfterDaysLabel: string;
|
||||
archiveAfterDaysHelper: string;
|
||||
moderationModelLabel: string;
|
||||
moderationModelHelper: string;
|
||||
curatorModelLabel: string;
|
||||
curatorModelHelper: string;
|
||||
curatorModelDefault: string;
|
||||
save: string;
|
||||
saveSuccess: string;
|
||||
reset: string;
|
||||
|
||||
@@ -541,6 +541,14 @@ export const zhCN: Translations = {
|
||||
total: "合计",
|
||||
runNow: "立即执行",
|
||||
runStarted: "已触发后台执行",
|
||||
runBlockedEvolutionDisabled: "自我进化未开启 —— 请先在下方打开开关。",
|
||||
runBlockedCuratorDisabled: "curator 未开启 —— 请先在下方打开 curator 开关。",
|
||||
runBlockedAlreadyRunning: "已有一次 curator 运行进行中。",
|
||||
runDisabledHint: "请先在下方开启自我进化与 curator 开关。",
|
||||
lastActions: "最近一次运行",
|
||||
llmSuggested: "LLM 建议(自述)",
|
||||
actuallyDid: "实际执行(工具调用)",
|
||||
noActions: "无",
|
||||
},
|
||||
config: {
|
||||
title: "配置",
|
||||
@@ -556,8 +564,9 @@ export const zhCN: Translations = {
|
||||
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||
archiveAfterDaysLabel: "归档阈值(天)",
|
||||
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||
moderationModelLabel: "安全审核模型(可选)",
|
||||
moderationModelHelper: "为空时使用默认主模型。",
|
||||
curatorModelLabel: "技能优化模型",
|
||||
curatorModelHelper: "用于 curator 技能评审和技能安全审核的模型,为空则使用主模型。",
|
||||
curatorModelDefault: "主模型(默认)",
|
||||
save: "保存修改",
|
||||
saveSuccess: "配置已更新",
|
||||
reset: "重置",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { getAPIClient } from "@/core/api";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { CanvasModel } from "../types/model";
|
||||
|
||||
interface BackendModel {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
provider?: string;
|
||||
is_local?: boolean;
|
||||
supports_vision?: boolean;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
supports_tool_calling?: boolean;
|
||||
// Canvas-phase-9 fields (now returned by backend)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function loadCanvasModels(): Promise<CanvasModel[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/models`);
|
||||
const data = (await res.json()) as { models?: BackendModel[] };
|
||||
return (data.models ?? []).map(
|
||||
(m): CanvasModel => ({
|
||||
name: m.name,
|
||||
display_name: m.display_name ?? m.name,
|
||||
provider: m.provider,
|
||||
is_local: m.is_local,
|
||||
supports_vision: m.supports_vision,
|
||||
supports_thinking: m.supports_thinking,
|
||||
supports_reasoning_effort: m.supports_reasoning_effort,
|
||||
supports_tool_calling: m.supports_tool_calling ?? true,
|
||||
supports_canvas_generation: true,
|
||||
supports_canvas_rewrite: true,
|
||||
supports_canvas_code_edit: true,
|
||||
supports_streaming_text: true,
|
||||
supports_streaming_tool_calls: m.supports_tool_calling ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { getAPIClient } from "@/core/api";
|
||||
|
||||
import { CANVAS_AGENT_ID } from "../constants";
|
||||
import type { CanvasArtifact } from "../types/artifact";
|
||||
import type { CanvasMessage } from "../types/thread";
|
||||
|
||||
export type CanvasRunInput = {
|
||||
messages: Array<{ type: string; content: string }>;
|
||||
canvas_mode?: "text" | "code";
|
||||
highlighted_text?: string;
|
||||
highlighted_code?: string;
|
||||
quick_action_id?: string;
|
||||
};
|
||||
|
||||
/** Partial artifact data extracted from streaming tool_call_chunks */
|
||||
export type StreamingArtifactPartial = {
|
||||
type?: "text" | "code";
|
||||
title?: string;
|
||||
content?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type CanvasStreamCallbacks = {
|
||||
onMessage?: (msg: CanvasMessage) => void;
|
||||
onArtifact?: (artifact: CanvasArtifact) => void;
|
||||
/** Called repeatedly during streaming as update_artifact args accumulate */
|
||||
onStreamingArtifact?: (partial: StreamingArtifactPartial) => void;
|
||||
onDone?: () => void;
|
||||
onError?: (err: unknown) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming JSON arg extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function unescapeJsonFragment(raw: string): string {
|
||||
// Remove trailing incomplete escape sequence
|
||||
const safe = raw.replace(/\\[^"nrts\\bfu]?$/, "");
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < safe.length) {
|
||||
if (safe[i] === "\\" && i + 1 < safe.length) {
|
||||
const next = safe[i + 1]!;
|
||||
if (next === "n") { out += "\n"; i += 2; }
|
||||
else if (next === "r") { out += "\r"; i += 2; }
|
||||
else if (next === "t") { out += "\t"; i += 2; }
|
||||
else if (next === '"') { out += '"'; i += 2; }
|
||||
else if (next === "\\") { out += "\\"; i += 2; }
|
||||
else if (next === "b") { out += "\b"; i += 2; }
|
||||
else if (next === "f") { out += "\f"; i += 2; }
|
||||
else { out += safe[i]!; i++; }
|
||||
} else if (safe[i] === '"') {
|
||||
// Unescaped closing quote — content value ends here
|
||||
break;
|
||||
} else {
|
||||
out += safe[i]!;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extract a fully-quoted string field, e.g. "type": "text" */
|
||||
function extractStringField(args: string, field: string): string | undefined {
|
||||
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, "");
|
||||
const m = re.exec(args);
|
||||
if (!m) return undefined;
|
||||
return unescapeJsonFragment(m[1]!);
|
||||
}
|
||||
|
||||
/** Extract the content field even from incomplete JSON (no closing quote yet) */
|
||||
function extractContentField(args: string): string | undefined {
|
||||
const keyIdx = args.indexOf('"content"');
|
||||
if (keyIdx === -1) return undefined;
|
||||
const afterKey = args.slice(keyIdx + '"content"'.length);
|
||||
const colonQuote = /^\s*:\s*"/.exec(afterKey);
|
||||
if (!colonQuote) return undefined;
|
||||
const valueStart = colonQuote[0].length;
|
||||
const raw = afterKey.slice(valueStart);
|
||||
return unescapeJsonFragment(raw);
|
||||
}
|
||||
|
||||
function extractPartialArtifactArgs(args: string): StreamingArtifactPartial {
|
||||
return {
|
||||
type: extractStringField(args, "type") as "text" | "code" | undefined,
|
||||
title: extractStringField(args, "title"),
|
||||
language: extractStringField(args, "language"),
|
||||
content: extractContentField(args),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main stream function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function streamCanvasRun(
|
||||
threadId: string,
|
||||
input: CanvasRunInput,
|
||||
callbacks: CanvasStreamCallbacks,
|
||||
modelName?: string | null,
|
||||
): Promise<void> {
|
||||
const client = getAPIClient();
|
||||
try {
|
||||
const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, {
|
||||
input,
|
||||
streamMode: ["values", "messages-tuple"],
|
||||
...(modelName ? { config: { configurable: { model_name: modelName } } } : {}),
|
||||
});
|
||||
|
||||
// Accumulate tool call args by index position
|
||||
const toolCallAccumulator: Record<number, { name: string; args: string }> = {};
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const event = chunk.event as string;
|
||||
const data = (chunk as { event: string; data: unknown }).data;
|
||||
|
||||
if (event === "values") {
|
||||
const state = data as Record<string, unknown>;
|
||||
if (state.artifact) {
|
||||
callbacks.onArtifact?.(state.artifact as CanvasArtifact);
|
||||
}
|
||||
} else if (event === "messages") {
|
||||
const tuple = data as [unknown, unknown] | unknown;
|
||||
const rawMsg = Array.isArray(tuple) ? tuple[0] : tuple;
|
||||
if (!rawMsg || typeof rawMsg !== "object") continue;
|
||||
|
||||
const msg = rawMsg as Record<string, unknown>;
|
||||
|
||||
// Emit text content to chat panel
|
||||
callbacks.onMessage?.(msg as unknown as CanvasMessage);
|
||||
|
||||
// ---- Streaming tool call args (tool_call_chunks) ----
|
||||
const toolCallChunks = msg.tool_call_chunks;
|
||||
if (Array.isArray(toolCallChunks) && toolCallChunks.length > 0) {
|
||||
for (const tcChunk of toolCallChunks as Array<Record<string, unknown>>) {
|
||||
const idx = (tcChunk.index as number | undefined) ?? 0;
|
||||
if (!toolCallAccumulator[idx]) {
|
||||
toolCallAccumulator[idx] = {
|
||||
name: (tcChunk.name as string | undefined) ?? "",
|
||||
args: "",
|
||||
};
|
||||
} else if (tcChunk.name) {
|
||||
toolCallAccumulator[idx]!.name = tcChunk.name as string;
|
||||
}
|
||||
toolCallAccumulator[idx]!.args += (tcChunk.args as string | undefined) ?? "";
|
||||
|
||||
if (toolCallAccumulator[idx]!.name === "update_artifact") {
|
||||
const partial = extractPartialArtifactArgs(toolCallAccumulator[idx]!.args);
|
||||
if (partial.content !== undefined || partial.title !== undefined) {
|
||||
callbacks.onStreamingArtifact?.(partial);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Complete tool_calls (non-streaming or final snapshot) ----
|
||||
const toolCalls = msg.tool_calls;
|
||||
if (Array.isArray(toolCalls)) {
|
||||
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||
if (tc.name === "update_artifact" && tc.args) {
|
||||
try {
|
||||
const args =
|
||||
typeof tc.args === "string"
|
||||
? (JSON.parse(tc.args) as Record<string, unknown>)
|
||||
: (tc.args as Record<string, unknown>);
|
||||
callbacks.onStreamingArtifact?.({
|
||||
type: args.type as "text" | "code" | undefined,
|
||||
title: args.title as string | undefined,
|
||||
content: args.content as string | undefined,
|
||||
language: args.language as string | undefined,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
callbacks.onDone?.();
|
||||
} catch (err) {
|
||||
callbacks.onError?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopCanvasRun(
|
||||
threadId: string,
|
||||
runId: string,
|
||||
): Promise<void> {
|
||||
const client = getAPIClient();
|
||||
await client.runs.cancel(threadId, runId);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getAPIClient } from "@/core/api";
|
||||
|
||||
import { CANVAS_THREAD_METADATA } from "../constants";
|
||||
import type { CanvasThreadMetadata, CanvasThreadValues } from "../types/thread";
|
||||
|
||||
export async function createCanvasThread(
|
||||
metadata?: Partial<CanvasThreadMetadata>,
|
||||
): Promise<string> {
|
||||
const client = getAPIClient();
|
||||
const thread = await client.threads.create({
|
||||
metadata: { ...CANVAS_THREAD_METADATA, ...metadata },
|
||||
});
|
||||
return thread.thread_id;
|
||||
}
|
||||
|
||||
export async function getCanvasThread(
|
||||
threadId: string,
|
||||
): Promise<CanvasThreadValues | null> {
|
||||
const client = getAPIClient();
|
||||
try {
|
||||
const state =
|
||||
await client.threads.getState<CanvasThreadValues>(threadId);
|
||||
return (state.values as CanvasThreadValues) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCanvasThreadState(
|
||||
threadId: string,
|
||||
values: Partial<CanvasThreadValues>,
|
||||
): Promise<void> {
|
||||
const client = getAPIClient();
|
||||
await client.threads.updateState(threadId, { values });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FileText, Code2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { getCurrentArtifactContent } from "../../utils/artifact";
|
||||
import { CanvasArtifactVersionNav } from "./CanvasArtifactVersionNav";
|
||||
|
||||
export function CanvasArtifactHeader() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
const isCode = content.type === "code";
|
||||
const Icon = isCode ? Code2 : FileText;
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b flex items-center px-4 gap-2 shrink-0 bg-background">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
||||
<span className="text-sm font-medium truncate flex-1 min-w-0">
|
||||
{content.title}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5">
|
||||
{isCode ? (content as { language: string }).language : "文本"}
|
||||
</Badge>
|
||||
<CanvasArtifactVersionNav />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import type { StreamingArtifactPartial } from "../../api/runs";
|
||||
import {
|
||||
getCurrentArtifactContent,
|
||||
isCodeArtifact,
|
||||
} from "../../utils/artifact";
|
||||
import { CanvasArtifactHeader } from "./CanvasArtifactHeader";
|
||||
import { CanvasEmptyState } from "./CanvasEmptyState";
|
||||
import { CanvasQuickActionBar } from "./CanvasQuickActionBar";
|
||||
import { TextArtifactRenderer } from "./TextArtifactRenderer";
|
||||
import { CodeArtifactRenderer } from "./CodeArtifactRenderer";
|
||||
import type {
|
||||
CanvasArtifactCode,
|
||||
CanvasArtifactText,
|
||||
} from "../../types/artifact";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingOverlay — shown while update_artifact args are streaming in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StreamingTextPreview({ partial }: { partial: StreamingArtifactPartial }) {
|
||||
const hasContent = partial.content && partial.content.trim();
|
||||
|
||||
if (!hasContent) {
|
||||
// Args started but content not yet arrived — show skeleton
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
<div className="space-y-1 text-center">
|
||||
{partial.title ? (
|
||||
<p className="text-sm font-medium text-foreground">{partial.title}</p>
|
||||
) : null}
|
||||
<p className="text-xs">AI 正在生成文档…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stream content into the real renderer (read-only, isStreaming = true)
|
||||
if (partial.type === "code") {
|
||||
const fakeContent: CanvasArtifactCode = {
|
||||
index: 0,
|
||||
type: "code",
|
||||
title: partial.title ?? "代码",
|
||||
language: (partial.language as CanvasArtifactCode["language"]) ?? "other",
|
||||
code: partial.content ?? "",
|
||||
};
|
||||
return <CodeArtifactRenderer content={fakeContent} isStreaming />;
|
||||
}
|
||||
|
||||
const fakeContent: CanvasArtifactText = {
|
||||
index: 0,
|
||||
type: "text",
|
||||
title: partial.title ?? "文档",
|
||||
fullMarkdown: partial.content ?? "",
|
||||
};
|
||||
return <TextArtifactRenderer content={fakeContent} isStreaming />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasArtifactPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasArtifactPanel() {
|
||||
const { artifact, streamingArtifact, isStreaming } = useCanvasGraphContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialType = searchParams.get("type") as "text" | "code" | null;
|
||||
|
||||
// While streaming and we have partial data, show the streaming preview
|
||||
if (isStreaming && streamingArtifact) {
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b shrink-0 bg-muted/30">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{streamingArtifact.title ?? "正在生成…"}
|
||||
</span>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary shrink-0" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StreamingTextPreview partial={streamingArtifact} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10">
|
||||
<CanvasEmptyState initialType={initialType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<CanvasArtifactHeader />
|
||||
<CanvasQuickActionBar />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{content && isCodeArtifact(content) ? (
|
||||
<CodeArtifactRenderer content={content as CanvasArtifactCode} />
|
||||
) : content ? (
|
||||
<TextArtifactRenderer content={content as CanvasArtifactText} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { setCurrentArtifactIndex } from "../../utils/artifact";
|
||||
|
||||
export function CanvasArtifactVersionNav() {
|
||||
const { artifact, setArtifact } = useCanvasGraphContext();
|
||||
|
||||
if (!artifact || artifact.contents.length <= 1) return null;
|
||||
|
||||
const total = artifact.contents.length;
|
||||
const current = artifact.currentIndex;
|
||||
const canGoPrev = current > 1;
|
||||
const canGoNext = current < total;
|
||||
|
||||
function go(delta: -1 | 1) {
|
||||
if (!artifact) return;
|
||||
setArtifact(setCurrentArtifactIndex(artifact, current + delta));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => go(-1)}
|
||||
disabled={!canGoPrev}
|
||||
title="上一个版本"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-10 text-center">
|
||||
{current}/{total}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => go(1)}
|
||||
disabled={!canGoNext}
|
||||
title="下一个版本"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { FileText, Code2, Globe, Atom, Terminal } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { createDraftArtifact } from "../../utils/artifact";
|
||||
import type { ProgrammingLanguageOptions } from "../../types/artifact";
|
||||
|
||||
const HTML_TEMPLATE = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>页面标题</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
<p>在这里开始编写你的页面</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const REACT_TEMPLATE = `import React from "react";
|
||||
|
||||
interface Props {
|
||||
// 在这里定义 props
|
||||
}
|
||||
|
||||
export default function MyComponent({}: Props) {
|
||||
return (
|
||||
<div>
|
||||
<h1>My Component</h1>
|
||||
<p>在这里开始编写你的组件</p>
|
||||
</div>
|
||||
);
|
||||
}`;
|
||||
|
||||
const PYTHON_TEMPLATE = `def main():
|
||||
"""主函数"""
|
||||
print("Hello, World!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()`;
|
||||
|
||||
interface QuickStartOption {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "text" | "code";
|
||||
templateTitle: string;
|
||||
content: string;
|
||||
language?: ProgrammingLanguageOptions;
|
||||
}
|
||||
|
||||
const QUICK_START_OPTIONS: QuickStartOption[] = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: "空白文档",
|
||||
description: "从空白 Markdown 文档开始",
|
||||
type: "text",
|
||||
templateTitle: "未命名文档",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
icon: Code2,
|
||||
title: "空白代码",
|
||||
description: "从空白代码文件开始",
|
||||
type: "code",
|
||||
templateTitle: "未命名代码",
|
||||
content: "",
|
||||
language: "python",
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: "HTML 页面",
|
||||
description: "基础 HTML 页面模板",
|
||||
type: "code",
|
||||
templateTitle: "HTML 页面",
|
||||
content: HTML_TEMPLATE,
|
||||
language: "html",
|
||||
},
|
||||
{
|
||||
icon: Atom,
|
||||
title: "React 组件",
|
||||
description: "TypeScript React 组件模板",
|
||||
type: "code",
|
||||
templateTitle: "React 组件",
|
||||
content: REACT_TEMPLATE,
|
||||
language: "typescript",
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: "Python 脚本",
|
||||
description: "Python 入口脚本模板",
|
||||
type: "code",
|
||||
templateTitle: "Python 脚本",
|
||||
content: PYTHON_TEMPLATE,
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
interface CanvasEmptyStateProps {
|
||||
initialType?: "text" | "code" | null;
|
||||
}
|
||||
|
||||
export function CanvasEmptyState({ initialType }: CanvasEmptyStateProps) {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
|
||||
function handleSelect(option: QuickStartOption) {
|
||||
setArtifact(
|
||||
createDraftArtifact(option.type, {
|
||||
title: option.templateTitle,
|
||||
content: option.content,
|
||||
language: option.language,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
initialType
|
||||
? QUICK_START_OPTIONS.filter(
|
||||
(o) => !initialType || o.type === initialType || o.type === "text",
|
||||
)
|
||||
: QUICK_START_OPTIONS;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">选择模板快速开始</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
或直接在左侧聊天区发送消息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{QUICK_START_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isHighlighted = initialType && option.type === initialType && option.content === "";
|
||||
return (
|
||||
<button
|
||||
key={option.title}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`w-full flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all hover:border-primary/50 hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
isHighlighted
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{option.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasQuickActions } from "../../hooks/useCanvasQuickActions";
|
||||
import type { QuickAction } from "../../types/quick-action";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import { QUICK_ACTIONS_CODE, QUICK_ACTIONS_TEXT } from "../../constants";
|
||||
|
||||
export function CanvasQuickActionBar() {
|
||||
const { artifact, isStreaming } = useCanvasGraphContext();
|
||||
const { triggerQuickAction } = useCanvasQuickActions();
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
if (!content) return null;
|
||||
|
||||
const actions: QuickAction[] = isCodeArtifact(content)
|
||||
? QUICK_ACTIONS_CODE
|
||||
: QUICK_ACTIONS_TEXT;
|
||||
|
||||
return (
|
||||
<div className="border-b px-4 py-1.5 flex items-center gap-1.5 flex-wrap shrink-0 bg-background">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
disabled={isStreaming}
|
||||
onClick={() => void triggerQuickAction(action.id)}
|
||||
className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors
|
||||
bg-background text-foreground hover:bg-accent hover:text-accent-foreground
|
||||
disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type {
|
||||
CanvasArtifactCode,
|
||||
ProgrammingLanguageOptions,
|
||||
} from "../../types/artifact";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Themes — match code-editor.tsx defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const darkTheme = monokaiInit({
|
||||
settings: {
|
||||
background: "transparent",
|
||||
gutterBackground: "transparent",
|
||||
gutterForeground: "#555",
|
||||
gutterActiveForeground: "#fff",
|
||||
fontSize: "var(--text-sm)",
|
||||
},
|
||||
});
|
||||
|
||||
const lightTheme = basicLightInit({
|
||||
settings: {
|
||||
background: "transparent",
|
||||
fontSize: "var(--text-sm)",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getLanguageExtension(
|
||||
lang: ProgrammingLanguageOptions,
|
||||
): Extension | null {
|
||||
switch (lang) {
|
||||
case "typescript":
|
||||
return javascript({ typescript: true, jsx: true });
|
||||
case "javascript":
|
||||
return javascript({ jsx: true });
|
||||
case "python":
|
||||
return python();
|
||||
case "html":
|
||||
return html();
|
||||
case "json":
|
||||
return json();
|
||||
// cpp, java, rust, sql, xml, clojure, csharp, php → plain text fallback
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CodeArtifactRendererProps {
|
||||
content: CanvasArtifactCode;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function CodeArtifactRenderer({ content, isStreaming: _isStreaming }: CodeArtifactRendererProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { handleCodeSelectionChange } = useCanvasSelection();
|
||||
|
||||
const extensions = useMemo<Extension[]>(() => {
|
||||
const ext = getLanguageExtension(content.language);
|
||||
return ext ? [ext] : [];
|
||||
}, [content.language]);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(update: ViewUpdate) => {
|
||||
if (!update.selectionSet) return;
|
||||
const { from, to } = update.state.selection.main;
|
||||
handleCodeSelectionChange(from, to);
|
||||
},
|
||||
[handleCodeSelectionChange],
|
||||
);
|
||||
|
||||
if (!content.code) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
空白代码,发送消息让 AI 填充内容
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<CodeMirror
|
||||
value={content.code}
|
||||
readOnly
|
||||
height="100%"
|
||||
className="h-full font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!"
|
||||
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
|
||||
extensions={extensions}
|
||||
onUpdate={handleUpdate}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
autocompletion: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import type { CanvasArtifactText } from "../../types/artifact";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
|
||||
interface TextArtifactRendererProps {
|
||||
content: CanvasArtifactText;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { handleTextPointerUp } = useCanvasSelection();
|
||||
|
||||
if (!content.fullMarkdown) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
空白文档,发送消息让 AI 填充内容
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-8 py-8">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-7 text-sm text-foreground/90">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-7">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-foreground/80">{children}</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { useCanvasStream } from "../../hooks/useCanvasStream";
|
||||
import type { CanvasMessage } from "../../types/thread";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import {
|
||||
buildMessageWithSelection,
|
||||
hasActiveSelection,
|
||||
resolveCodeSelection,
|
||||
} from "../../utils/selection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ClarificationPayload = {
|
||||
question?: string;
|
||||
context?: string | null;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripThinkBlocks(text: string): string {
|
||||
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
||||
}
|
||||
|
||||
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
|
||||
const c = msg.additional_kwargs?.clarification;
|
||||
if (typeof c !== "object" || c === null) return null;
|
||||
return c as ClarificationPayload;
|
||||
}
|
||||
|
||||
function getTextContent(msg: CanvasMessage): string {
|
||||
return typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClarificationCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ClarificationCard({
|
||||
message,
|
||||
disabled,
|
||||
onSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
disabled: boolean;
|
||||
onSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message)!;
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const options = (payload.options ?? []).filter(
|
||||
(o) => typeof o.label === "string" && o.label.trim(),
|
||||
);
|
||||
|
||||
const busy = disabled || submitted;
|
||||
|
||||
function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setSubmitted(true);
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
|
||||
const fallback = stripThinkBlocks(getTextContent(message));
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
|
||||
{payload.context ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 font-medium">
|
||||
{payload.question || fallback}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id || option.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={busy}
|
||||
onClick={() => submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span className="text-sm">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allow_custom !== false ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={customAnswer}
|
||||
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
disabled={busy}
|
||||
className="min-h-20 resize-none text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !customAnswer.trim()}
|
||||
onClick={() => submitAnswer(customAnswer)}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
发送回答
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiMessageContent — renders a single AI message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AiMessageContent({
|
||||
message,
|
||||
isActiveStream,
|
||||
isStreaming,
|
||||
onClarificationSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
isActiveStream: boolean;
|
||||
isStreaming: boolean;
|
||||
onClarificationSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
|
||||
const clarification = getClarificationPayload(message);
|
||||
|
||||
if (clarification) {
|
||||
return (
|
||||
<ClarificationCard
|
||||
message={message}
|
||||
disabled={isStreaming}
|
||||
onSubmit={onClarificationSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = stripThinkBlocks(getTextContent(message));
|
||||
if (!displayText) return null;
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
isLoading={isActiveStream}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatWelcome
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChatWelcome() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-6 space-y-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">开始创作</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
发送消息让 AI 帮你生成文档或代码,
|
||||
<br />
|
||||
或在右侧选择快速开始模板
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasChatPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasChatPanel() {
|
||||
const { messages, isStreaming, artifact } = useCanvasGraphContext();
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { selection, clearSelection } = useCanvasSelection();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
const hasSelection = hasActiveSelection(selection);
|
||||
|
||||
// Resolve selected code snippet when artifact is a code artifact
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
const resolvedCode =
|
||||
selection.codeSelection && currentContent && isCodeArtifact(currentContent)
|
||||
? resolveCodeSelection(
|
||||
currentContent.code,
|
||||
selection.codeSelection.startCharIndex,
|
||||
selection.codeSelection.endCharIndex,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Build a short preview for the selection badge
|
||||
const selectionPreview = (() => {
|
||||
if (selection.textSelection?.selectedText) {
|
||||
const t = selection.textSelection.selectedText;
|
||||
return t.length > 40 ? t.slice(0, 40) + "…" : t;
|
||||
}
|
||||
if (resolvedCode) {
|
||||
return resolvedCode.length > 40
|
||||
? resolvedCode.slice(0, 40) + "…"
|
||||
: resolvedCode;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Scroll to bottom whenever messages change or streaming state updates
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
setInput("");
|
||||
|
||||
const finalText = hasSelection
|
||||
? buildMessageWithSelection(text, selection, resolvedCode)
|
||||
: text;
|
||||
|
||||
clearSelection();
|
||||
await sendMessage(finalText);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the last AI message — used to decide which message gets streaming animation
|
||||
const lastAiIndex = (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.type !== "human") return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
|
||||
// Whether the last AI message has any visible text (not just think blocks / empty)
|
||||
const lastAiHasVisibleContent = (() => {
|
||||
if (lastAiIndex < 0) return false;
|
||||
const msg = messages[lastAiIndex]!;
|
||||
if (getClarificationPayload(msg)) return true;
|
||||
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{hasMessages ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((msg, i) => {
|
||||
const isHuman = msg.type === "human";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id ?? i}
|
||||
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman ? (
|
||||
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
|
||||
{getTextContent(msg)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
<AiMessageContent
|
||||
message={msg}
|
||||
isActiveStream={isStreaming && i === lastAiIndex}
|
||||
isStreaming={isStreaming}
|
||||
onClarificationSubmit={(answer) => void sendMessage(answer)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
||||
<User className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreaming && !lastAiHasVisibleContent && (
|
||||
<div className="flex gap-2 justify-start">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">正在生成…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
) : (
|
||||
<ChatWelcome />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection badge */}
|
||||
{hasSelection && selectionPreview && (
|
||||
<div className="px-3 pt-2 shrink-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{selection.textSelection ? "选中文本:" : "选中代码:"}
|
||||
</span>
|
||||
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
|
||||
aria-label="清除选区"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t p-3 shrink-0">
|
||||
<div className="flex gap-2 items-end">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
hasSelection
|
||||
? "针对选中内容发出指令…"
|
||||
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
|
||||
}
|
||||
className="min-h-[40px] max-h-[160px] resize-none text-sm"
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={!input.trim() || isStreaming}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Loader2, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { getCurrentArtifactContent } from "../../utils/artifact";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
|
||||
export function CanvasHeader() {
|
||||
const navigate = useNavigate();
|
||||
const { threadId } = useCanvasThreadContext();
|
||||
const { artifact, isStreaming } = useCanvasGraphContext();
|
||||
const { isChatCollapsed, setIsChatCollapsed } = useCanvasUIContext();
|
||||
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
const title = currentContent?.title ?? "新 Canvas";
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => navigate("/page/canvas")}
|
||||
title="返回 Canvas 列表"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setIsChatCollapsed(!isChatCollapsed)}
|
||||
>
|
||||
{isChatCollapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isChatCollapsed ? "展开聊天区" : "收起聊天区"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate">{title}</span>
|
||||
{isStreaming && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CanvasModelSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
|
||||
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
|
||||
|
||||
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
|
||||
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
|
||||
|
||||
export function CanvasResizableLayout() {
|
||||
const { isChatCollapsed } = useCanvasUIContext();
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
layoutRef.current?.setLayout(
|
||||
isChatCollapsed ? LAYOUT_COLLAPSED : LAYOUT_DEFAULT,
|
||||
);
|
||||
}, [isChatCollapsed]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
className="h-full"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="chat"
|
||||
defaultSize={35}
|
||||
minSize={0}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<CanvasChatPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
id="artifact"
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<CanvasArtifactPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getCanvasThread } from "../../api/threads";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import { CanvasHeader } from "./CanvasHeader";
|
||||
import { CanvasResizableLayout } from "./CanvasResizableLayout";
|
||||
|
||||
export function CanvasShell() {
|
||||
const { threadId, setThreadId } = useCanvasThreadContext();
|
||||
const { setMessages, setArtifact } = useCanvasGraphContext();
|
||||
|
||||
// Load existing thread state when navigating to a saved thread.
|
||||
// If the thread doesn't exist in the backend (e.g. pre-generated UUID from
|
||||
// CanvasHomePage or stale URL after a DB reset), clear threadId so that
|
||||
// the next sendMessage call creates a fresh thread instead of hitting 404.
|
||||
useEffect(() => {
|
||||
if (!threadId) return;
|
||||
let cancelled = false;
|
||||
|
||||
getCanvasThread(threadId)
|
||||
.then((values) => {
|
||||
if (cancelled) return;
|
||||
if (!values) {
|
||||
setThreadId(null);
|
||||
return;
|
||||
}
|
||||
if (values.messages?.length) {
|
||||
setMessages(values.messages);
|
||||
}
|
||||
if (values.artifact) {
|
||||
setArtifact(values.artifact);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[canvas] failed to load thread state:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [threadId, setMessages, setArtifact, setThreadId]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<CanvasHeader />
|
||||
<CanvasResizableLayout />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { loadCanvasModels } from "../../api/models";
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import type { CanvasModel } from "../../types/model";
|
||||
|
||||
export function CanvasModelSelector() {
|
||||
const { modelName, setModelName } = useCanvasThreadContext();
|
||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCanvasModels()
|
||||
.then((list) => {
|
||||
setModels(list);
|
||||
if (!modelName && list.length > 0) {
|
||||
setModelName(list[0].name);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// fail silently — backend may be unavailable during dev
|
||||
});
|
||||
// only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const selected = models.find((m) => m.name === modelName) ?? models[0];
|
||||
|
||||
return (
|
||||
<Select value={selected.name} onValueChange={setModelName}>
|
||||
<SelectTrigger className="h-7 text-xs border-dashed w-auto min-w-[120px] max-w-[200px] gap-1.5">
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{selected.is_local && (
|
||||
<Cpu className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{selected.display_name ?? selected.name}
|
||||
</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{m.display_name ?? m.name}</span>
|
||||
{m.is_local && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1 shrink-0">
|
||||
本地
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ProgrammingLanguageOptions } from "../types/artifact";
|
||||
import type { QuickAction } from "../types/quick-action";
|
||||
|
||||
export const CANVAS_AGENT_ID = "canvas_agent";
|
||||
|
||||
export const CANVAS_THREAD_METADATA = {
|
||||
ui_mode: "canvas",
|
||||
agent_id: CANVAS_AGENT_ID,
|
||||
} as const;
|
||||
|
||||
export const PROGRAMMING_LANGUAGES: Array<{
|
||||
language: ProgrammingLanguageOptions;
|
||||
label: string;
|
||||
}> = [
|
||||
{ language: "typescript", label: "TypeScript" },
|
||||
{ language: "javascript", label: "JavaScript" },
|
||||
{ language: "python", label: "Python" },
|
||||
{ language: "html", label: "HTML" },
|
||||
{ language: "cpp", label: "C++" },
|
||||
{ language: "java", label: "Java" },
|
||||
{ language: "rust", label: "Rust" },
|
||||
{ language: "sql", label: "SQL" },
|
||||
{ language: "json", label: "JSON" },
|
||||
{ language: "php", label: "PHP" },
|
||||
{ language: "xml", label: "XML" },
|
||||
{ language: "clojure", label: "Clojure" },
|
||||
{ language: "csharp", label: "C#" },
|
||||
{ language: "other", label: "Other" },
|
||||
];
|
||||
|
||||
export const QUICK_ACTIONS_TEXT: QuickAction[] = [
|
||||
{ id: "expand", label: "扩写", applicable_to: "text" },
|
||||
{ id: "shorten", label: "精简", applicable_to: "text" },
|
||||
{ id: "formalize", label: "改正式语气", applicable_to: "text" },
|
||||
{ id: "casualize", label: "改口语语气", applicable_to: "text" },
|
||||
];
|
||||
|
||||
export const QUICK_ACTIONS_CODE: QuickAction[] = [
|
||||
{ id: "add_comments", label: "加注释", applicable_to: "code" },
|
||||
{ id: "add_logs", label: "加日志", applicable_to: "code" },
|
||||
{ id: "fix_bugs", label: "修复 Bug", applicable_to: "code" },
|
||||
{ id: "optimize_code", label: "优化代码", applicable_to: "code" },
|
||||
];
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { CanvasArtifact } from "../types/artifact";
|
||||
import type { CanvasMessage } from "../types/thread";
|
||||
import type { StreamingArtifactPartial } from "../api/runs";
|
||||
|
||||
export interface CanvasGraphContextValue {
|
||||
messages: CanvasMessage[];
|
||||
artifact: CanvasArtifact | null;
|
||||
streamingArtifact: StreamingArtifactPartial | null;
|
||||
isStreaming: boolean;
|
||||
setMessages: (messages: CanvasMessage[]) => void;
|
||||
/** Upsert a message by id — concatenates string content for streaming deltas. */
|
||||
appendMessage: (msg: CanvasMessage) => void;
|
||||
setArtifact: (artifact: CanvasArtifact | null) => void;
|
||||
setStreamingArtifact: (partial: StreamingArtifactPartial | null) => void;
|
||||
setIsStreaming: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
|
||||
|
||||
export function CanvasGraphProvider({ children }: { children: ReactNode }) {
|
||||
const [messages, setMessages] = useState<CanvasMessage[]>([]);
|
||||
const [artifact, setArtifact] = useState<CanvasArtifact | null>(null);
|
||||
const [streamingArtifact, setStreamingArtifact] =
|
||||
useState<StreamingArtifactPartial | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
const appendMessage = useCallback((msg: CanvasMessage) => {
|
||||
setMessages((prev) => {
|
||||
if (!msg.id) return [...prev, msg];
|
||||
const idx = prev.findIndex((m) => m.id === msg.id);
|
||||
if (idx >= 0) {
|
||||
const existing = prev[idx]!;
|
||||
const existingText =
|
||||
typeof existing.content === "string" ? existing.content : "";
|
||||
const deltaText =
|
||||
typeof msg.content === "string" ? msg.content : "";
|
||||
const next = [...prev];
|
||||
next[idx] = { ...existing, content: existingText + deltaText };
|
||||
return next;
|
||||
}
|
||||
return [...prev, msg];
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CanvasGraphContext.Provider
|
||||
value={{
|
||||
messages,
|
||||
artifact,
|
||||
streamingArtifact,
|
||||
isStreaming,
|
||||
setMessages,
|
||||
appendMessage,
|
||||
setArtifact,
|
||||
setStreamingArtifact,
|
||||
setIsStreaming,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CanvasGraphContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvasGraphContext() {
|
||||
const ctx = useContext(CanvasGraphContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useCanvasGraphContext must be used within CanvasGraphProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
export interface CanvasThreadContextValue {
|
||||
threadId: string | null;
|
||||
modelName: string | null;
|
||||
setThreadId: (id: string | null) => void;
|
||||
setModelName: (name: string | null) => void;
|
||||
}
|
||||
|
||||
const CanvasThreadContext = createContext<CanvasThreadContextValue | null>(null);
|
||||
|
||||
export function CanvasThreadProvider({
|
||||
children,
|
||||
initialThreadId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialThreadId?: string | null;
|
||||
}) {
|
||||
const [threadId, setThreadId] = useState<string | null>(
|
||||
initialThreadId ?? null,
|
||||
);
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<CanvasThreadContext.Provider
|
||||
value={{ threadId, setThreadId, modelName, setModelName }}
|
||||
>
|
||||
{children}
|
||||
</CanvasThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvasThreadContext() {
|
||||
const ctx = useContext(CanvasThreadContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useCanvasThreadContext must be used within CanvasThreadProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
CanvasCodeSelection,
|
||||
CanvasSelectionState,
|
||||
CanvasTextSelection,
|
||||
} from "../types/selection";
|
||||
|
||||
export interface CanvasUIContextValue {
|
||||
isChatCollapsed: boolean;
|
||||
isEditing: boolean;
|
||||
isWebSearchOpen: boolean;
|
||||
selection: CanvasSelectionState;
|
||||
setIsChatCollapsed: (v: boolean) => void;
|
||||
setIsEditing: (v: boolean) => void;
|
||||
setIsWebSearchOpen: (v: boolean) => void;
|
||||
/** Set text selection; clears any existing code selection. */
|
||||
setTextSelection: (s: CanvasTextSelection | null) => void;
|
||||
/** Set code selection; clears any existing text selection. */
|
||||
setCodeSelection: (s: CanvasCodeSelection | null) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: CanvasSelectionState = {
|
||||
textSelection: null,
|
||||
codeSelection: null,
|
||||
};
|
||||
|
||||
const CanvasUIContext = createContext<CanvasUIContextValue | null>(null);
|
||||
|
||||
export function CanvasUIProvider({ children }: { children: ReactNode }) {
|
||||
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
|
||||
const [selection, setSelection] =
|
||||
useState<CanvasSelectionState>(EMPTY_SELECTION);
|
||||
|
||||
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
|
||||
setSelection({ textSelection: s, codeSelection: null });
|
||||
}, []);
|
||||
|
||||
const setCodeSelection = useCallback((s: CanvasCodeSelection | null) => {
|
||||
setSelection({ textSelection: null, codeSelection: s });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CanvasUIContext.Provider
|
||||
value={{
|
||||
isChatCollapsed,
|
||||
isEditing,
|
||||
isWebSearchOpen,
|
||||
selection,
|
||||
setIsChatCollapsed,
|
||||
setIsEditing,
|
||||
setIsWebSearchOpen,
|
||||
setTextSelection,
|
||||
setCodeSelection,
|
||||
clearSelection: () => setSelection(EMPTY_SELECTION),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CanvasUIContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvasUIContext() {
|
||||
const ctx = useContext(CanvasUIContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useCanvasUIContext must be used within CanvasUIProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Phase 6-7: artifact version management helpers
|
||||
export {};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
|
||||
import type { QuickActionId } from "../types/quick-action";
|
||||
import { getCurrentArtifactContent } from "../utils/artifact";
|
||||
import { buildQuickActionMessage } from "../utils/quick-actions";
|
||||
import { useCanvasStream } from "./useCanvasStream";
|
||||
|
||||
export function useCanvasQuickActions() {
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
|
||||
const triggerQuickAction = useCallback(
|
||||
async (actionId: QuickActionId) => {
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
if (!content) return;
|
||||
|
||||
const message = buildQuickActionMessage(actionId);
|
||||
await sendMessage(message, { quick_action_id: actionId });
|
||||
},
|
||||
[sendMessage, artifact],
|
||||
);
|
||||
|
||||
return { triggerQuickAction };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCanvasUIContext } from "../contexts/CanvasUIContext";
|
||||
import { captureTextSelection } from "../utils/selection";
|
||||
|
||||
export function useCanvasSelection() {
|
||||
const { selection, setTextSelection, setCodeSelection, clearSelection } =
|
||||
useCanvasUIContext();
|
||||
|
||||
/**
|
||||
* Call from a text renderer's onMouseUp handler.
|
||||
* Reads window.getSelection() and stores the result.
|
||||
*/
|
||||
const handleTextPointerUp = useCallback(
|
||||
(fullMarkdown: string) => {
|
||||
const captured = captureTextSelection(fullMarkdown);
|
||||
setTextSelection(captured);
|
||||
},
|
||||
[setTextSelection],
|
||||
);
|
||||
|
||||
/**
|
||||
* Call from CodeMirror's onUpdate/onStatistics handler.
|
||||
* Pass the selection range (from/to char indices).
|
||||
*/
|
||||
const handleCodeSelectionChange = useCallback(
|
||||
(from: number, to: number) => {
|
||||
if (from === to) {
|
||||
setCodeSelection(null);
|
||||
} else {
|
||||
setCodeSelection({ startCharIndex: from, endCharIndex: to });
|
||||
}
|
||||
},
|
||||
[setCodeSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
selection,
|
||||
handleTextPointerUp,
|
||||
handleCodeSelectionChange,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { streamCanvasRun } from "../api/runs";
|
||||
import type { CanvasRunInput } from "../api/runs";
|
||||
import { createCanvasThread } from "../api/threads";
|
||||
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
|
||||
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
|
||||
import type { CanvasMessage } from "../types/thread";
|
||||
|
||||
export function useCanvasStream() {
|
||||
const navigate = useNavigate();
|
||||
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
appendMessage,
|
||||
setArtifact,
|
||||
setStreamingArtifact,
|
||||
setIsStreaming,
|
||||
} = useCanvasGraphContext();
|
||||
|
||||
// Ref so callbacks inside streamCanvasRun always see the latest messages
|
||||
// without adding `messages` to useCallback's dep array (avoids re-creation
|
||||
// on every message append during streaming).
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
extra?: Partial<Omit<CanvasRunInput, "messages">>,
|
||||
) => {
|
||||
// 1. Ensure we have a thread
|
||||
let activeThreadId = threadId;
|
||||
if (!activeThreadId) {
|
||||
activeThreadId = await createCanvasThread(
|
||||
modelName ? { model_name: modelName } : undefined,
|
||||
);
|
||||
setThreadId(activeThreadId);
|
||||
navigate(`/page/canvas/${activeThreadId}`, { replace: true });
|
||||
}
|
||||
|
||||
// 2. Optimistic human message
|
||||
const humanMsg: CanvasMessage = {
|
||||
id: `human-${Date.now()}`,
|
||||
type: "human",
|
||||
content: text,
|
||||
};
|
||||
setMessages([...messagesRef.current, humanMsg]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// 3. Build run input
|
||||
const input: CanvasRunInput = {
|
||||
messages: [{ type: "human", content: text }],
|
||||
...extra,
|
||||
};
|
||||
|
||||
// 4. Stream
|
||||
await streamCanvasRun(
|
||||
activeThreadId,
|
||||
input,
|
||||
{
|
||||
onMessage(msg) {
|
||||
if (msg.type === "ai" || msg.type === "AIMessageChunk") {
|
||||
appendMessage(msg);
|
||||
}
|
||||
},
|
||||
onStreamingArtifact(partial) {
|
||||
setStreamingArtifact(partial);
|
||||
},
|
||||
onArtifact(artifact) {
|
||||
setArtifact(artifact);
|
||||
setStreamingArtifact(null);
|
||||
},
|
||||
onDone() {
|
||||
setIsStreaming(false);
|
||||
setStreamingArtifact(null);
|
||||
},
|
||||
onError(err) {
|
||||
console.error("[canvas] stream error:", err);
|
||||
setIsStreaming(false);
|
||||
setStreamingArtifact(null);
|
||||
},
|
||||
},
|
||||
modelName ?? undefined,
|
||||
);
|
||||
},
|
||||
// messagesRef replaces direct `messages` dep — intentional
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
threadId,
|
||||
modelName,
|
||||
setThreadId,
|
||||
navigate,
|
||||
appendMessage,
|
||||
setMessages,
|
||||
setArtifact,
|
||||
setStreamingArtifact,
|
||||
setIsStreaming,
|
||||
],
|
||||
);
|
||||
|
||||
return { sendMessage };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useCanvasThreadContext as useCanvasThread } from "../contexts/CanvasThreadContext";
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createCanvasThread } from "../api/threads";
|
||||
|
||||
const QUICK_START_ITEMS = [
|
||||
{
|
||||
type: "text" as const,
|
||||
icon: FileText,
|
||||
title: "新建文本文档",
|
||||
description: "撰写和编辑 Markdown 文档,AI 实时协作修改",
|
||||
},
|
||||
{
|
||||
type: "code" as const,
|
||||
icon: Code2,
|
||||
title: "新建代码",
|
||||
description: "编写和重构多语言代码,AI 辅助生成与优化",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CanvasHomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleQuickStart(type: "text" | "code") {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const threadId = await createCanvasThread();
|
||||
navigate(`${threadId}?type=${type}`);
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create thread:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlankCanvas() {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const threadId = await createCanvasThread();
|
||||
navigate(threadId);
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create thread:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-xl space-y-10">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 mb-2">
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Canvas 模式</h1>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
在左侧聊天区与 AI 协作,右侧实时生成和编辑文档或代码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{QUICK_START_ITEMS.map(({ type, icon: Icon, title, description }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => void handleQuickStart(type)}
|
||||
disabled={loading}
|
||||
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground text-xs"
|
||||
onClick={() => void handleBlankCanvas()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
) : null}
|
||||
或直接开始空白 Canvas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { CanvasThreadProvider } from "../contexts/CanvasThreadContext";
|
||||
import { CanvasGraphProvider } from "../contexts/CanvasGraphContext";
|
||||
import { CanvasUIProvider } from "../contexts/CanvasUIContext";
|
||||
import { CanvasShell } from "../components/layout/CanvasShell";
|
||||
|
||||
export default function CanvasThreadPage() {
|
||||
const { thread_id } = useParams<{ thread_id: string }>();
|
||||
|
||||
return (
|
||||
<CanvasThreadProvider initialThreadId={thread_id}>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<CanvasShell />
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import CanvasHomePage from "../pages/CanvasHomePage";
|
||||
import CanvasThreadPage from "../pages/CanvasThreadPage";
|
||||
|
||||
export default function OpenCanvasRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<CanvasHomePage />} />
|
||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ProgrammingLanguageOptions =
|
||||
| "typescript"
|
||||
| "javascript"
|
||||
| "cpp"
|
||||
| "java"
|
||||
| "php"
|
||||
| "python"
|
||||
| "html"
|
||||
| "sql"
|
||||
| "json"
|
||||
| "rust"
|
||||
| "xml"
|
||||
| "clojure"
|
||||
| "csharp"
|
||||
| "other";
|
||||
|
||||
export type ArtifactType = "code" | "text";
|
||||
|
||||
export type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
export type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: ProgrammingLanguageOptions;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type CanvasArtifactContent = CanvasArtifactText | CanvasArtifactCode;
|
||||
|
||||
export type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: CanvasArtifactContent[];
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export type CanvasModel = {
|
||||
name: string;
|
||||
display_name: string;
|
||||
provider?: string;
|
||||
is_local?: boolean;
|
||||
supports_vision?: boolean;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
supports_tool_calling?: boolean;
|
||||
supports_canvas_generation?: boolean;
|
||||
supports_canvas_rewrite?: boolean;
|
||||
supports_canvas_code_edit?: boolean;
|
||||
supports_streaming_text?: boolean;
|
||||
supports_streaming_tool_calls?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export type QuickActionApplicableTo = "text" | "code" | "both";
|
||||
|
||||
export type QuickAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
applicable_to: QuickActionApplicableTo;
|
||||
prompt_template?: string;
|
||||
};
|
||||
|
||||
export type QuickActionId =
|
||||
| "expand"
|
||||
| "shorten"
|
||||
| "formalize"
|
||||
| "casualize"
|
||||
| "add_comments"
|
||||
| "add_logs"
|
||||
| "fix_bugs"
|
||||
| "optimize_code"
|
||||
| "port_language"
|
||||
| string;
|
||||
@@ -0,0 +1,15 @@
|
||||
export type CanvasTextSelection = {
|
||||
fullMarkdown: string;
|
||||
markdownBlock: string;
|
||||
selectedText: string;
|
||||
};
|
||||
|
||||
export type CanvasCodeSelection = {
|
||||
startCharIndex: number;
|
||||
endCharIndex: number;
|
||||
};
|
||||
|
||||
export type CanvasSelectionState = {
|
||||
textSelection: CanvasTextSelection | null;
|
||||
codeSelection: CanvasCodeSelection | null;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CanvasArtifact } from "./artifact";
|
||||
|
||||
export type CanvasMessage = {
|
||||
id?: string;
|
||||
type: string;
|
||||
content: string | unknown[];
|
||||
additional_kwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CanvasThreadValues = {
|
||||
title?: string;
|
||||
messages?: CanvasMessage[];
|
||||
artifact?: CanvasArtifact;
|
||||
web_search_results?: unknown[];
|
||||
todos?: unknown[];
|
||||
};
|
||||
|
||||
export type CanvasThreadMetadata = {
|
||||
ui_mode: "canvas";
|
||||
agent_id: "canvas_agent";
|
||||
model_name?: string;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
CanvasArtifact,
|
||||
CanvasArtifactContent,
|
||||
CanvasArtifactText,
|
||||
CanvasArtifactCode,
|
||||
ProgrammingLanguageOptions,
|
||||
} from "../types/artifact";
|
||||
|
||||
export function getCurrentArtifactContent(
|
||||
artifact: CanvasArtifact | null,
|
||||
): CanvasArtifactContent | null {
|
||||
if (!artifact || artifact.contents.length === 0) return null;
|
||||
return (
|
||||
artifact.contents.find((c) => c.index === artifact.currentIndex) ??
|
||||
artifact.contents[artifact.contents.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function isTextArtifact(
|
||||
content: CanvasArtifactContent,
|
||||
): content is CanvasArtifactText {
|
||||
return content.type === "text";
|
||||
}
|
||||
|
||||
export function isCodeArtifact(
|
||||
content: CanvasArtifactContent,
|
||||
): content is CanvasArtifactCode {
|
||||
return content.type === "code";
|
||||
}
|
||||
|
||||
export function appendArtifactVersion(
|
||||
artifact: CanvasArtifact,
|
||||
content: Omit<CanvasArtifactContent, "index">,
|
||||
): CanvasArtifact {
|
||||
const nextIndex = artifact.contents.length + 1;
|
||||
const newContent = { ...content, index: nextIndex } as CanvasArtifactContent;
|
||||
return {
|
||||
currentIndex: nextIndex,
|
||||
contents: [...artifact.contents, newContent],
|
||||
};
|
||||
}
|
||||
|
||||
export function setCurrentArtifactIndex(
|
||||
artifact: CanvasArtifact,
|
||||
index: number,
|
||||
): CanvasArtifact {
|
||||
return { ...artifact, currentIndex: index };
|
||||
}
|
||||
|
||||
export function createDraftArtifact(
|
||||
type: "text" | "code",
|
||||
options?: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
language?: ProgrammingLanguageOptions;
|
||||
},
|
||||
): CanvasArtifact {
|
||||
const { title, content = "", language = "python" } = options ?? {};
|
||||
if (type === "text") {
|
||||
return {
|
||||
currentIndex: 1,
|
||||
contents: [
|
||||
{
|
||||
index: 1,
|
||||
type: "text",
|
||||
title: title ?? "未命名文档",
|
||||
fullMarkdown: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
currentIndex: 1,
|
||||
contents: [
|
||||
{
|
||||
index: 1,
|
||||
type: "code",
|
||||
title: title ?? "未命名代码",
|
||||
language,
|
||||
code: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { QuickActionId } from "../types/quick-action";
|
||||
|
||||
const QUICK_ACTION_MESSAGES: Record<string, string> = {
|
||||
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
|
||||
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
|
||||
formalize: "请将当前内容改写为正式、专业的语气",
|
||||
casualize: "请将当前内容改写为口语化、友好的语气",
|
||||
add_comments: "请为当前代码添加清晰实用的注释",
|
||||
add_logs: "请为当前代码在关键位置添加适当的日志语句",
|
||||
fix_bugs: "请检查并修复当前代码中的所有 Bug",
|
||||
optimize_code: "请对当前代码进行性能和可读性优化",
|
||||
};
|
||||
|
||||
export function buildQuickActionMessage(id: QuickActionId): string {
|
||||
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
CanvasSelectionState,
|
||||
CanvasTextSelection,
|
||||
} from "../types/selection";
|
||||
|
||||
export function hasActiveSelection(selection: CanvasSelectionState): boolean {
|
||||
return selection.textSelection !== null || selection.codeSelection !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest line-level block in markdown that contains selectedText.
|
||||
* Falls back to the selectedText itself when no clean boundary is found.
|
||||
*/
|
||||
function findMarkdownBlock(fullMarkdown: string, selectedText: string): string {
|
||||
const idx = fullMarkdown.indexOf(selectedText);
|
||||
if (idx === -1) return selectedText;
|
||||
|
||||
let blockStart = idx;
|
||||
while (blockStart > 0 && fullMarkdown[blockStart - 1] !== "\n") {
|
||||
blockStart--;
|
||||
}
|
||||
|
||||
let blockEnd = idx + selectedText.length;
|
||||
while (blockEnd < fullMarkdown.length && fullMarkdown[blockEnd] !== "\n") {
|
||||
blockEnd++;
|
||||
}
|
||||
|
||||
return fullMarkdown.slice(blockStart, blockEnd).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the browser selection and map it to a CanvasTextSelection.
|
||||
* Returns null when nothing meaningful is selected.
|
||||
*/
|
||||
export function captureTextSelection(
|
||||
fullMarkdown: string,
|
||||
): CanvasTextSelection | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) return null;
|
||||
|
||||
const selectedText = sel.toString().trim();
|
||||
if (!selectedText) return null;
|
||||
|
||||
return {
|
||||
fullMarkdown,
|
||||
markdownBlock: findMarkdownBlock(fullMarkdown, selectedText),
|
||||
selectedText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the code snippet from a full code string given char indices.
|
||||
*/
|
||||
export function resolveCodeSelection(
|
||||
code: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): string {
|
||||
return code.slice(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human message with embedded selection context so the canvas_agent
|
||||
* knows exactly which portion to rewrite.
|
||||
*/
|
||||
export function buildMessageWithSelection(
|
||||
userText: string,
|
||||
selection: CanvasSelectionState,
|
||||
resolvedCode?: string,
|
||||
): string {
|
||||
const { textSelection, codeSelection } = selection;
|
||||
|
||||
if (textSelection?.selectedText) {
|
||||
return [
|
||||
"请针对以下选中的文本内容进行操作:",
|
||||
"",
|
||||
"<selection>",
|
||||
textSelection.selectedText,
|
||||
"</selection>",
|
||||
"",
|
||||
`用户指令:${userText}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (codeSelection && resolvedCode) {
|
||||
return [
|
||||
"请针对以下选中的代码片段进行操作:",
|
||||
"",
|
||||
"<selection>",
|
||||
resolvedCode,
|
||||
"</selection>",
|
||||
"",
|
||||
`用户指令:${userText}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return userText;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Phase 6: parse LangGraph stream events into messages and artifact updates
|
||||
export {};
|
||||
@@ -0,0 +1,2 @@
|
||||
// Phase 5: thread creation, metadata tagging, and state recovery helpers
|
||||
export {};
|
||||
@@ -6,6 +6,7 @@ import { PageSidebar } from "@/components/page-sidebar";
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
|
||||
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
||||
const SIDEBAR_ENABLED = false;
|
||||
@@ -41,6 +42,7 @@ function PageLayout() {
|
||||
}
|
||||
/>
|
||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user