Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
4761677676
74 files changed
+9723
-2840
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>
|
||||
|
||||
@@ -302,6 +302,55 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
|
||||
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
|
||||
|
||||
Background skill lifecycle manager. The Gateway `lifespan` starts a resident
|
||||
scheduler task (`_curator_scheduler_loop` in `app/gateway/app.py`) that — after a
|
||||
5-minute startup delay — calls `maybe_run_curator()` every hour. A cross-process
|
||||
file lock (`.curator_scheduler.lock`) elects a single leader worker so the
|
||||
scheduler runs once per deployment. Per run:
|
||||
|
||||
- **Master switch** — `skill_evolution.enabled` (default **`false`**) gates the
|
||||
whole feature; `curator.enabled` (default **`false`**) gates the curator
|
||||
specifically. Both must be `true` for the curator to act. `should_run_now()`
|
||||
and `_curator_thread_target()` both enforce this — so manual `POST
|
||||
/api/curator/run` also respects the master switch. Config is
|
||||
`config.yaml.skill_evolution` deep-merged with per-user
|
||||
`users/{user_id}/skill_evolution.json`.
|
||||
- **Per-user run lock** — `.curator_state` carries `running` / `started_at`;
|
||||
`_try_acquire_run_lock()` prevents the same user's curator from running
|
||||
concurrently (scheduler vs. manual trigger, or double manual clicks). A lock
|
||||
whose `started_at` is older than `_CURATOR_STALE_LOCK_HOURS` (6h) is treated as
|
||||
process-crash residue and can be taken over.
|
||||
- **Concurrency cap** — `maybe_run_curator()` collects all due users and hands
|
||||
them to `_dispatch_curator_runs()`, which runs them through a
|
||||
`ThreadPoolExecutor` capped at `_CURATOR_MAX_CONCURRENCY` (5) — avoids a
|
||||
thundering herd of LLM calls when many users come due at once (e.g. first
|
||||
deploy).
|
||||
- **Failure back-off** — `_release_run_lock()` always stamps `last_attempt_at`.
|
||||
When a first run keeps crashing (`last_run_at` never set), `should_run_now()`
|
||||
retries on `_CURATOR_RETRY_HOURS` (6h) instead of every hourly tick.
|
||||
- **`should_run_now()`** is the real gate: the hourly tick only checks "is the
|
||||
per-user `interval_hours` (default 168h) elapsed". Timestamp parsing tolerates
|
||||
naive (tz-less) strings — a corrupt `last_run_at` triggers a re-run rather than
|
||||
a permanent deadlock.
|
||||
- **Published-skill filter** — `SkillStore.list_published_names()` is a batch
|
||||
query; the curator fetches the published-name set once per run instead of one
|
||||
DB round-trip per candidate skill.
|
||||
- **LLM review timeout** — `_run_llm_review()` runs `agent.invoke()` in a worker
|
||||
thread bounded by `curator.review_timeout_seconds` (default 600s). The model's
|
||||
per-request `request_timeout` can't bound a multi-turn React agent loop, so on
|
||||
timeout the run is abandoned (a warning is written to `curator_log`, visible in
|
||||
the Web UI) and the curator finishes cleanly + releases the run lock. The
|
||||
worker context is copied via `contextvars.copy_context()` so tool calls still
|
||||
resolve the correct `user_id`.
|
||||
- **Review model** — `_run_llm_review()` uses `curator.model_name` (a
|
||||
`config.yaml` `models[]` entry name) if set, else the primary model; it is
|
||||
editable from the Web UI evolution panel. The skill security scanner
|
||||
(`scan_skill_content` in `security_scanner.py`) reuses the same
|
||||
`skill_evolution.curator.model_name` setting — review and moderation share one
|
||||
model. There is no separate moderation-model config field.
|
||||
|
||||
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
||||
|
||||
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -268,6 +269,93 @@ async def _run_db_migrations() -> None:
|
||||
logger.exception("Database migration failed — schema may be out of date")
|
||||
|
||||
|
||||
# Skill curator 调度参数。
|
||||
# - 检查间隔:每隔这么久检查一次"需不需要进化";真正是否执行由 should_run_now() 判定。
|
||||
# - 启动延迟:启动后先等这么久再首次检查,避开启动高峰。
|
||||
_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 小时
|
||||
_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 分钟
|
||||
|
||||
|
||||
def _acquire_singleton_lock(lock_path):
|
||||
"""跨平台非阻塞独占文件锁,用于多 worker 下的 leader 选举。
|
||||
|
||||
成功 -> 返回持有锁的文件句柄(调用方须保持其存活,直到进程退出/主动释放);
|
||||
抢不到锁(已有其它 worker 持有)或打开失败 -> 返回 None。
|
||||
进程异常退出时 OS 会自动释放该锁。
|
||||
"""
|
||||
try:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = open(lock_path, "a+")
|
||||
except OSError:
|
||||
logger.exception("Curator scheduler: 打开锁文件失败")
|
||||
return None
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
handle.close()
|
||||
return None
|
||||
return handle
|
||||
|
||||
|
||||
def _release_singleton_lock(handle) -> None:
|
||||
"""释放并关闭 _acquire_singleton_lock 拿到的锁文件句柄。"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _curator_scheduler_loop() -> None:
|
||||
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
|
||||
|
||||
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
|
||||
运行调度,其余 worker 直接退出,避免 curator 被重复触发、重复调用 LLM。
|
||||
被 cancel 时 CancelledError 会穿过 ``except Exception``(它是 BaseException
|
||||
子类,不会被捕获),协程随之干净退出并在 finally 里释放锁。
|
||||
"""
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
from deerflow.skills.curator import maybe_run_curator
|
||||
|
||||
lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
|
||||
if lock_handle is None:
|
||||
logger.info("Curator scheduler: 另一个 worker 已持有调度锁,本 worker 跳过")
|
||||
return
|
||||
|
||||
logger.info("Curator scheduler: 取得调度锁,本 worker 负责 curator 调度")
|
||||
try:
|
||||
# 启动后延迟一会儿再首次检查,避开启动高峰
|
||||
await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
|
||||
while True:
|
||||
try:
|
||||
await maybe_run_curator()
|
||||
except Exception:
|
||||
logger.exception("Curator scheduler tick failed (non-fatal)")
|
||||
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
|
||||
finally:
|
||||
_release_singleton_lock(lock_handle)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
@@ -302,12 +390,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
except Exception:
|
||||
logger.exception("Skill ownership migration failed (non-fatal)")
|
||||
|
||||
# Kick off skill curator in background if the run interval has elapsed
|
||||
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
|
||||
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
|
||||
try:
|
||||
from deerflow.skills.curator import maybe_run_curator
|
||||
asyncio.create_task(maybe_run_curator())
|
||||
app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
|
||||
except Exception:
|
||||
logger.exception("Skill curator startup task failed (non-fatal)")
|
||||
logger.exception("Skill curator scheduler failed to start (non-fatal)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
@@ -335,6 +423,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
yield
|
||||
|
||||
# 停止 curator 调度器
|
||||
curator_task = getattr(app.state, "curator_scheduler_task", None)
|
||||
if curator_task is not None:
|
||||
curator_task.cancel()
|
||||
try:
|
||||
await curator_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Curator scheduler shutdown error")
|
||||
|
||||
try:
|
||||
scheduler_service = getattr(app.state, "scheduled_task_service", None)
|
||||
if scheduler_service is not None:
|
||||
|
||||
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["models"])
|
||||
|
||||
_PROVIDER_KEYWORDS: list[tuple[str, str]] = [
|
||||
("langchain_openai", "openai"),
|
||||
("langchain_anthropic", "anthropic"),
|
||||
("langchain_google", "google"),
|
||||
("langchain_groq", "groq"),
|
||||
("langchain_mistralai", "mistral"),
|
||||
("langchain_cohere", "cohere"),
|
||||
("ollama", "ollama"),
|
||||
("vllm", "vllm"),
|
||||
]
|
||||
|
||||
_LOCAL_PROVIDERS = {"ollama", "vllm"}
|
||||
|
||||
|
||||
def _derive_provider(use: str) -> str | None:
|
||||
use_lower = use.lower()
|
||||
for keyword, provider in _PROVIDER_KEYWORDS:
|
||||
if keyword in use_lower:
|
||||
return provider
|
||||
return None
|
||||
|
||||
|
||||
def _build_model_response(model: ModelConfig) -> "ModelResponse":
|
||||
extra = model.model_extra or {}
|
||||
provider = str(extra["provider"]) if "provider" in extra else _derive_provider(model.use)
|
||||
is_local = bool(extra["is_local"]) if "is_local" in extra else (provider in _LOCAL_PROVIDERS)
|
||||
supports_tool_calling = bool(extra["supports_tool_calling"]) if "supports_tool_calling" in extra else True
|
||||
return ModelResponse(
|
||||
name=model.name,
|
||||
model=model.model,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
supports_reasoning_effort=model.supports_reasoning_effort,
|
||||
supports_vision=model.supports_vision,
|
||||
supports_tool_calling=supports_tool_calling,
|
||||
is_local=is_local,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
class ModelResponse(BaseModel):
|
||||
"""Response model for model information."""
|
||||
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
|
||||
description: str | None = Field(None, description="Model description")
|
||||
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
|
||||
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
||||
supports_vision: bool = Field(default=False, description="Whether model supports image inputs")
|
||||
supports_tool_calling: bool = Field(default=True, description="Whether model supports tool/function calling")
|
||||
is_local: bool = Field(default=False, description="Whether this is a locally-hosted model")
|
||||
provider: str | None = Field(None, description="Model provider name (openai, anthropic, ollama, etc.)")
|
||||
|
||||
|
||||
class TokenUsageResponse(BaseModel):
|
||||
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
||||
}
|
||||
```
|
||||
"""
|
||||
models = [
|
||||
ModelResponse(
|
||||
name=model.name,
|
||||
model=model.model,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
supports_reasoning_effort=model.supports_reasoning_effort,
|
||||
)
|
||||
for model in config.models
|
||||
]
|
||||
models = [_build_model_response(model) for model in config.models]
|
||||
return ModelsListResponse(
|
||||
models=models,
|
||||
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
||||
@@ -122,11 +157,4 @@ async def get_model(model_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||
|
||||
return ModelResponse(
|
||||
name=model.name,
|
||||
model=model.model,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
supports_reasoning_effort=model.supports_reasoning_effort,
|
||||
)
|
||||
return _build_model_response(model)
|
||||
@@ -1045,12 +1045,21 @@ async def curator_status(request: Request) -> dict:
|
||||
@router.post("/curator/run", summary="Trigger Curator Run")
|
||||
async def run_curator(request: Request) -> dict:
|
||||
"""Manually trigger a curator review pass for the authenticated user
|
||||
(bypasses interval check, runs in a background thread)."""
|
||||
(bypasses the interval check, runs in a background thread).
|
||||
|
||||
Pre-checks the master switch / curator switch / run lock so the response
|
||||
accurately reflects whether a run actually started — instead of always
|
||||
reporting success. Returns ``{"triggered": bool, "reason": str | None}``.
|
||||
"""
|
||||
try:
|
||||
import threading
|
||||
from deerflow.skills.curator import _curator_thread_target
|
||||
|
||||
from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
reason = manual_trigger_blocked_reason(user_id)
|
||||
if reason is not None:
|
||||
return {"triggered": False, "reason": reason}
|
||||
thread = threading.Thread(
|
||||
target=_curator_thread_target,
|
||||
args=(user_id,),
|
||||
@@ -1058,7 +1067,7 @@ async def run_curator(request: Request) -> dict:
|
||||
name=f"skill-curator-manual-{user_id}",
|
||||
)
|
||||
thread.start()
|
||||
return {"triggered": True, "message": "Curator run started in background"}
|
||||
return {"triggered": True, "reason": None}
|
||||
except Exception as e:
|
||||
logger.error("Failed to trigger curator: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}")
|
||||
@@ -1067,17 +1076,19 @@ async def run_curator(request: Request) -> dict:
|
||||
class CuratorSubConfig(BaseModel):
|
||||
"""Nested curator configuration mirroring CuratorConfig."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether the curator runs automatically.")
|
||||
# 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。
|
||||
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
|
||||
interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.")
|
||||
stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.")
|
||||
review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.")
|
||||
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.")
|
||||
|
||||
|
||||
class CuratorConfigResponse(BaseModel):
|
||||
"""Response model exposing the effective skill_evolution config."""
|
||||
|
||||
enabled: bool
|
||||
moderation_model_name: str | None
|
||||
curator: CuratorSubConfig
|
||||
|
||||
|
||||
@@ -1089,7 +1100,6 @@ class CuratorConfigRequest(BaseModel):
|
||||
"""
|
||||
|
||||
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
|
||||
moderation_model_name: str | None = Field(default=None, description="Optional model name for skill security moderation.")
|
||||
curator: CuratorSubConfig
|
||||
|
||||
|
||||
@@ -1113,12 +1123,13 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
||||
"""
|
||||
return CuratorConfigResponse(
|
||||
enabled=evo.enabled,
|
||||
moderation_model_name=evo.moderation_model_name,
|
||||
curator=CuratorSubConfig(
|
||||
enabled=evo.curator.enabled,
|
||||
interval_hours=evo.curator.interval_hours,
|
||||
stale_after_days=evo.curator.stale_after_days,
|
||||
archive_after_days=evo.curator.archive_after_days,
|
||||
review_timeout_seconds=evo.curator.review_timeout_seconds,
|
||||
model_name=evo.curator.model_name,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||
|
||||
# Built-in LangGraph graph IDs registered in langgraph.json.
|
||||
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
|
||||
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
|
||||
|
||||
|
||||
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
|
||||
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
|
||||
@@ -180,12 +184,15 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
|
||||
def resolve_agent_factory(assistant_id: str | None):
|
||||
"""Resolve the agent factory callable from config.
|
||||
|
||||
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
|
||||
injected into ``configurable`` or ``context`` — see
|
||||
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
||||
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
||||
``cfg["agent_id"]``.
|
||||
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
|
||||
to their own factory. Custom agents share the ``lead_agent`` factory with
|
||||
``agent_id`` injected into configurable/context by :func:`build_run_config`.
|
||||
"""
|
||||
if assistant_id == "canvas_agent":
|
||||
from deerflow.agents.canvas_agent.agent import make_canvas_agent
|
||||
|
||||
return make_canvas_agent
|
||||
|
||||
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||
|
||||
return make_lead_agent
|
||||
@@ -244,8 +251,9 @@ def build_run_config(
|
||||
config["configurable"] = {"thread_id": thread_id}
|
||||
|
||||
# Inject custom agent id when the caller specified a non-default assistant.
|
||||
# Honour an explicit agent_id in the active runtime options container.
|
||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
|
||||
# factory and must NOT receive an agent_id override.
|
||||
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||
normalized = assistant_id.strip()
|
||||
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
|
||||
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.")
|
||||
@@ -265,7 +273,7 @@ def build_run_config(
|
||||
def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||
"""Collect agent ids from assistant_id/config/context for access checks."""
|
||||
agent_ids: set[str] = set()
|
||||
if body.assistant_id and body.assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||
if body.assistant_id and body.assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||
agent_ids.add(str(body.assistant_id))
|
||||
|
||||
def collect(container: Mapping[str, Any] | None) -> None:
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
],
|
||||
"env": ".env",
|
||||
"graphs": {
|
||||
"lead_agent": "deerflow.agents:make_lead_agent"
|
||||
"lead_agent": "deerflow.agents:make_lead_agent",
|
||||
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent"
|
||||
},
|
||||
"auth": {
|
||||
"path": "./app/gateway/langgraph_auth.py:auth"
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from deerflow.agents.canvas_agent.agent import make_canvas_agent
|
||||
|
||||
__all__ = ["make_canvas_agent"]
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from deerflow.agents.canvas_agent.prompt import CANVAS_SYSTEM_PROMPT
|
||||
from deerflow.agents.canvas_agent.tools import update_artifact_tool
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.tools.builtins import ask_clarification_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||
cfg = dict(config.get("configurable", {}) or {})
|
||||
context = config.get("context", {}) or {}
|
||||
if isinstance(context, dict):
|
||||
cfg.update(context)
|
||||
return cfg
|
||||
|
||||
|
||||
def make_canvas_agent(config: RunnableConfig):
|
||||
"""LangGraph graph factory for canvas mode; signature compatible with LangGraph Server."""
|
||||
app_config = get_app_config()
|
||||
cfg = _get_runtime_config(config)
|
||||
|
||||
requested_model = cfg.get("model_name") or cfg.get("model")
|
||||
if requested_model and app_config.get_model_config(requested_model):
|
||||
model_name = requested_model
|
||||
else:
|
||||
if requested_model:
|
||||
logger.warning("Canvas agent: model '%s' not found; falling back to default.", requested_model)
|
||||
model_name = app_config.models[0].name if app_config.models else None
|
||||
|
||||
if model_name is None:
|
||||
raise ValueError("No chat models configured. Please add at least one model in config.yaml.")
|
||||
|
||||
thinking_enabled = cfg.get("thinking_enabled", False)
|
||||
model_config = app_config.get_model_config(model_name)
|
||||
if thinking_enabled and model_config and not model_config.supports_thinking:
|
||||
thinking_enabled = False
|
||||
|
||||
logger.info("Create CanvasAgent -> model_name: %s, thinking_enabled: %s", model_name, thinking_enabled)
|
||||
|
||||
middlewares = [
|
||||
DanglingToolCallMiddleware(),
|
||||
ToolErrorHandlingMiddleware(),
|
||||
TitleMiddleware(app_config=app_config),
|
||||
LoopDetectionMiddleware(),
|
||||
ClarificationMiddleware(),
|
||||
]
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=app_config),
|
||||
tools=[update_artifact_tool, ask_clarification_tool],
|
||||
middleware=middlewares,
|
||||
system_prompt=CANVAS_SYSTEM_PROMPT,
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
CANVAS_SYSTEM_PROMPT = """You are a Canvas AI assistant that helps users create and edit documents and code.
|
||||
|
||||
<canvas_behavior>
|
||||
You work with an **artifact** — a document or code file shown in the canvas panel on the right.
|
||||
ALWAYS call `update_artifact` to save your work. Never embed the artifact content in your chat response.
|
||||
|
||||
Rules:
|
||||
- On every request that creates or changes the artifact, call `update_artifact` ONCE with the COMPLETE updated content.
|
||||
- In your text reply, describe what you did in 1–2 sentences.
|
||||
- If the user is only asking a question (no edit needed), answer in text — skip the tool call.
|
||||
</canvas_behavior>
|
||||
|
||||
<artifact_context>
|
||||
The current artifact state is injected into your context. When editing, build on the existing content exactly.
|
||||
When creating from scratch, produce a complete, high-quality artifact in one shot.
|
||||
</artifact_context>
|
||||
|
||||
<selection_rewrite>
|
||||
When the user's message contains a <selection> block, you MUST:
|
||||
1. Identify the exact selection in the current artifact content.
|
||||
2. Rewrite ONLY that selection according to the user's instruction.
|
||||
3. Keep the rest of the artifact exactly unchanged.
|
||||
4. Call update_artifact with the complete artifact where the selection has been replaced.
|
||||
|
||||
Example: if the artifact is "A B C" and the selection is "B" with instruction "make it bold",
|
||||
produce "A **B** C" — not a new document.
|
||||
</selection_rewrite>
|
||||
|
||||
<quick_actions>
|
||||
When the user triggers a quick action, apply it to the full artifact unless specific highlighted text is provided:
|
||||
- expand: Add depth and detail while preserving structure
|
||||
- shorten: Condense to the essential points
|
||||
- formalize: Rewrite in professional, formal language
|
||||
- casualize: Rewrite in friendly, conversational language
|
||||
- add_comments: Add clear, helpful code comments
|
||||
- add_logs: Add appropriate logging statements
|
||||
- fix_bugs: Identify and fix all bugs in the code
|
||||
- optimize_code: Improve performance and readability
|
||||
</quick_actions>
|
||||
"""
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from langchain.tools import InjectedToolCallId, ToolRuntime, tool
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
from langgraph.typing import ContextT
|
||||
|
||||
from deerflow.agents.thread_state import CanvasArtifact, CanvasArtifactCode, CanvasArtifactText, ThreadState
|
||||
|
||||
|
||||
@tool("update_artifact", parse_docstring=True)
|
||||
def update_artifact_tool(
|
||||
runtime: ToolRuntime[ContextT, ThreadState],
|
||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||
type: Literal["text", "code"],
|
||||
title: str,
|
||||
content: str,
|
||||
language: str = "other",
|
||||
) -> Command:
|
||||
"""Create or update the canvas artifact. Always call this to persist your work.
|
||||
|
||||
Args:
|
||||
type: Artifact type — "text" for markdown documents, "code" for source code.
|
||||
title: Human-readable title of the artifact.
|
||||
content: Full artifact content: markdown text or complete source code.
|
||||
language: Programming language (for code type only, e.g. "python", "typescript"). Ignored for text.
|
||||
"""
|
||||
current: CanvasArtifact | None = (runtime.state or {}).get("artifact") if runtime.state else None
|
||||
|
||||
if current is not None:
|
||||
existing_contents = list(current.get("contents", []))
|
||||
new_index = current.get("currentIndex", len(existing_contents)) + 1
|
||||
else:
|
||||
existing_contents = []
|
||||
new_index = 1
|
||||
|
||||
if type == "text":
|
||||
new_entry: CanvasArtifactText | CanvasArtifactCode = CanvasArtifactText(
|
||||
index=new_index,
|
||||
type="text",
|
||||
title=title,
|
||||
fullMarkdown=content,
|
||||
)
|
||||
else:
|
||||
new_entry = CanvasArtifactCode(
|
||||
index=new_index,
|
||||
type="code",
|
||||
title=title,
|
||||
language=language,
|
||||
code=content,
|
||||
)
|
||||
|
||||
updated: CanvasArtifact = {
|
||||
"currentIndex": new_index,
|
||||
"contents": existing_contents + [new_entry],
|
||||
}
|
||||
|
||||
return Command(
|
||||
update={
|
||||
"artifact": updated,
|
||||
"messages": [ToolMessage("Artifact updated.", tool_call_id=tool_call_id)],
|
||||
}
|
||||
)
|
||||
+19
-2
@@ -69,8 +69,25 @@ _enabled_skills_refresh_version = 0
|
||||
_enabled_skills_refresh_event = threading.Event()
|
||||
|
||||
|
||||
def _drop_archived_skills(skills: list[Skill]) -> list[Skill]:
|
||||
"""从注入提示词的技能列表里剔除已归档(usage state=archived)的技能。
|
||||
|
||||
技能归档是软状态:SKILL.md 仍留在 custom/<name>/、enabled 标志不变,所以
|
||||
load_skills(enabled_only=True) 仍会返回它。但归档技能不应再出现在 agent 的系统
|
||||
提示词里 —— 对 agent 而言它应等同于"不存在"。失败时不过滤(返回原列表)。
|
||||
"""
|
||||
try:
|
||||
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
|
||||
|
||||
entries = all_entries()
|
||||
return [s for s in skills if entries.get(s.name, {}).get("state") != STATE_ARCHIVED]
|
||||
except Exception:
|
||||
logger.debug("Failed to filter archived skills for prompt injection", exc_info=True)
|
||||
return skills
|
||||
|
||||
|
||||
def _load_enabled_skills_sync() -> list[Skill]:
|
||||
return list(get_or_new_skill_storage().load_skills(enabled_only=True))
|
||||
return _drop_archived_skills(list(get_or_new_skill_storage().load_skills(enabled_only=True)))
|
||||
|
||||
|
||||
def _start_enabled_skills_refresh_thread() -> None:
|
||||
@@ -171,7 +188,7 @@ def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[
|
||||
"""
|
||||
if app_config is None:
|
||||
return _get_enabled_skills()
|
||||
return list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))
|
||||
return _drop_archived_skills(list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True)))
|
||||
|
||||
|
||||
def _skill_mutability_label(category: SkillCategory | str) -> str:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Annotated, NotRequired, TypedDict
|
||||
from typing import Annotated, Literal, NotRequired, Optional, TypedDict
|
||||
|
||||
from langchain.agents import AgentState
|
||||
|
||||
@@ -18,6 +18,26 @@ class ViewedImageData(TypedDict):
|
||||
mime_type: str
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
||||
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
||||
if existing is None:
|
||||
@@ -53,3 +73,11 @@ class ThreadState(AgentState):
|
||||
todos: NotRequired[list | None]
|
||||
uploaded_files: NotRequired[list[dict] | None]
|
||||
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
|
||||
# Canvas fields (coexist with artifacts: list[str] which tracks file outputs)
|
||||
artifact: NotRequired[Optional[CanvasArtifact]]
|
||||
highlighted_text: NotRequired[Optional[str]]
|
||||
highlighted_code: NotRequired[Optional[str]]
|
||||
canvas_mode: NotRequired[Optional[Literal["text", "code"]]]
|
||||
quick_action_id: NotRequired[Optional[str]]
|
||||
web_search_enabled: NotRequired[bool]
|
||||
web_search_results: NotRequired[Optional[list[dict]]]
|
||||
+8
-5
@@ -4,10 +4,17 @@ from pydantic import BaseModel, Field
|
||||
class CuratorConfig(BaseModel):
|
||||
"""Configuration for the background skill curator."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether the curator runs automatically.")
|
||||
# 默认关闭:curator 仅在用户显式开启时才运行(配合 SkillEvolutionConfig.enabled
|
||||
# 总开关,二者均为安全默认 False —— 未显式 opt-in 则整套技能进化不动作)。
|
||||
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
|
||||
interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).")
|
||||
stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.")
|
||||
# LLM 评审整体超时:agent.invoke 内部可能多轮调 LLM,模型层的单次 request_timeout
|
||||
# 兜不住整体;超过此秒数则中止本次评审,curator 正常收尾并释放运行锁。
|
||||
review_timeout_seconds: int = Field(default=600, description="Overall timeout (seconds) for the LLM review pass.")
|
||||
# curator LLM 评审使用的模型名(对应 config.yaml models[].name);为空则用主模型。
|
||||
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass. Defaults to the primary chat model.")
|
||||
|
||||
|
||||
class SkillEvolutionConfig(BaseModel):
|
||||
@@ -17,10 +24,6 @@ class SkillEvolutionConfig(BaseModel):
|
||||
default=False,
|
||||
description="Whether the agent can create and modify skills under skills/custom.",
|
||||
)
|
||||
moderation_model_name: str | None = Field(
|
||||
default=None,
|
||||
description="Optional model name for skill security moderation. Defaults to the primary chat model.",
|
||||
)
|
||||
curator: CuratorConfig = Field(
|
||||
default_factory=CuratorConfig,
|
||||
description="Configuration for the background skill curator.",
|
||||
|
||||
@@ -67,3 +67,13 @@ class SkillStore(ABC):
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_published_names(self) -> set[str]:
|
||||
"""Return the set of skill names with ``published = True`` (any owner).
|
||||
|
||||
Batch alternative to calling ``get_any`` per skill — the curator uses
|
||||
this to filter out published skills in a single query instead of one
|
||||
DB round-trip per candidate skill.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+3
@@ -133,3 +133,6 @@ class MemorySkillStore(SkillStore):
|
||||
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
return {name for name, record in self._skills.items() if record.get("published")}
|
||||
@@ -148,3 +148,9 @@ class SkillRepository(SkillStore):
|
||||
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return {row[0] for row in result.all()}
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -25,6 +26,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_CURATOR_STATE_FILENAME = ".curator_state"
|
||||
|
||||
# 周期调度一轮里同时执行的 curator 数量上限。到期用户多时(尤其首次部署所有用户一起
|
||||
# 到期)用线程池排队消费,避免一次性起 N 个线程齐调 LLM 打爆 provider 速率限制。
|
||||
_CURATOR_MAX_CONCURRENCY = 5
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@@ -119,6 +124,81 @@ def _write_curator_state(state: dict, user_id: str | None) -> None:
|
||||
logger.debug("Failed to write curator state", exc_info=True)
|
||||
|
||||
|
||||
def _parse_state_time(value: str | None) -> datetime | None:
|
||||
"""解析 ``.curator_state`` 里的 ISO 时间字符串,返回 aware datetime(UTC)。
|
||||
|
||||
naive(无时区)字符串按 UTC 补全 —— 与 ``apply_automatic_transitions`` 的处理
|
||||
一致,避免 naive 时间串与 ``datetime.now(UTC)`` 直接比较时抛异常。无法解析时
|
||||
返回 None,由调用方决定降级行为。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except Exception:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
# Curator 运行锁:防止同一用户的 curator 并发执行(定时器 vs 手动触发、连点手动触发)。
|
||||
# 锁状态写进 .curator_state 的 running/started_at 字段,跨 worker 可见(手动请求可能
|
||||
# 落到非调度 worker)。started_at 超过下面的阈值视为进程残留,允许接管以免永久死锁。
|
||||
_CURATOR_STALE_LOCK_HOURS = 6
|
||||
# 上一轮"尝试"过但未成功(last_run_at 始终为空)时的重试间隔 —— 比正常 interval_hours
|
||||
# 短得多,但也不至于每个调度 tick(1h)都重试、每次都烧一份 LLM 配额。
|
||||
_CURATOR_RETRY_HOURS = 6
|
||||
# 进程内串行化运行锁的"读-改-写",保证同进程内的抢锁是原子的(跨进程的极小竞态窗口
|
||||
# 可接受 —— curator 触发频率极低)。
|
||||
_run_lock_guard = threading.Lock()
|
||||
|
||||
|
||||
def _run_lock_active(state: dict, now: datetime) -> bool:
|
||||
"""判断 ``.curator_state`` 里的运行锁当前是否有效(running 且未过期)。
|
||||
|
||||
``started_at`` 缺失或不可解析、或距今已超过 ``_CURATOR_STALE_LOCK_HOURS`` 时,
|
||||
视为进程残留留下的死锁,返回 False 以便后续触发能接管。
|
||||
"""
|
||||
if not state.get("running"):
|
||||
return False
|
||||
started_dt = _parse_state_time(state.get("started_at"))
|
||||
if started_dt is None:
|
||||
return False
|
||||
return now < started_dt + timedelta(hours=_CURATOR_STALE_LOCK_HOURS)
|
||||
|
||||
|
||||
def _try_acquire_run_lock(user_id: str, now: datetime) -> bool:
|
||||
"""尝试为 ``user_id`` 抢占 curator 运行锁。
|
||||
|
||||
抢到锁(已把 running=True / started_at 写回)返回 True;已有未过期的运行时返回
|
||||
False。读-改-写在 ``_run_lock_guard`` 下进行,保证同进程内原子。
|
||||
"""
|
||||
with _run_lock_guard:
|
||||
state = _read_curator_state(user_id)
|
||||
if _run_lock_active(state, now):
|
||||
return False
|
||||
state["running"] = True
|
||||
state["started_at"] = now.isoformat()
|
||||
_write_curator_state(state, user_id)
|
||||
return True
|
||||
|
||||
|
||||
def _release_run_lock(user_id: str) -> None:
|
||||
"""释放 curator 运行锁并记录本次尝试时间。无论运行成功或异常都应调用。
|
||||
|
||||
除了把 running 置回 False,还写入 ``last_attempt_at`` —— 用于失败退避:首次运行
|
||||
若一直崩溃,``last_run_at`` 永远为空,``should_run_now`` 改用
|
||||
``last_attempt_at + _CURATOR_RETRY_HOURS`` 判定,避免每个 tick 都重试。
|
||||
成功跑完后 ``last_run_at`` 已被写入,``should_run_now`` 不再看 ``last_attempt_at``。
|
||||
"""
|
||||
with _run_lock_guard:
|
||||
state = _read_curator_state(user_id)
|
||||
state["running"] = False
|
||||
state["last_attempt_at"] = datetime.now(UTC).isoformat()
|
||||
_write_curator_state(state, user_id)
|
||||
|
||||
|
||||
def _get_curator_config(user_id: str | None = None):
|
||||
"""Return the curator config object for ``user_id``.
|
||||
|
||||
@@ -135,6 +215,21 @@ def _get_curator_config(user_id: str | None = None):
|
||||
return None
|
||||
|
||||
|
||||
def _skill_evolution_enabled(user_id: str | None = None) -> bool:
|
||||
"""返回技能进化总开关 ``skill_evolution.enabled``(已合并 per-user 覆盖)。
|
||||
|
||||
这是整套技能进化功能的总闸:关闭时 curator 完全不动作。任何读取异常都按
|
||||
"关闭"处理(fail-safe)—— 宁可不跑,也不在配置不可读时偷偷运行。
|
||||
"""
|
||||
try:
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
||||
|
||||
skill_evo = load_skill_evolution_config_for_user(user_id)
|
||||
return bool(getattr(skill_evo, "enabled", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
|
||||
"""Return skill names owned by ``user_id`` (used to filter transitions).
|
||||
|
||||
@@ -231,12 +326,39 @@ def _is_skill_published(skill_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _list_published_skill_names() -> set[str]:
|
||||
"""一次性返回所有 ``published=True`` 的技能名集合。
|
||||
|
||||
取代"每个技能调一次 ``_is_skill_published``"的逐条查库 —— curator 一轮里会遍历
|
||||
所有候选技能,逐条查会产生 ≈2N 次独立 event loop + DB 往返。出错时返回空集
|
||||
(fail-open:与 ``_is_skill_published`` 出错返回 False 语义一致,即 DB 不可用时
|
||||
所有技能都视为未发布,回退到 curator 的 legacy 行为)。
|
||||
"""
|
||||
try:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.skills import make_skill_store
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return set()
|
||||
store = make_skill_store(factory)
|
||||
|
||||
async def _run() -> set[str]:
|
||||
return await store.list_published_names()
|
||||
|
||||
return _run_async_blocking(_run())
|
||||
except Exception:
|
||||
logger.debug("_list_published_skill_names failed", exc_info=True)
|
||||
return set()
|
||||
|
||||
|
||||
def _eligible_for_curation(
|
||||
name: str,
|
||||
entry: dict,
|
||||
*,
|
||||
owned_set: set[str] | None,
|
||||
referenced_set: set[str],
|
||||
published_set: set[str],
|
||||
) -> bool:
|
||||
"""Three-condition gate for whether curator may touch a skill.
|
||||
|
||||
@@ -244,11 +366,15 @@ def _eligible_for_curation(
|
||||
1. source == "agent" (caller is expected to have already filtered, but
|
||||
we double-check here for safety)
|
||||
2. NOT referenced by any agent's skills config
|
||||
3. NOT published (queried via SkillStore)
|
||||
3. NOT published
|
||||
|
||||
``owned_set`` is the set of skill names owned by the running user. When
|
||||
None, the legacy "process every agent-created skill" fallback applies
|
||||
for ownership only (the other two conditions still hold).
|
||||
|
||||
``published_set`` is the set of published skill names, pre-fetched once by
|
||||
the caller via ``_list_published_skill_names()`` — avoids a per-skill DB
|
||||
round-trip.
|
||||
"""
|
||||
if entry.get("source") != "agent":
|
||||
return False
|
||||
@@ -256,7 +382,7 @@ def _eligible_for_curation(
|
||||
return False
|
||||
if name in referenced_set:
|
||||
return False
|
||||
if _is_skill_published(name):
|
||||
if name in published_set:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -264,21 +390,52 @@ def _eligible_for_curation(
|
||||
def should_run_now(user_id: str) -> bool:
|
||||
"""Return True if the curator interval has elapsed for ``user_id`` and it
|
||||
is not paused."""
|
||||
curator_cfg = _get_curator_config()
|
||||
if curator_cfg is None or not getattr(curator_cfg, "enabled", True):
|
||||
# 总开关:技能进化整体关闭时,curator 一律不跑。
|
||||
if not _skill_evolution_enabled(user_id):
|
||||
return False
|
||||
curator_cfg = _get_curator_config(user_id)
|
||||
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
|
||||
return False
|
||||
state = _read_curator_state(user_id)
|
||||
if state.get("paused"):
|
||||
return False
|
||||
# 运行锁:已有 curator 在跑(且锁未过期)时不再触发新的一轮。
|
||||
if _run_lock_active(state, datetime.now(UTC)):
|
||||
return False
|
||||
last_run = state.get("last_run_at")
|
||||
if not last_run:
|
||||
return True
|
||||
# 从未成功跑完过。若此前已尝试过(last_attempt_at 存在),按较短的重试间隔
|
||||
# 退避 —— 避免首次运行持续失败时每个 tick(1h)都重试、每次烧一份 LLM 配额。
|
||||
attempt_dt = _parse_state_time(state.get("last_attempt_at"))
|
||||
if attempt_dt is None:
|
||||
return True
|
||||
return datetime.now(UTC) >= attempt_dt + timedelta(hours=_CURATOR_RETRY_HOURS)
|
||||
interval_hours = getattr(curator_cfg, "interval_hours", 168)
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
|
||||
except Exception:
|
||||
last_dt = _parse_state_time(last_run)
|
||||
if last_dt is None:
|
||||
# 时间串损坏 —— 视同"从未跑过",允许重跑并重写正确时间戳,而不是像旧逻辑那样
|
||||
# except 返回 False 导致该用户永久不再触发。
|
||||
return True
|
||||
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
|
||||
|
||||
|
||||
def manual_trigger_blocked_reason(user_id: str) -> str | None:
|
||||
"""手动触发 curator 的预检。返回 None 表示可以跑;否则返回阻塞原因代码:
|
||||
``evolution_disabled`` / ``curator_disabled`` / ``already_running``。
|
||||
|
||||
供 ``POST /api/curator/run`` 在派发后台线程**前**同步判定 —— 后台线程
|
||||
(``_curator_thread_target``)本身也会再查一遍,这里只是为了让前端拿到准确反馈,
|
||||
而不是无论开关状态如何都提示"已开始"。
|
||||
"""
|
||||
if not _skill_evolution_enabled(user_id):
|
||||
return "evolution_disabled"
|
||||
curator_cfg = _get_curator_config(user_id)
|
||||
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
|
||||
return "curator_disabled"
|
||||
state = _read_curator_state(user_id)
|
||||
if _run_lock_active(state, datetime.now(UTC)):
|
||||
return "already_running"
|
||||
return None
|
||||
|
||||
|
||||
def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict:
|
||||
@@ -297,12 +454,13 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
|
||||
if now is None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
curator_cfg = _get_curator_config()
|
||||
curator_cfg = _get_curator_config(user_id)
|
||||
stale_days = getattr(curator_cfg, "stale_after_days", 30) if curator_cfg else 30
|
||||
archive_days = getattr(curator_cfg, "archive_after_days", 90) if curator_cfg else 90
|
||||
|
||||
owned = _list_user_owned_skill_names(user_id)
|
||||
referenced = _collect_referenced_skill_names()
|
||||
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
|
||||
raw_entries = agent_created_entries()
|
||||
|
||||
skipped_referenced = 0
|
||||
@@ -314,7 +472,7 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
|
||||
if name in referenced:
|
||||
skipped_referenced += 1
|
||||
continue
|
||||
if _is_skill_published(name):
|
||||
if name in published:
|
||||
skipped_published += 1
|
||||
continue
|
||||
eligible[name] = entry
|
||||
@@ -377,9 +535,10 @@ def _render_candidate_list(user_id: str) -> str:
|
||||
|
||||
owned = _list_user_owned_skill_names(user_id)
|
||||
referenced = _collect_referenced_skill_names()
|
||||
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
|
||||
entries: dict[str, dict] = {}
|
||||
for k, v in all_entries().items():
|
||||
if not _eligible_for_curation(k, v, owned_set=owned, referenced_set=referenced):
|
||||
if not _eligible_for_curation(k, v, owned_set=owned, referenced_set=referenced, published_set=published):
|
||||
continue
|
||||
if v.get("state") == STATE_ARCHIVED:
|
||||
continue
|
||||
@@ -590,8 +749,72 @@ def _make_skill_archive_tool(user_id: str):
|
||||
return _skill_archive
|
||||
|
||||
|
||||
def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
||||
"""Run the curator consolidation pass as a React agent with skill tools bound."""
|
||||
def _make_curator_skill_manage_tool():
|
||||
"""curator 受限版 ``skill_manage``:在工具层屏蔽 delete 动作。
|
||||
|
||||
curator 做 umbrella 合并需要 create / patch / edit / write_file,但绝不能删整个
|
||||
技能 —— prompt 硬规则也写明"归档(skill_archive)是允许的最大操作"。通用
|
||||
skill_manage 的 delete 走 ``_assert_agent_may_delete``,**绕过了** skill_archive
|
||||
对"已发布 / 被 agent 引用"技能的拒绝保护。只靠 prompt 文字约束不可靠,这里在
|
||||
工具层直接拒掉 delete,curator 的 LLM 无论如何都删不掉技能。
|
||||
"""
|
||||
from langchain.tools import tool as lc_tool
|
||||
|
||||
from deerflow.tools.skill_manage_tool import _skill_manage_impl
|
||||
|
||||
@lc_tool("skill_manage")
|
||||
def _curator_skill_manage(
|
||||
action: str,
|
||||
name: str,
|
||||
content: str | None = None,
|
||||
path: str | None = None,
|
||||
find: str | None = None,
|
||||
replace: str | None = None,
|
||||
expected_count: int | None = None,
|
||||
) -> str:
|
||||
"""管理 skills/custom/ 下的自定义技能(curator 受限版)。
|
||||
|
||||
支持 action:create / patch / edit / write_file / remove_file / list / view。
|
||||
**不支持 delete** —— curator 不得删除技能;如需下架请改用 skill_archive。
|
||||
|
||||
Args:
|
||||
action: 操作类型(见上;delete 会被拒绝)。
|
||||
name: 连字符命名的技能名。
|
||||
content: create / edit / write_file 的新文件内容。
|
||||
path: write_file / remove_file 的支持文件路径。
|
||||
find: patch 要替换的原文本。
|
||||
replace: patch 的替换文本。
|
||||
expected_count: patch 的预期替换次数(可选)。
|
||||
"""
|
||||
if action == "delete":
|
||||
return (
|
||||
"Refused: curator 不允许删除技能。归档是允许的最大操作 —— "
|
||||
"请改用 skill_archive(name, reason) 软下架(文件保留,用户可在 Web UI 恢复)。"
|
||||
)
|
||||
# curator 不绑定到具体会话线程,runtime 传 None(_get_thread_id 兼容 None)。
|
||||
return _run_async_blocking(
|
||||
_skill_manage_impl(
|
||||
runtime=None,
|
||||
action=action,
|
||||
name=name,
|
||||
content=content,
|
||||
path=path,
|
||||
find=find,
|
||||
replace=replace,
|
||||
expected_count=expected_count,
|
||||
)
|
||||
)
|
||||
|
||||
return _curator_skill_manage
|
||||
|
||||
|
||||
def _run_llm_review(candidate_list: str, *, user_id: str) -> tuple[str, list[dict]]:
|
||||
"""Run the curator consolidation pass as a React agent with skill tools bound.
|
||||
|
||||
返回 ``(llm_text, tool_actions)``:
|
||||
- ``llm_text`` —— LLM 最终回复文字(它"自述"做了什么);
|
||||
- ``tool_actions`` —— 从消息流提取的**真实**工具调用记录(它"实际"做了什么)。
|
||||
"""
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
try:
|
||||
@@ -601,10 +824,14 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||
|
||||
model = create_chat_model()
|
||||
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(user_id)]
|
||||
curator_cfg = _get_curator_config(user_id)
|
||||
# curator 评审用的模型:per-user 可配 curator.model_name(对应 config.yaml
|
||||
# models[].name);为空则用主模型。
|
||||
model_name = getattr(curator_cfg, "model_name", None) if curator_cfg else None
|
||||
model = create_chat_model(name=model_name)
|
||||
# 用 curator 受限版 skill_manage(屏蔽 delete),不用通用版 —— 见 _make_curator_skill_manage_tool。
|
||||
tools = [skill_list_tool, skill_view_tool, _make_curator_skill_manage_tool(), _make_skill_archive_tool(user_id)]
|
||||
# Reuse the lead-agent error middleware: when the LLM mis-calls a tool
|
||||
# (wrong action / missing required args), the exception is converted
|
||||
# into a ToolMessage(status="error") so the LLM can read the error and
|
||||
@@ -613,30 +840,58 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
||||
# middleware kwarg — use LangChain 1.x's ``create_agent`` which does.
|
||||
agent = create_agent(model=model, tools=tools, middleware=[ToolErrorHandlingMiddleware()])
|
||||
|
||||
curator_log.record("info", "LLM 评审:开始调用 React Agent", user_id=user_id)
|
||||
timeout_seconds = getattr(curator_cfg, "review_timeout_seconds", 600) if curator_cfg else 600
|
||||
curator_log.record(
|
||||
"info",
|
||||
f"LLM 评审:开始调用 React Agent(模型 {model_name or '主模型'},整体超时 {timeout_seconds}s)",
|
||||
user_id=user_id,
|
||||
)
|
||||
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
|
||||
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
|
||||
|
||||
# 给整个 React Agent 评审套一个总超时:agent.invoke 内部可能多轮调 LLM,模型层的
|
||||
# 单次 request_timeout 兜不住整体。超时后本 curator 线程能正常收尾、释放运行锁,
|
||||
# 而不是无限期挂起。注意:Python 无法强杀线程 —— 超时后那个 agent.invoke 线程会
|
||||
# 成为孤儿(最终被模型层 request_timeout 或自然返回所终结),但不再拖住 curator。
|
||||
# copy_context():把 current_user 等 ContextVar 传进工作线程,否则工具调用会把
|
||||
# user_id 误解析为 DEFAULT_USER_ID。
|
||||
ctx = contextvars.copy_context()
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="curator-llm")
|
||||
future = pool.submit(ctx.run, agent.invoke, {"messages": [HumanMessage(content=prompt)]})
|
||||
try:
|
||||
result = future.result(timeout=timeout_seconds)
|
||||
except concurrent.futures.TimeoutError:
|
||||
curator_log.record(
|
||||
"warning",
|
||||
f"LLM 评审超时(超过 {timeout_seconds}s),已中止本次评审",
|
||||
user_id=user_id,
|
||||
)
|
||||
logger.warning("Curator[%s] LLM review timed out after %ss", user_id, timeout_seconds)
|
||||
return "", []
|
||||
finally:
|
||||
# wait=False:超时时不阻塞等待挂死的 invoke 线程。
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
messages = result.get("messages", [])
|
||||
tool_actions = _extract_tool_actions(messages)
|
||||
for msg in reversed(messages):
|
||||
if not getattr(msg, "tool_calls", None):
|
||||
content = getattr(msg, "content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||
return content
|
||||
return content, tool_actions
|
||||
if isinstance(content, list):
|
||||
text = " ".join(
|
||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||
for block in content
|
||||
).strip()
|
||||
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||
return text
|
||||
return text, tool_actions
|
||||
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
|
||||
return ""
|
||||
return "", tool_actions
|
||||
except Exception as e:
|
||||
logger.warning("Curator LLM review failed: %s", e)
|
||||
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
|
||||
return ""
|
||||
return "", []
|
||||
|
||||
|
||||
def _parse_structured_summary(text: str) -> dict:
|
||||
@@ -653,6 +908,44 @@ def _parse_structured_summary(text: str) -> dict:
|
||||
return {"consolidations": [], "prunings": []}
|
||||
|
||||
|
||||
def _extract_tool_actions(messages: list) -> list[dict]:
|
||||
"""从 agent 消息流里提取**真实发生的**工具调用 + 结果。
|
||||
|
||||
与 _parse_structured_summary(解析 LLM 自述的 YAML)互补:这里记录的是 curator
|
||||
实际调了哪些工具、入参是什么、成功还是失败 —— 用于审计"嘴上说的"与"实际做的"
|
||||
是否一致。每条形如 ``{tool, args, status, result}``。
|
||||
"""
|
||||
# 先按 tool_call_id 收集每次调用的工具名与入参(来自 AIMessage.tool_calls)
|
||||
calls: dict[str, dict] = {}
|
||||
for msg in messages:
|
||||
for tc in getattr(msg, "tool_calls", None) or []:
|
||||
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
|
||||
if not tc_id:
|
||||
continue
|
||||
calls[tc_id] = {
|
||||
"tool": tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None),
|
||||
"args": (tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", None)) or {},
|
||||
}
|
||||
# 再按 ToolMessage(带 tool_call_id)收集真实结果
|
||||
actions: list[dict] = []
|
||||
for msg in messages:
|
||||
tc_id = getattr(msg, "tool_call_id", None)
|
||||
if not tc_id:
|
||||
continue
|
||||
call = calls.get(tc_id, {})
|
||||
raw = getattr(msg, "content", "")
|
||||
result = raw if isinstance(raw, str) else str(raw)
|
||||
actions.append(
|
||||
{
|
||||
"tool": call.get("tool") or getattr(msg, "name", None),
|
||||
"args": call.get("args", {}),
|
||||
"status": getattr(msg, "status", None) or "success",
|
||||
"result": result[:500],
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def run_curator_review(user_id: str) -> dict:
|
||||
"""Main curator run for ``user_id``: transitions → LLM consolidation → state.
|
||||
|
||||
@@ -684,16 +977,23 @@ def run_curator_review(user_id: str) -> dict:
|
||||
"info", f"进入 LLM 评审的候选技能:共 {candidate_line_count} 个", user_id=user_id
|
||||
)
|
||||
|
||||
llm_text = _run_llm_review(candidate_list, user_id=user_id)
|
||||
suggestions = _parse_structured_summary(llm_text)
|
||||
curator_log.record(
|
||||
"info",
|
||||
(
|
||||
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
||||
f"建议删减 {len(suggestions['prunings'])} 项"
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
if candidate_line_count == 0:
|
||||
# 没有候选技能 —— 跳过 LLM 评审,省掉无意义的模型调用。
|
||||
# 年龄流转(上面的 apply_automatic_transitions)已完成,且不依赖 LLM。
|
||||
curator_log.record("info", "无候选技能,跳过 LLM 评审", user_id=user_id)
|
||||
suggestions = {"consolidations": [], "prunings": []}
|
||||
tool_actions: list[dict] = []
|
||||
else:
|
||||
llm_text, tool_actions = _run_llm_review(candidate_list, user_id=user_id)
|
||||
suggestions = _parse_structured_summary(llm_text)
|
||||
curator_log.record(
|
||||
"info",
|
||||
(
|
||||
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
||||
f"建议删减 {len(suggestions['prunings'])} 项"
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
# Surface per-item reasoning so operators can audit *why* each action
|
||||
# was taken, not just the count.
|
||||
for item in suggestions.get("consolidations", []) or []:
|
||||
@@ -718,15 +1018,26 @@ def run_curator_review(user_id: str) -> dict:
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# 上面是 LLM"自述"的建议;下面记录它"实际"调用了哪些工具,供对照审计。
|
||||
curator_log.record("info", f"LLM 实际调用工具 {len(tool_actions)} 次", user_id=user_id)
|
||||
for act in tool_actions:
|
||||
target = act.get("args", {}).get("name", "")
|
||||
curator_log.record(
|
||||
"info",
|
||||
f" 实际调用 [{act.get('tool')}]{f' [{target}]' if target else ''} → {act.get('status')}",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
state = _read_curator_state(user_id)
|
||||
state["last_run_at"] = now.isoformat()
|
||||
state["run_count"] = state.get("run_count", 0) + 1
|
||||
state["last_run_summary"] = (
|
||||
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
||||
f"归档 {transition_counters['archived']} 个;"
|
||||
f"LLM:建议合并 {len(suggestions['consolidations'])} 项"
|
||||
f"LLM:建议合并 {len(suggestions['consolidations'])} 项、实际工具调用 {len(tool_actions)} 次"
|
||||
)
|
||||
state["last_consolidation_suggestions"] = suggestions
|
||||
state["last_consolidation_suggestions"] = suggestions # LLM 自述
|
||||
state["last_actual_actions"] = tool_actions # 真实工具调用记录
|
||||
_write_curator_state(state, user_id)
|
||||
|
||||
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
|
||||
@@ -755,6 +1066,23 @@ def _curator_thread_target(user_id: str) -> None:
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
# 总开关 + curator 子开关:任一关闭则直接跳过。手动触发 /api/curator/run 也经由
|
||||
# 本函数,因此手动触发同样受总开关与 curator.enabled 约束。
|
||||
if not _skill_evolution_enabled(user_id):
|
||||
logger.info("Curator[%s]: 技能进化总开关关闭,跳过", user_id)
|
||||
return
|
||||
curator_cfg = _get_curator_config(user_id)
|
||||
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
|
||||
logger.info("Curator[%s]: curator 未启用,跳过", user_id)
|
||||
return
|
||||
|
||||
# 运行锁:防止同一用户的 curator 并发(定时器 vs 手动触发、连点手动触发)。抢不到
|
||||
# 锁说明已有一轮在跑,直接跳过本次触发。
|
||||
if not _try_acquire_run_lock(user_id, datetime.now(UTC)):
|
||||
logger.info("Curator[%s]: 已有 curator 正在运行,跳过本次触发", user_id)
|
||||
curator_log.record("info", "已有 curator 正在运行,跳过本次触发", user_id=user_id)
|
||||
return
|
||||
|
||||
# Bind the per-thread user context for the entire curator run so every
|
||||
# downstream tool / repository call resolves the correct user_id.
|
||||
token = set_current_user(_CuratorUser(user_id))
|
||||
@@ -765,13 +1093,16 @@ def _curator_thread_target(user_id: str) -> None:
|
||||
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
# 无论成功或异常,必定释放运行锁。
|
||||
_release_run_lock(user_id)
|
||||
|
||||
|
||||
async def maybe_run_curator() -> None:
|
||||
"""Async startup entry — kicks off a thread per user whose interval elapsed.
|
||||
"""周期调度入口 —— 给每个 interval 已到期的用户起一个后台策展线程。
|
||||
|
||||
Skips users with no DB-tracked entries (a fresh deployment) and never
|
||||
blocks lifespan startup on failure.
|
||||
由 app 的 curator 调度器周期性调用(也用于手动触发)。无候选技能的用户,其
|
||||
LLM 评审会在 run_curator_review 内被跳过 —— 只做不耗费 LLM 的年龄流转。
|
||||
任何失败都不会阻断调用方。
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
@@ -789,20 +1120,42 @@ async def maybe_run_curator() -> None:
|
||||
logger.exception("Curator: failed to enumerate users at startup")
|
||||
return
|
||||
|
||||
due_users: list[str] = []
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
if not should_run_now(user_id):
|
||||
continue
|
||||
logger.info("Curator[%s]: interval elapsed, spawning background thread", user_id)
|
||||
thread = threading.Thread(
|
||||
target=_curator_thread_target,
|
||||
args=(user_id,),
|
||||
daemon=True,
|
||||
name=f"skill-curator-{user_id}",
|
||||
)
|
||||
thread.start()
|
||||
if should_run_now(user_id):
|
||||
due_users.append(user_id)
|
||||
except Exception:
|
||||
logger.exception("Curator: failed to spawn for user %s", user_id)
|
||||
logger.exception("Curator: should_run_now check failed for user %s", user_id)
|
||||
_dispatch_curator_runs(due_users)
|
||||
|
||||
|
||||
def _dispatch_curator_runs(due_users: list[str]) -> None:
|
||||
"""用线程池(并发上限 ``_CURATOR_MAX_CONCURRENCY``)派发到期用户的 curator 执行。
|
||||
|
||||
到期用户排队消费,而非一次性全部起线程齐调 LLM —— 防首次部署所有用户同时到期时
|
||||
的惊群。``shutdown(wait=False)``:提交后立即返回,线程池在所有任务跑完后自行回收。
|
||||
抽成独立函数便于对并发上限做单元测试。
|
||||
"""
|
||||
if not due_users:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Curator: %d 个用户到期,以并发上限 %d 派发执行",
|
||||
len(due_users),
|
||||
_CURATOR_MAX_CONCURRENCY,
|
||||
)
|
||||
executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=_CURATOR_MAX_CONCURRENCY,
|
||||
thread_name_prefix="skill-curator",
|
||||
)
|
||||
try:
|
||||
for user_id in due_users:
|
||||
executor.submit(_curator_thread_target, user_id)
|
||||
except Exception:
|
||||
logger.exception("Curator: failed to submit curator tasks")
|
||||
finally:
|
||||
executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def get_curator_status(user_id: str) -> dict:
|
||||
@@ -819,7 +1172,7 @@ def get_curator_status(user_id: str) -> dict:
|
||||
s = entry.get("state", STATE_ACTIVE)
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
|
||||
cfg = _get_curator_config()
|
||||
cfg = _get_curator_config(user_id)
|
||||
return {
|
||||
"enabled": cfg is not None and getattr(cfg, "enabled", True),
|
||||
"run_count": state.get("run_count", 0),
|
||||
@@ -832,4 +1185,8 @@ def get_curator_status(user_id: str) -> dict:
|
||||
"stale": counts[STATE_STALE],
|
||||
"archived": counts[STATE_ARCHIVED],
|
||||
},
|
||||
# 上次运行的两份记录:LLM 自述的建议 + 实际发生的工具调用,供前端对照展示。
|
||||
"last_consolidation_suggestions": state.get("last_consolidation_suggestions")
|
||||
or {"consolidations": [], "prunings": []},
|
||||
"last_actual_actions": state.get("last_actual_actions") or [],
|
||||
}
|
||||
+4
-2
@@ -50,8 +50,10 @@ async def scan_skill_content(content: str, *, executable: bool = False, location
|
||||
|
||||
try:
|
||||
config = app_config or get_app_config()
|
||||
model_name = config.skill_evolution.moderation_model_name
|
||||
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config) if model_name else create_chat_model(thinking_enabled=False, app_config=config)
|
||||
# 安全审核复用 curator 的评审模型配置(skill_evolution.curator.model_name):
|
||||
# 二者用同一个模型。未配置时 model_name 为 None,create_chat_model 回退主模型。
|
||||
model_name = config.skill_evolution.curator.model_name
|
||||
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config)
|
||||
response = await model.ainvoke(
|
||||
[
|
||||
{"role": "system", "content": rubric},
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,6 +14,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_USAGE_FILENAME = ".usage.json"
|
||||
|
||||
# 串行化对 .usage.json 的"读-改-写",避免并发(尤其 curator 多线程)丢更新。
|
||||
# .usage.json 是全局单文件;此锁覆盖进程内并发。leader 选举已使 curator 只在
|
||||
# 单进程跑,故进程内锁即可保证状态流转不丢。
|
||||
_usage_lock = threading.Lock()
|
||||
|
||||
STATE_ACTIVE = "active"
|
||||
STATE_STALE = "stale"
|
||||
STATE_ARCHIVED = "archived"
|
||||
@@ -75,13 +81,20 @@ def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _update_entry(name: str, updates: dict) -> None:
|
||||
def _update_entry(name: str, updates) -> None:
|
||||
"""更新一个 usage 条目。``updates`` 可以是 dict,或 ``(entry) -> dict`` 回调。
|
||||
|
||||
用回调形式可让"读当前值 -> 算增量"也落在锁内(例如计数自增),
|
||||
避免并发下的 lost update。
|
||||
"""
|
||||
try:
|
||||
data = _read_all()
|
||||
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
|
||||
entry.update(updates)
|
||||
data[name] = entry
|
||||
_write_all(data)
|
||||
with _usage_lock:
|
||||
data = _read_all()
|
||||
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
|
||||
resolved = updates(entry) if callable(updates) else updates
|
||||
entry.update(resolved)
|
||||
data[name] = entry
|
||||
_write_all(data)
|
||||
except Exception:
|
||||
logger.debug("Failed to update usage entry for %r", name, exc_info=True)
|
||||
|
||||
@@ -103,18 +116,16 @@ def agent_created_entries() -> dict:
|
||||
|
||||
|
||||
def bump_view(name: str) -> None:
|
||||
entry = get_entry(name)
|
||||
_update_entry(name, {"view_count": entry.get("view_count", 0) + 1, "last_viewed_at": _now_iso()})
|
||||
# 用回调:读当前计数 + 自增在同一把锁内完成,避免并发丢计数。
|
||||
_update_entry(name, lambda e: {"view_count": e.get("view_count", 0) + 1, "last_viewed_at": _now_iso()})
|
||||
|
||||
|
||||
def bump_use(name: str) -> None:
|
||||
entry = get_entry(name)
|
||||
_update_entry(name, {"use_count": entry.get("use_count", 0) + 1, "last_used_at": _now_iso()})
|
||||
_update_entry(name, lambda e: {"use_count": e.get("use_count", 0) + 1, "last_used_at": _now_iso()})
|
||||
|
||||
|
||||
def bump_patch(name: str) -> None:
|
||||
entry = get_entry(name)
|
||||
_update_entry(name, {"patch_count": entry.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
|
||||
_update_entry(name, lambda e: {"patch_count": e.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
|
||||
|
||||
|
||||
def set_state(name: str, state: str) -> None:
|
||||
@@ -132,9 +143,10 @@ def set_pinned(name: str, pinned: bool) -> None:
|
||||
|
||||
def forget(name: str) -> None:
|
||||
try:
|
||||
data = _read_all()
|
||||
data.pop(name, None)
|
||||
_write_all(data)
|
||||
with _usage_lock:
|
||||
data = _read_all()
|
||||
data.pop(name, None)
|
||||
_write_all(data)
|
||||
except Exception:
|
||||
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
|
||||
|
||||
|
||||
+84
-5
@@ -59,6 +59,66 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_effective_user_id() -> str | None:
|
||||
"""当前请求/线程绑定的用户 id;解析失败时返回 None。"""
|
||||
try:
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
return get_effective_user_id()
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve effective user id for skill_list", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _run_coro_blocking(coro):
|
||||
"""在同步工具里把一个协程跑到完成。
|
||||
|
||||
``skill_list`` 是同步 ``@tool``:LangChain 通常在线程池里执行它(无运行中的
|
||||
事件循环),但 curator 评审会在已有事件循环的线程里调用它。两种情况都要能
|
||||
拿到 ``SkillStore`` 的异步查询结果 —— 检测到运行中的事件循环就转交独立线程
|
||||
执行 ``asyncio.run``,否则直接跑。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
in_loop = True
|
||||
except RuntimeError:
|
||||
in_loop = False
|
||||
|
||||
if in_loop:
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _load_owned_skill_names(user_id: str | None) -> set[str] | None:
|
||||
"""返回 ``owner_user_id == user_id`` 的自定义技能名集合。
|
||||
|
||||
用于默认 lead agent 的可见性过滤:只算"用户自己拥有的"自定义技能,不含
|
||||
其他用户发布的。返回 ``None`` 表示 ``SkillStore`` 不可用(未配置数据库
|
||||
等)—— 调用方应降级为"放行全部自定义技能",避免离线无库部署下技能凭空
|
||||
消失。
|
||||
"""
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.skills import make_skill_store
|
||||
|
||||
factory = get_session_factory()
|
||||
if factory is None:
|
||||
return None
|
||||
store = make_skill_store(factory)
|
||||
records = _run_coro_blocking(store.list_visible(user_id))
|
||||
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
|
||||
except Exception:
|
||||
logger.debug("Failed to load owned skill names for skill_list filter", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@tool("skill_view")
|
||||
def skill_view_tool(name: str) -> str:
|
||||
"""Read the full SKILL.md content of a skill by name.
|
||||
@@ -106,28 +166,47 @@ def skill_list_tool(runtime: ToolRuntime) -> str:
|
||||
under the ``skills:`` field — the agent only sees what it was wired to
|
||||
use.
|
||||
|
||||
For the **default lead agent** (no agent_id in runtime), there is no
|
||||
per-agent whitelist and every visible skill is returned.
|
||||
For the **default lead agent** (no agent_id in runtime), the result is
|
||||
filtered to non-archived skills that are either built-in (public) or
|
||||
owned by the current user. Other users' published custom skills and
|
||||
archived skills are excluded.
|
||||
|
||||
Returns a JSON array with fields: name, description, category, enabled,
|
||||
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
||||
source.
|
||||
"""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.usage import all_entries, latest_activity_at
|
||||
from deerflow.skills.types import SkillCategory
|
||||
from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at
|
||||
|
||||
agent_id = _resolve_active_agent_id(runtime)
|
||||
allowed = _load_agent_skill_whitelist(agent_id)
|
||||
|
||||
# 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于
|
||||
# 当前用户"的技能。owned 为 None 表示 SkillStore 不可用,降级放行全部自定义
|
||||
# 技能(见 _load_owned_skill_names)。
|
||||
owned: set[str] | None = None
|
||||
if allowed is None:
|
||||
owned = _load_owned_skill_names(_resolve_effective_user_id())
|
||||
|
||||
try:
|
||||
storage = get_or_new_skill_storage()
|
||||
skills = storage.load_skills(enabled_only=False)
|
||||
usage = all_entries()
|
||||
result = []
|
||||
for skill in skills:
|
||||
if allowed is not None and skill.name not in allowed:
|
||||
continue
|
||||
entry = usage.get(skill.name, {})
|
||||
if allowed is not None:
|
||||
# 自定义 agent:严格按 config.yaml 的 skills 白名单过滤。
|
||||
if skill.name not in allowed:
|
||||
continue
|
||||
else:
|
||||
# 默认 agent:跳过已归档技能。
|
||||
if entry.get("state") == STATE_ARCHIVED:
|
||||
continue
|
||||
# 自定义技能仅当属于当前用户时可见;内置 public 技能始终可见。
|
||||
if skill.category == SkillCategory.CUSTOM and owned is not None and skill.name not in owned:
|
||||
continue
|
||||
result.append({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
|
||||
@@ -84,12 +84,26 @@ def get_available_tools(
|
||||
builtin_tools = BUILTIN_TOOLS.copy()
|
||||
if not is_scheduled_run:
|
||||
builtin_tools.append(scheduled_task_tool)
|
||||
skill_evolution_config = getattr(config, "skill_evolution", None)
|
||||
# skill_list / skill_view 是只读工具(发现、查看技能),与"自我进化"写能力无关,
|
||||
# 始终提供给 agent。
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||
|
||||
builtin_tools.extend([skill_list_tool, skill_view_tool])
|
||||
|
||||
# skill_manage 能创建/修改/删除技能,属于"自我进化"写能力,按 skill_evolution.enabled
|
||||
# 门控。该开关用户可在 per-user 配置里覆盖,所以按"生效配置(含 per-user 覆盖)"判断 ——
|
||||
# Gateway 每次 run 重建 agent,用户改了配置下次 run 即生效;解析失败退回全局 config.yaml。
|
||||
try:
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
skill_evolution_config = load_skill_evolution_config_for_user(get_effective_user_id())
|
||||
except Exception: # noqa: BLE001 - 解析失败不能阻断 agent 构建
|
||||
skill_evolution_config = getattr(config, "skill_evolution", None)
|
||||
if getattr(skill_evolution_config, "enabled", False):
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||
|
||||
builtin_tools.extend([skill_list_tool, skill_view_tool, skill_manage_tool])
|
||||
builtin_tools.append(skill_manage_tool)
|
||||
|
||||
# Add subagent tools only if enabled via runtime parameter
|
||||
if subagent_enabled:
|
||||
|
||||
@@ -25,12 +25,12 @@ def test_load_overrides_returns_none_when_missing(overrides_path):
|
||||
def test_save_and_load_overrides_roundtrip(overrides_path):
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"moderation_model_name": "moderation-mini",
|
||||
"curator": {
|
||||
"enabled": True,
|
||||
"interval_hours": 12,
|
||||
"stale_after_days": 7,
|
||||
"archive_after_days": 30,
|
||||
"model_name": "curator-mini",
|
||||
},
|
||||
}
|
||||
written = skill_evolution_runtime.save_overrides(payload)
|
||||
|
||||
@@ -501,7 +501,8 @@ def test_curator_skips_published_skill(isolated_paths, monkeypatch) -> None:
|
||||
|
||||
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"public-tool"})
|
||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
|
||||
monkeypatch.setattr(curator, "_is_skill_published", lambda name: name == "public-tool")
|
||||
# curator 现在用批量接口一次性查 published 集合,而非逐技能 _is_skill_published。
|
||||
monkeypatch.setattr(curator, "_list_published_skill_names", lambda: {"public-tool"})
|
||||
_isolated_skills_root(isolated_paths, monkeypatch)
|
||||
_seed_old_agent_skill("public-tool")
|
||||
|
||||
|
||||
Generated
+2692
-2692
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user