Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
4761677676
74 files changed
+9770
-2887
No files matched your search
@@ -0,0 +1,726 @@
|
|||||||
|
# AI 写作助手后端接口与 Prompt 协议文档
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
|
||||||
|
|
||||||
|
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 本轮后端职责
|
||||||
|
|
||||||
|
后端负责:
|
||||||
|
|
||||||
|
- 接收当前线程信息
|
||||||
|
- 接收用户选区文本
|
||||||
|
- 接收改写动作
|
||||||
|
- 选取必要上下文
|
||||||
|
- 调用模型生成候选改写结果
|
||||||
|
- 返回纯净候选文本
|
||||||
|
|
||||||
|
后端本轮不负责:
|
||||||
|
|
||||||
|
- 直接编辑 Tiptap JSON
|
||||||
|
- 直接操作前端编辑器选区
|
||||||
|
- 直接替换前端文档内容
|
||||||
|
- 做复杂富文本 patch
|
||||||
|
- 做 tracked changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 设计原则
|
||||||
|
|
||||||
|
### 3.1 纯文本协议优先
|
||||||
|
|
||||||
|
首版只让后端处理:
|
||||||
|
|
||||||
|
- 选区文本
|
||||||
|
- 前后文文本
|
||||||
|
- 动作类型
|
||||||
|
- 风格参数
|
||||||
|
|
||||||
|
不让后端直接理解完整富文本 JSON。
|
||||||
|
|
||||||
|
### 3.2 前端负责应用,后端负责生成
|
||||||
|
|
||||||
|
- 前端负责选区获取
|
||||||
|
- 前端负责候选结果展示
|
||||||
|
- 前端负责替换 / 插入
|
||||||
|
- 后端只负责“给出候选文本”
|
||||||
|
|
||||||
|
### 3.3 结果必须可直接落文档
|
||||||
|
|
||||||
|
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
|
||||||
|
|
||||||
|
- “当然可以,以下是润色版本:”
|
||||||
|
- “我帮你改写如下:”
|
||||||
|
- “下面是优化后的内容:”
|
||||||
|
|
||||||
|
这类内容必须禁止。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 推荐接口设计
|
||||||
|
|
||||||
|
## 4.1 主接口
|
||||||
|
|
||||||
|
推荐新增统一接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/writing/rewrite
|
||||||
|
```
|
||||||
|
|
||||||
|
由 `action` 决定具体重写类型。
|
||||||
|
|
||||||
|
## 4.2 为什么推荐统一接口
|
||||||
|
|
||||||
|
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
|
||||||
|
|
||||||
|
- 前端判断分支增多
|
||||||
|
- 埋点与异常处理分散
|
||||||
|
- 后续新增动作不方便扩展
|
||||||
|
|
||||||
|
统一接口更适合本轮需求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 请求体定义
|
||||||
|
|
||||||
|
建议请求体如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"threadId": "string",
|
||||||
|
"documentId": "string",
|
||||||
|
"selection": {
|
||||||
|
"from": 120,
|
||||||
|
"to": 260,
|
||||||
|
"text": "待改写文本"
|
||||||
|
},
|
||||||
|
"action": "polish",
|
||||||
|
"tone": "professional",
|
||||||
|
"surroundingContext": {
|
||||||
|
"before": "选区前文",
|
||||||
|
"after": "选区后文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 字段说明
|
||||||
|
|
||||||
|
### 6.1 `threadId`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 关联当前 DeerFlow 线程
|
||||||
|
- 必要时取线程上下文或会话元数据
|
||||||
|
|
||||||
|
### 6.2 `documentId`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 标识当前右侧文档
|
||||||
|
- 首版可选
|
||||||
|
- 如果当前系统还没有独立文档 ID,可先允许为空
|
||||||
|
|
||||||
|
### 6.3 `selection.from` / `selection.to`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 帮助前后端联调排查
|
||||||
|
- 首版后端通常不需要真正使用偏移量
|
||||||
|
- 建议保留,便于日志定位
|
||||||
|
|
||||||
|
### 6.4 `selection.text`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 这是本轮最核心输入
|
||||||
|
- 后端实际重写的主文本
|
||||||
|
|
||||||
|
### 6.5 `action`
|
||||||
|
|
||||||
|
允许值:
|
||||||
|
|
||||||
|
- `polish`
|
||||||
|
- `expand`
|
||||||
|
- `shorten`
|
||||||
|
- `tone`
|
||||||
|
|
||||||
|
### 6.6 `tone`
|
||||||
|
|
||||||
|
仅当 `action = tone` 时使用。
|
||||||
|
|
||||||
|
建议允许值:
|
||||||
|
|
||||||
|
- `professional`
|
||||||
|
- `casual`
|
||||||
|
- `readable`
|
||||||
|
- `academic`
|
||||||
|
- `expressive`
|
||||||
|
|
||||||
|
### 6.7 `surroundingContext.before`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 提供选区前文
|
||||||
|
- 帮助模型理解语气、主语、叙事连续性
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 控制长度
|
||||||
|
- 不要塞太多无关上下文
|
||||||
|
|
||||||
|
### 6.8 `surroundingContext.after`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 提供选区后文
|
||||||
|
- 帮助模型在扩写、改语气时与后续内容衔接
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 返回体定义
|
||||||
|
|
||||||
|
建议返回体如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"output": "AI 改写后的候选文本",
|
||||||
|
"originalSelection": {
|
||||||
|
"from": 120,
|
||||||
|
"to": 260,
|
||||||
|
"text": "待改写文本"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"action": "polish",
|
||||||
|
"tone": null,
|
||||||
|
"model": "your-model-name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 返回字段要求
|
||||||
|
|
||||||
|
## 8.1 `output`
|
||||||
|
|
||||||
|
必须:
|
||||||
|
|
||||||
|
- 只包含候选正文
|
||||||
|
- 尽量不夹带解释语
|
||||||
|
- 能直接被前端替换或插入
|
||||||
|
|
||||||
|
## 8.2 `originalSelection`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 前端可以做校验
|
||||||
|
- 联调时方便确认是针对哪段文本返回的
|
||||||
|
|
||||||
|
## 8.3 `metadata`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 便于埋点
|
||||||
|
- 便于日志记录
|
||||||
|
- 便于问题追踪
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 错误响应规范
|
||||||
|
|
||||||
|
建议统一错误结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "WRITING_REWRITE_FAILED",
|
||||||
|
"message": "rewrite failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议错误码:
|
||||||
|
|
||||||
|
- `WRITING_REWRITE_FAILED`
|
||||||
|
- `WRITING_INVALID_ACTION`
|
||||||
|
- `WRITING_INVALID_TONE`
|
||||||
|
- `WRITING_EMPTY_SELECTION`
|
||||||
|
- `WRITING_MODEL_UNAVAILABLE`
|
||||||
|
- `WRITING_CONTEXT_TOO_LARGE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 动作语义定义
|
||||||
|
|
||||||
|
## 10.1 `polish`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 保持原意
|
||||||
|
- 改善表达
|
||||||
|
- 提升通顺度、自然度和可读性
|
||||||
|
|
||||||
|
不应:
|
||||||
|
|
||||||
|
- 引入新事实
|
||||||
|
- 改写成完全不同内容
|
||||||
|
|
||||||
|
## 10.2 `expand`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 保持主旨
|
||||||
|
- 扩充细节
|
||||||
|
- 增强画面感、解释性或完整度
|
||||||
|
|
||||||
|
不应:
|
||||||
|
|
||||||
|
- 无限制发散
|
||||||
|
- 添加明显脱离主题的新内容
|
||||||
|
|
||||||
|
## 10.3 `shorten`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 压缩篇幅
|
||||||
|
- 保留关键信息
|
||||||
|
- 减少重复表达
|
||||||
|
|
||||||
|
不应:
|
||||||
|
|
||||||
|
- 丢失关键论点
|
||||||
|
- 压缩到语义残缺
|
||||||
|
|
||||||
|
## 10.4 `tone`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 不改变原意
|
||||||
|
- 调整语言风格
|
||||||
|
|
||||||
|
不应:
|
||||||
|
|
||||||
|
- 改写事实
|
||||||
|
- 改写逻辑结构到完全不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Prompt 设计原则
|
||||||
|
|
||||||
|
## 11.1 通用系统约束
|
||||||
|
|
||||||
|
所有改写类 prompt 都应包含以下约束:
|
||||||
|
|
||||||
|
- 直接输出最终可用正文
|
||||||
|
- 不输出标题,除非原文本身就是标题改写
|
||||||
|
- 不输出解释
|
||||||
|
- 不输出分析过程
|
||||||
|
- 不输出“以下是结果”之类前缀
|
||||||
|
- 保持与上下文语气一致
|
||||||
|
|
||||||
|
## 11.2 上下文使用原则
|
||||||
|
|
||||||
|
前后文只用于理解,不要求模型重写前后文。
|
||||||
|
|
||||||
|
明确告诉模型:
|
||||||
|
|
||||||
|
- 只改写选中文本
|
||||||
|
- 前后文仅用于参考衔接
|
||||||
|
|
||||||
|
## 11.3 长度控制原则
|
||||||
|
|
||||||
|
- `polish`:长度与原文接近
|
||||||
|
- `expand`:长度可明显增加,但不要失控
|
||||||
|
- `shorten`:长度明显缩短,但要保留关键信息
|
||||||
|
- `tone`:长度尽量与原文接近
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Prompt 模板建议
|
||||||
|
|
||||||
|
## 12.1 润色模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一名中文写作编辑助手。
|
||||||
|
|
||||||
|
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 保留原文事实和核心意思。
|
||||||
|
2. 不要添加原文没有的新事实。
|
||||||
|
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||||
|
4. 直接输出润色后的正文。
|
||||||
|
|
||||||
|
前文参考:
|
||||||
|
{before}
|
||||||
|
|
||||||
|
选中文本:
|
||||||
|
{selection}
|
||||||
|
|
||||||
|
后文参考:
|
||||||
|
{after}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12.2 扩写模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一名中文写作编辑助手。
|
||||||
|
|
||||||
|
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 保持原文主旨不变。
|
||||||
|
2. 增加内容时要自然,不要跑题。
|
||||||
|
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||||
|
4. 直接输出扩写后的正文。
|
||||||
|
|
||||||
|
前文参考:
|
||||||
|
{before}
|
||||||
|
|
||||||
|
选中文本:
|
||||||
|
{selection}
|
||||||
|
|
||||||
|
后文参考:
|
||||||
|
{after}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12.3 缩写模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一名中文写作编辑助手。
|
||||||
|
|
||||||
|
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 不要丢掉核心信息。
|
||||||
|
2. 去掉重复、冗余和拖沓表达。
|
||||||
|
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||||
|
4. 直接输出缩写后的正文。
|
||||||
|
|
||||||
|
前文参考:
|
||||||
|
{before}
|
||||||
|
|
||||||
|
选中文本:
|
||||||
|
{selection}
|
||||||
|
|
||||||
|
后文参考:
|
||||||
|
{after}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12.4 改语气模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一名中文写作编辑助手。
|
||||||
|
|
||||||
|
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 保持原文信息不变。
|
||||||
|
2. 只调整表达风格,不改变事实和逻辑。
|
||||||
|
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||||
|
4. 直接输出改写后的正文。
|
||||||
|
|
||||||
|
前文参考:
|
||||||
|
{before}
|
||||||
|
|
||||||
|
选中文本:
|
||||||
|
{selection}
|
||||||
|
|
||||||
|
后文参考:
|
||||||
|
{after}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Tone 映射建议
|
||||||
|
|
||||||
|
建议前端传标准值,后端映射中文标签:
|
||||||
|
|
||||||
|
- `professional` -> 专业、正式
|
||||||
|
- `casual` -> 口语化、轻松自然
|
||||||
|
- `readable` -> 更易读、更清晰
|
||||||
|
- `academic` -> 学术、严谨
|
||||||
|
- `expressive` -> 更有文采、更有表现力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 上下文截取策略
|
||||||
|
|
||||||
|
## 14.1 推荐策略
|
||||||
|
|
||||||
|
前端传:
|
||||||
|
|
||||||
|
- 选区前固定长度文本
|
||||||
|
- 选区后固定长度文本
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- 前文 200 到 500 字
|
||||||
|
- 后文 200 到 500 字
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
- 足够帮助模型理解文气和衔接
|
||||||
|
- 不会让请求太重
|
||||||
|
|
||||||
|
## 14.2 不建议
|
||||||
|
|
||||||
|
- 把整篇长文全量塞进去
|
||||||
|
- 把整个线程消息都塞进改写接口
|
||||||
|
|
||||||
|
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. LangGraph 接入建议
|
||||||
|
|
||||||
|
## 15.1 推荐接入方式
|
||||||
|
|
||||||
|
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
|
||||||
|
|
||||||
|
建议二选一:
|
||||||
|
|
||||||
|
### 方案 A
|
||||||
|
|
||||||
|
新增专用 router:
|
||||||
|
|
||||||
|
- `POST /api/writing/rewrite`
|
||||||
|
|
||||||
|
内部调用现有模型层 / agent 层。
|
||||||
|
|
||||||
|
### 方案 B
|
||||||
|
|
||||||
|
新增专用 graph node 或 tool:
|
||||||
|
|
||||||
|
- `rewrite_selection`
|
||||||
|
|
||||||
|
再由 router 调用该能力。
|
||||||
|
|
||||||
|
## 15.2 更推荐方案
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 对外暴露统一 router
|
||||||
|
- 内部走已有模型封装或 LangGraph 节点
|
||||||
|
|
||||||
|
这样前端接口更稳定,后续后端内部重构不会影响前端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 后端模块拆解
|
||||||
|
|
||||||
|
## 16.1 模块 A:接口层
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 请求校验
|
||||||
|
- 错误响应
|
||||||
|
- 参数标准化
|
||||||
|
|
||||||
|
需要完成:
|
||||||
|
|
||||||
|
- 统一 rewrite API
|
||||||
|
- action / tone 校验
|
||||||
|
- 空选区校验
|
||||||
|
|
||||||
|
## 16.2 模块 B:上下文拼装层
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 拼装 before / selection / after
|
||||||
|
- 必要时提取线程相关信息
|
||||||
|
|
||||||
|
需要完成:
|
||||||
|
|
||||||
|
- 上下文长度控制
|
||||||
|
- 清洗空白和异常字符
|
||||||
|
|
||||||
|
## 16.3 模块 C:Prompt 层
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 选择模板
|
||||||
|
- 填充 action 和 tone
|
||||||
|
|
||||||
|
需要完成:
|
||||||
|
|
||||||
|
- 4 类动作模板
|
||||||
|
- tone 映射
|
||||||
|
|
||||||
|
## 16.4 模块 D:模型调用层
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 走现有模型配置体系
|
||||||
|
- 执行大模型请求
|
||||||
|
|
||||||
|
需要完成:
|
||||||
|
|
||||||
|
- 统一调用入口
|
||||||
|
- 模型不可用错误处理
|
||||||
|
|
||||||
|
## 16.5 模块 E:结果清洗层
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 去掉解释性前缀
|
||||||
|
- 去掉多余包裹
|
||||||
|
- 标准化返回
|
||||||
|
|
||||||
|
需要完成:
|
||||||
|
|
||||||
|
- 输出清洗
|
||||||
|
- metadata 填充
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 返回结果清洗建议
|
||||||
|
|
||||||
|
即使 prompt 已经约束,模型仍可能输出:
|
||||||
|
|
||||||
|
- “以下是润色后的内容:”
|
||||||
|
- “当然,这里是改写结果:”
|
||||||
|
- “优化如下:”
|
||||||
|
|
||||||
|
建议后端加一层轻量清洗:
|
||||||
|
|
||||||
|
- 去掉常见说明性前缀
|
||||||
|
- 去掉包裹性 markdown 代码块
|
||||||
|
- 去掉多余首尾空白
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 清洗必须保守
|
||||||
|
- 不要误删正文
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 日志与观测建议
|
||||||
|
|
||||||
|
建议记录:
|
||||||
|
|
||||||
|
- `threadId`
|
||||||
|
- `documentId`
|
||||||
|
- `action`
|
||||||
|
- `tone`
|
||||||
|
- `selection_length`
|
||||||
|
- `before_length`
|
||||||
|
- `after_length`
|
||||||
|
- `model_name`
|
||||||
|
- `latency_ms`
|
||||||
|
- `success / fail`
|
||||||
|
|
||||||
|
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 后端测试清单
|
||||||
|
|
||||||
|
## 19.1 单元测试
|
||||||
|
|
||||||
|
- action 校验
|
||||||
|
- tone 校验
|
||||||
|
- 空选区校验
|
||||||
|
- prompt 选择逻辑
|
||||||
|
- 输出清洗逻辑
|
||||||
|
|
||||||
|
## 19.2 集成测试
|
||||||
|
|
||||||
|
- `polish`
|
||||||
|
- `expand`
|
||||||
|
- `shorten`
|
||||||
|
- `tone-professional`
|
||||||
|
- `tone-casual`
|
||||||
|
- `tone-readable`
|
||||||
|
- `tone-academic`
|
||||||
|
- `tone-expressive`
|
||||||
|
|
||||||
|
## 19.3 边界测试
|
||||||
|
|
||||||
|
- 选区过短
|
||||||
|
- 选区过长
|
||||||
|
- 前后文为空
|
||||||
|
- 多段文本带换行
|
||||||
|
- 模型超时
|
||||||
|
- 模型不可用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. 联调约束
|
||||||
|
|
||||||
|
## 20.1 前后端必须统一
|
||||||
|
|
||||||
|
- `action` 枚举值
|
||||||
|
- `tone` 枚举值
|
||||||
|
- 错误码
|
||||||
|
- 返回体字段名
|
||||||
|
|
||||||
|
## 20.2 联调重点
|
||||||
|
|
||||||
|
- 多段落文本中换行保留是否正确
|
||||||
|
- AI 返回内容是否适合直接落文档
|
||||||
|
- 超时和失败时前端是否能优雅重试
|
||||||
|
|
||||||
|
## 20.3 联调建议顺序
|
||||||
|
|
||||||
|
1. 先 mock 返回固定文本
|
||||||
|
2. 再接真实模型
|
||||||
|
3. 先验证单段落
|
||||||
|
4. 再验证多段落
|
||||||
|
5. 最后验证大段文本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. 优先级建议
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
- 统一 rewrite API
|
||||||
|
- 4 类动作支持
|
||||||
|
- 输出纯净正文
|
||||||
|
- 错误结构标准化
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
- 输出清洗增强
|
||||||
|
- 埋点与日志
|
||||||
|
- 上下文截断策略优化
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
- 更细粒度风格控制
|
||||||
|
- 结果质量二次校验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. 完成定义
|
||||||
|
|
||||||
|
后端满足以下条件即可视为本轮完成:
|
||||||
|
|
||||||
|
- 前端能通过统一接口发起改写
|
||||||
|
- 4 类动作均可稳定返回候选文本
|
||||||
|
- 返回文本基本可直接用于替换或插入
|
||||||
|
- 多段落文本能稳定处理
|
||||||
|
- 错误结构规范统一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. 结论
|
||||||
|
|
||||||
|
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
|
||||||
|
|
||||||
|
最稳妥的路线是:
|
||||||
|
|
||||||
|
- 统一接口
|
||||||
|
- 统一动作枚举
|
||||||
|
- 统一 prompt 模板
|
||||||
|
- 统一错误结构
|
||||||
|
- 统一输出约束
|
||||||
|
|
||||||
|
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
# AI 写作助手前端开发任务清单
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
|
||||||
|
|
||||||
|
目标是让前端团队按模块并行推进,最终完成:
|
||||||
|
|
||||||
|
- 保留左侧聊天区
|
||||||
|
- 右侧升级为 `Tiptap` 写作编辑器
|
||||||
|
- 支持选区浮层
|
||||||
|
- 支持润色 / 扩写 / 缩写 / 改语气
|
||||||
|
- 支持替换 / 插入
|
||||||
|
- 支持标题 / 列表 / 引用 / 代码块工具条
|
||||||
|
- UI 参考豆包 + Novel + open-canvas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 本轮前端改造范围
|
||||||
|
|
||||||
|
### 2.1 保留不动
|
||||||
|
|
||||||
|
- 左侧聊天主链路
|
||||||
|
- 现有线程消息流
|
||||||
|
- 现有模型选择器
|
||||||
|
- 现有输入框的 `writing_mode` 总入口
|
||||||
|
|
||||||
|
### 2.2 重点改造
|
||||||
|
|
||||||
|
- 右侧 Artifact 面板承载方式
|
||||||
|
- 新增独立写作工作区
|
||||||
|
- 富文本编辑器内核
|
||||||
|
- 选区浮层
|
||||||
|
- AI 改写结果预览
|
||||||
|
- 文档级工具条
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 推荐目录结构
|
||||||
|
|
||||||
|
建议新增独立模块目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend-web/src/open-canvas-writing/
|
||||||
|
routes/
|
||||||
|
WritingRoutes.tsx
|
||||||
|
pages/
|
||||||
|
WritingWorkspacePage.tsx
|
||||||
|
components/
|
||||||
|
WritingShell.tsx
|
||||||
|
WritingHeader.tsx
|
||||||
|
WritingToolbar.tsx
|
||||||
|
WritingEditorPane.tsx
|
||||||
|
WritingBubbleMenu.tsx
|
||||||
|
WritingAiMenu.tsx
|
||||||
|
WritingRewritePreview.tsx
|
||||||
|
WritingStatusBar.tsx
|
||||||
|
WritingEmptyState.tsx
|
||||||
|
editor/
|
||||||
|
TiptapEditor.tsx
|
||||||
|
TiptapExtensions.ts
|
||||||
|
commands.ts
|
||||||
|
selection.ts
|
||||||
|
context/
|
||||||
|
WritingEditorContext.tsx
|
||||||
|
hooks/
|
||||||
|
useWritingEditor.ts
|
||||||
|
useWritingSelection.ts
|
||||||
|
useWritingRewrite.ts
|
||||||
|
useWritingToolbar.ts
|
||||||
|
api/
|
||||||
|
rewrite.ts
|
||||||
|
documents.ts
|
||||||
|
types/
|
||||||
|
document.ts
|
||||||
|
rewrite.ts
|
||||||
|
utils/
|
||||||
|
apply-rewrite.ts
|
||||||
|
markdown.ts
|
||||||
|
editor-style.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 现有代码落点
|
||||||
|
|
||||||
|
## 4.1 需要接入的现有文件
|
||||||
|
|
||||||
|
- `frontend-web/src/pages/PageRoutes.tsx`
|
||||||
|
- 增加新写作路由入口
|
||||||
|
|
||||||
|
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||||
|
- 当前双栏布局参考来源
|
||||||
|
- 后续决定是否复用布局容器,或在新页面中重建双栏
|
||||||
|
|
||||||
|
- `frontend-web/src/components/workspace/input-box.tsx`
|
||||||
|
- 保留 `writing_mode`
|
||||||
|
- 后续可在切换写作模式时跳转到新写作页面
|
||||||
|
|
||||||
|
- `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||||
|
- 仅保留给旧 Artifact 逻辑
|
||||||
|
- 不要继续承载写作编辑器状态
|
||||||
|
|
||||||
|
## 4.2 不建议继续沿用的方式
|
||||||
|
|
||||||
|
- 不要把编辑器主状态存成 `string[] artifacts`
|
||||||
|
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
|
||||||
|
- 不要让 AI 改写结果先回聊天,再由用户手动复制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 任务分阶段拆解
|
||||||
|
|
||||||
|
## 阶段 A:路由与页面壳
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 新增 `WritingRoutes.tsx`
|
||||||
|
- 新增 `WritingWorkspacePage.tsx`
|
||||||
|
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
|
||||||
|
- 确认新页面进入方式
|
||||||
|
- 确定是:
|
||||||
|
- 通过左侧聊天上下文进入
|
||||||
|
- 通过 `writing_mode` 入口进入
|
||||||
|
- 或二者都支持
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 新写作页面能被路由访问
|
||||||
|
- 页面有基础左右布局和空态
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 打开 `page/writing` 能进入工作区
|
||||||
|
- 页面不报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 B:编辑器底座
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把右侧正式替换成富文本编辑器。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 安装 Tiptap 依赖
|
||||||
|
- 建立 `TiptapEditor.tsx`
|
||||||
|
- 建立 `TiptapExtensions.ts`
|
||||||
|
- 配置节点:
|
||||||
|
- paragraph
|
||||||
|
- heading
|
||||||
|
- bulletList
|
||||||
|
- orderedList
|
||||||
|
- listItem
|
||||||
|
- blockquote
|
||||||
|
- codeBlock
|
||||||
|
- hardBreak
|
||||||
|
- 配置 marks:
|
||||||
|
- bold
|
||||||
|
- italic
|
||||||
|
- underline
|
||||||
|
- strike
|
||||||
|
- code
|
||||||
|
- 建立编辑器初始化逻辑
|
||||||
|
- 建立编辑器销毁逻辑
|
||||||
|
- 定义最小文档状态
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 可编辑正文区域
|
||||||
|
- 初始文档可加载
|
||||||
|
- 内容可读可写
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 可以直接输入正文
|
||||||
|
- 可以换行、加标题、切换列表
|
||||||
|
- 不出现内容错位和明显闪烁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 C:主工具栏
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
右侧具备正式写作工具栏。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 新增 `WritingToolbar.tsx`
|
||||||
|
- 新增 `useWritingToolbar.ts`
|
||||||
|
- 实现按钮:
|
||||||
|
- 正文
|
||||||
|
- H1
|
||||||
|
- H2
|
||||||
|
- H3
|
||||||
|
- 有序列表
|
||||||
|
- 无序列表
|
||||||
|
- 引用
|
||||||
|
- 代码块
|
||||||
|
- 加粗
|
||||||
|
- 斜体
|
||||||
|
- 下划线
|
||||||
|
- 删除线
|
||||||
|
- 处理 active 状态
|
||||||
|
- 处理不可用状态
|
||||||
|
- 处理光标块状态联动
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 主工具栏可用
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 工具条按钮能正确反映当前块状态
|
||||||
|
- 操作后编辑器内容正确变化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 D:选区状态与浮层
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
实现豆包式选区浮层基础能力。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 新增 `useWritingSelection.ts`
|
||||||
|
- 新增 `selection.ts`
|
||||||
|
- 新增 `WritingBubbleMenu.tsx`
|
||||||
|
- 基于 Tiptap 监听 selection 变化
|
||||||
|
- 过滤掉空选区
|
||||||
|
- 处理多段连续选区
|
||||||
|
- 浮层按钮:
|
||||||
|
- AI 改写
|
||||||
|
- 加粗
|
||||||
|
- 斜体
|
||||||
|
- 标题
|
||||||
|
- 复制
|
||||||
|
- 处理滚动定位
|
||||||
|
- 处理窗口尺寸变化
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 选中内容后出现浮层
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 单段可触发
|
||||||
|
- 多段可触发
|
||||||
|
- 浮层位置稳定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 E:AI 改写菜单
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把选区浮层接到 AI 改写工作流。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 新增 `WritingAiMenu.tsx`
|
||||||
|
- 新增 `useWritingRewrite.ts`
|
||||||
|
- 新增 `api/rewrite.ts`
|
||||||
|
- 实现一级动作:
|
||||||
|
- 润色
|
||||||
|
- 扩写
|
||||||
|
- 缩写
|
||||||
|
- 改语气
|
||||||
|
- 实现改语气二级菜单:
|
||||||
|
- 更专业
|
||||||
|
- 更口语化
|
||||||
|
- 更易读
|
||||||
|
- 更学术
|
||||||
|
- 更有文采
|
||||||
|
- 封装请求入参
|
||||||
|
- 增加 loading 态
|
||||||
|
- 增加 error 态
|
||||||
|
- 增加 retry 入口
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- AI 改写菜单和请求链路
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 4 个动作都能发请求
|
||||||
|
- 改语气的二级菜单可正常展开
|
||||||
|
- 请求失败时有反馈
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 F:候选结果预览
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 新增 `WritingRewritePreview.tsx`
|
||||||
|
- 新增 `apply-rewrite.ts`
|
||||||
|
- 显示原文与候选内容
|
||||||
|
- 提供按钮:
|
||||||
|
- 替换
|
||||||
|
- 插入
|
||||||
|
- 重试
|
||||||
|
- 取消
|
||||||
|
- 用 transaction 应用结果
|
||||||
|
- 处理应用成功后的焦点与滚动
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 改写结果预览卡片或面板
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 替换准确
|
||||||
|
- 插入准确
|
||||||
|
- 取消后文档保持原样
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 G:文档状态与容器体验
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
补齐正式产品的壳层体验。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 新增 `WritingShell.tsx`
|
||||||
|
- 新增 `WritingHeader.tsx`
|
||||||
|
- 新增 `WritingStatusBar.tsx`
|
||||||
|
- 新增 `WritingEmptyState.tsx`
|
||||||
|
- 补充文档标题区域
|
||||||
|
- 补充文档状态提示
|
||||||
|
- 补充空态和加载态
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 页面完整壳层
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 页面不是裸编辑器
|
||||||
|
- 有完整的文档工作区感
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 H:视觉打磨
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让整体观感接近成熟产品。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- 调整右侧阅读宽度
|
||||||
|
- 调整字体大小和行高
|
||||||
|
- 调整段距和标题层级
|
||||||
|
- 调整工具栏阴影、边框、圆角
|
||||||
|
- 调整浮层动效
|
||||||
|
- 调整 AI 预览卡片质感
|
||||||
|
- 优化深浅色模式兼容
|
||||||
|
|
||||||
|
### 交付物
|
||||||
|
|
||||||
|
- 完整视觉优化
|
||||||
|
|
||||||
|
### 验收
|
||||||
|
|
||||||
|
- 明显优于当前版本
|
||||||
|
- 有豆包 / Novel 风格的编辑器质感
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 组件级开发清单
|
||||||
|
|
||||||
|
## 6.1 `WritingWorkspacePage.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 页面级容器
|
||||||
|
- 获取线程 / 文档上下文
|
||||||
|
- 组织左右布局
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- 页面初始化
|
||||||
|
- 与左侧聊天协同
|
||||||
|
- 右侧编辑区挂载
|
||||||
|
|
||||||
|
## 6.2 `WritingShell.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 写作工作区总壳层
|
||||||
|
- 布局标题区、工具栏、正文区、状态区
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- 自适应布局
|
||||||
|
- 桌面端优先
|
||||||
|
- 窄屏降级
|
||||||
|
|
||||||
|
## 6.3 `TiptapEditor.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 包装 Tiptap EditorContent
|
||||||
|
- 负责 editor 实例初始化
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- `onUpdate`
|
||||||
|
- `onSelectionUpdate`
|
||||||
|
- 内容同步
|
||||||
|
|
||||||
|
## 6.4 `WritingToolbar.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 顶部主工具栏
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- 所有基础格式化命令
|
||||||
|
- 当前状态高亮
|
||||||
|
|
||||||
|
## 6.5 `WritingBubbleMenu.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 选区浮层
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- 跟随选区
|
||||||
|
- 基础快捷操作
|
||||||
|
- AI 改写入口
|
||||||
|
|
||||||
|
## 6.6 `WritingAiMenu.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- AI 改写动作菜单
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- 一级菜单
|
||||||
|
- 改语气二级菜单
|
||||||
|
- 动作触发
|
||||||
|
|
||||||
|
## 6.7 `WritingRewritePreview.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 展示候选内容
|
||||||
|
|
||||||
|
必须完成:
|
||||||
|
|
||||||
|
- 替换
|
||||||
|
- 插入
|
||||||
|
- 重试
|
||||||
|
- 取消
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Hook 级开发清单
|
||||||
|
|
||||||
|
## 7.1 `useWritingEditor.ts`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- editor 生命周期
|
||||||
|
- 初始内容装载
|
||||||
|
- 内容变更同步
|
||||||
|
|
||||||
|
## 7.2 `useWritingSelection.ts`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 当前选区抽取
|
||||||
|
- 选区文本缓存
|
||||||
|
- 多段落纯文本拼接
|
||||||
|
|
||||||
|
## 7.3 `useWritingRewrite.ts`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 改写请求
|
||||||
|
- loading / success / error
|
||||||
|
- 结果缓存
|
||||||
|
- 重试
|
||||||
|
|
||||||
|
## 7.4 `useWritingToolbar.ts`
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- toolbar active 状态
|
||||||
|
- editor command 映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API 对接清单
|
||||||
|
|
||||||
|
前端需向后端约定以下接口能力:
|
||||||
|
|
||||||
|
- `POST /api/writing/rewrite`
|
||||||
|
|
||||||
|
前端请求内容至少包括:
|
||||||
|
|
||||||
|
- `threadId`
|
||||||
|
- `documentId`
|
||||||
|
- `selection.from`
|
||||||
|
- `selection.to`
|
||||||
|
- `selection.text`
|
||||||
|
- `action`
|
||||||
|
- `tone`
|
||||||
|
- `surroundingContext.before`
|
||||||
|
- `surroundingContext.after`
|
||||||
|
|
||||||
|
前端需处理的返回字段:
|
||||||
|
|
||||||
|
- `output`
|
||||||
|
- `originalSelection`
|
||||||
|
- `metadata`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 状态管理建议
|
||||||
|
|
||||||
|
建议新增独立 Context:
|
||||||
|
|
||||||
|
- `WritingEditorContext.tsx`
|
||||||
|
|
||||||
|
建议持有:
|
||||||
|
|
||||||
|
- 当前文档内容
|
||||||
|
- 当前 editor 实例
|
||||||
|
- 当前选区
|
||||||
|
- 当前改写请求状态
|
||||||
|
- 当前候选结果
|
||||||
|
- 当前 UI 面板状态
|
||||||
|
|
||||||
|
不建议继续复用旧 Artifact Context 的原因:
|
||||||
|
|
||||||
|
- 旧 Context 关注文件列表和文件路径
|
||||||
|
- 新编辑器关注选区、结构、候选结果、应用动作
|
||||||
|
- 关注点完全不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 前端联调检查点
|
||||||
|
|
||||||
|
## 10.1 与后端联调前
|
||||||
|
|
||||||
|
- 先用 mock 数据打通浮层和预览
|
||||||
|
- 先验证替换 / 插入逻辑
|
||||||
|
|
||||||
|
## 10.2 与后端联调时
|
||||||
|
|
||||||
|
- 检查多段落选区拼接结果
|
||||||
|
- 检查 AI 返回内容是否带说明性前缀
|
||||||
|
- 检查替换后位置是否准确
|
||||||
|
|
||||||
|
## 10.3 联调后
|
||||||
|
|
||||||
|
- 验证大段文本性能
|
||||||
|
- 验证撤销 / 重做
|
||||||
|
- 验证快速连续触发是否会状态错乱
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 前端测试清单
|
||||||
|
|
||||||
|
## 11.1 功能测试
|
||||||
|
|
||||||
|
- 输入正文
|
||||||
|
- 插入标题
|
||||||
|
- 切换列表
|
||||||
|
- 插入引用
|
||||||
|
- 插入代码块
|
||||||
|
- 加粗 / 斜体 / 下划线 / 删除线
|
||||||
|
- 选区浮层显示
|
||||||
|
- 选区浮层隐藏
|
||||||
|
- AI 改写菜单打开
|
||||||
|
- 改语气二级菜单打开
|
||||||
|
- 改写结果替换
|
||||||
|
- 改写结果插入
|
||||||
|
- 改写结果取消
|
||||||
|
|
||||||
|
## 11.2 场景测试
|
||||||
|
|
||||||
|
- 单段短文本润色
|
||||||
|
- 多段文本润色
|
||||||
|
- 多段文本扩写
|
||||||
|
- 多段文本缩写
|
||||||
|
- 多段文本改语气
|
||||||
|
- 连续两次改写同一段
|
||||||
|
- 快速切换线程后重新进入
|
||||||
|
|
||||||
|
## 11.3 回归测试
|
||||||
|
|
||||||
|
- 左侧聊天是否正常
|
||||||
|
- 模型切换是否正常
|
||||||
|
- `writing_mode` 是否正常
|
||||||
|
- 原有 artifact 模块是否仍可使用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 任务优先级
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
- 路由与页面壳
|
||||||
|
- Tiptap 接入
|
||||||
|
- 主工具栏
|
||||||
|
- 选区浮层
|
||||||
|
- 改写菜单
|
||||||
|
- 替换 / 插入
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
- 页面头部与状态
|
||||||
|
- 优雅 loading / error / retry
|
||||||
|
- 多段落稳定性
|
||||||
|
- UI 打磨
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
- 空态优化
|
||||||
|
- 动效优化
|
||||||
|
- 快捷键支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 建议分工
|
||||||
|
|
||||||
|
### 前端 1 号
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 路由与页面壳
|
||||||
|
- Tiptap 接入
|
||||||
|
- 主工具栏
|
||||||
|
|
||||||
|
### 前端 2 号
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 选区状态
|
||||||
|
- BubbleMenu
|
||||||
|
- AI 菜单
|
||||||
|
|
||||||
|
### 前端 3 号
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 候选结果预览
|
||||||
|
- 替换 / 插入
|
||||||
|
- 状态栏与页面体验
|
||||||
|
|
||||||
|
### 联调负责人
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 前后端请求协议
|
||||||
|
- 错误态规范
|
||||||
|
- 多段落测试和回归
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 前端完成定义
|
||||||
|
|
||||||
|
满足以下条件可视为前端完成:
|
||||||
|
|
||||||
|
- 右侧能作为正式编辑器独立使用
|
||||||
|
- 选区后可直接发起 AI 改写
|
||||||
|
- 改写后可替换或插入
|
||||||
|
- 视觉上明显优于当前版本
|
||||||
|
- 左侧聊天和现有系统不被破坏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 下一步建议
|
||||||
|
|
||||||
|
前端团队可以按以下顺序开工:
|
||||||
|
|
||||||
|
1. 先起页面和路由
|
||||||
|
2. 再接 Tiptap 与主工具栏
|
||||||
|
3. 再做选区浮层
|
||||||
|
4. 再接 AI 菜单和结果预览
|
||||||
|
5. 最后做视觉打磨
|
||||||
|
|
||||||
|
这个顺序风险最小,也最容易在中途验收。
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
# AI 写作助手源码与实现映射表
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文档用于把以下几类信息对应起来:
|
||||||
|
|
||||||
|
- 豆包 AI 写作助手里要借鉴的交互
|
||||||
|
- Novel / open-canvas / Tiptap 中可参考的实现思路
|
||||||
|
- DeerFlow 当前项目里已经存在的落点
|
||||||
|
- 本次新建模块应该落到哪里
|
||||||
|
|
||||||
|
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 使用方式
|
||||||
|
|
||||||
|
开发时建议按以下顺序看:
|
||||||
|
|
||||||
|
1. 先看“目标能力”
|
||||||
|
2. 再看“参考来源”
|
||||||
|
3. 再看“DeerFlow 当前落点”
|
||||||
|
4. 最后看“本次目标文件”
|
||||||
|
|
||||||
|
这样可以避免出现两种常见问题:
|
||||||
|
|
||||||
|
- 直接照搬外部项目结构,结果和你们现有架构冲突
|
||||||
|
- 只看自己项目,不知道成熟产品交互应该怎么落
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 总体映射
|
||||||
|
|
||||||
|
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
|
||||||
|
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
|
||||||
|
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
|
||||||
|
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
|
||||||
|
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
|
||||||
|
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
|
||||||
|
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
|
||||||
|
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
|
||||||
|
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
|
||||||
|
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 豆包功能到 DeerFlow 的映射
|
||||||
|
|
||||||
|
## 4.1 滑词 / 选区浮层
|
||||||
|
|
||||||
|
### 豆包里的体验
|
||||||
|
|
||||||
|
- 选中文本后出现轻量浮层
|
||||||
|
- 浮层里有:
|
||||||
|
- AI 改写
|
||||||
|
- 文本样式
|
||||||
|
- 复制
|
||||||
|
|
||||||
|
### DeerFlow 当前状态
|
||||||
|
|
||||||
|
- 右侧还没有正式的选区级工具层
|
||||||
|
|
||||||
|
### 本次实现方式
|
||||||
|
|
||||||
|
- 使用 Tiptap 的 `BubbleMenu`
|
||||||
|
- 再叠加自定义菜单逻辑
|
||||||
|
|
||||||
|
### 本次落点
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||||
|
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 多段落改写
|
||||||
|
|
||||||
|
### 豆包里的体验
|
||||||
|
|
||||||
|
- 不是只改单句
|
||||||
|
- 能对连续几段一起润色、扩写、缩写、改语气
|
||||||
|
|
||||||
|
### DeerFlow 当前状态
|
||||||
|
|
||||||
|
- 现有右侧编辑区没有正式的多段落选区改写模型
|
||||||
|
|
||||||
|
### 本次实现方式
|
||||||
|
|
||||||
|
- 前端提取连续选区纯文本
|
||||||
|
- 前端附带前后文
|
||||||
|
- 后端返回候选纯文本
|
||||||
|
- 前端决定替换或插入
|
||||||
|
|
||||||
|
### 本次落点
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3 替换 / 插入
|
||||||
|
|
||||||
|
### 豆包里的体验
|
||||||
|
|
||||||
|
- 改写结果不是直接覆盖原文
|
||||||
|
- 用户可选择:
|
||||||
|
- 替换
|
||||||
|
- 插入
|
||||||
|
|
||||||
|
### DeerFlow 当前状态
|
||||||
|
|
||||||
|
- 当前没有候选文本应用层
|
||||||
|
|
||||||
|
### 本次实现方式
|
||||||
|
|
||||||
|
- 建立候选结果预览区
|
||||||
|
- 基于 Tiptap transaction 应用文本
|
||||||
|
|
||||||
|
### 本次落点
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||||
|
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.4 标题 / 列表 / 引用 / 代码块工具条
|
||||||
|
|
||||||
|
### 豆包里的体验
|
||||||
|
|
||||||
|
- 右侧本身就是正式文档编辑器
|
||||||
|
- 可以快速切换文档结构
|
||||||
|
|
||||||
|
### DeerFlow 当前状态
|
||||||
|
|
||||||
|
- 当前右侧偏预览,不是正式写作编辑器
|
||||||
|
|
||||||
|
### 本次实现方式
|
||||||
|
|
||||||
|
- 使用 Tiptap 节点和命令
|
||||||
|
- 在主工具栏中集中承载
|
||||||
|
|
||||||
|
### 本次落点
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
|
||||||
|
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Novel 参考映射
|
||||||
|
|
||||||
|
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
|
||||||
|
|
||||||
|
- 编辑器主容器的产品感
|
||||||
|
- 工具栏和空态的组织方式
|
||||||
|
- 正文阅读宽度、排版、留白
|
||||||
|
|
||||||
|
## 5.2 对应到 DeerFlow 的实现位置
|
||||||
|
|
||||||
|
- 页面壳:`WritingShell.tsx`
|
||||||
|
- 顶部文档区:`WritingHeader.tsx`
|
||||||
|
- 编辑容器:`TiptapEditor.tsx`
|
||||||
|
- 工具栏:`WritingToolbar.tsx`
|
||||||
|
- 空态:`WritingEmptyState.tsx`
|
||||||
|
|
||||||
|
## 5.3 不建议直接照搬的部分
|
||||||
|
|
||||||
|
- Next.js 页面结构
|
||||||
|
- 依赖其服务端 AI 调用方式
|
||||||
|
- 与 Vercel AI SDK 深绑定的部分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. open-canvas 参考映射
|
||||||
|
|
||||||
|
## 6.1 主要借鉴对象
|
||||||
|
|
||||||
|
open-canvas 这次最值得借鉴的不是页面外观,而是:
|
||||||
|
|
||||||
|
- AI 工作流组织方式
|
||||||
|
- 改写结果应用节奏
|
||||||
|
- 编辑器和聊天协作的模式
|
||||||
|
|
||||||
|
## 6.2 借鉴重点
|
||||||
|
|
||||||
|
### 借鉴 1:独立工作区思想
|
||||||
|
|
||||||
|
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
|
||||||
|
|
||||||
|
对应 DeerFlow:
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
|
||||||
|
|
||||||
|
### 借鉴 2:画布状态独立维护
|
||||||
|
|
||||||
|
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
|
||||||
|
|
||||||
|
对应 DeerFlow:
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
|
||||||
|
|
||||||
|
### 借鉴 3:AI 输出先预览,再应用
|
||||||
|
|
||||||
|
对应 DeerFlow:
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tiptap 参考映射
|
||||||
|
|
||||||
|
## 7.1 Tiptap 在本项目中的职责
|
||||||
|
|
||||||
|
- 作为右侧正式富文本编辑器内核
|
||||||
|
- 提供结构节点与 marks
|
||||||
|
- 提供 BubbleMenu 能力
|
||||||
|
- 提供 transaction 应用能力
|
||||||
|
|
||||||
|
## 7.2 建议直接参考的实现点
|
||||||
|
|
||||||
|
- `EditorContent` 的封装模式
|
||||||
|
- `BubbleMenu` 的接入方式
|
||||||
|
- heading / list / blockquote / codeBlock 扩展配置
|
||||||
|
- `editor.chain().focus()` 命令调用模式
|
||||||
|
- selection 变化监听
|
||||||
|
|
||||||
|
## 7.3 DeerFlow 本次落点
|
||||||
|
|
||||||
|
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
|
||||||
|
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||||
|
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. DeerFlow 现有文件到新模块的映射
|
||||||
|
|
||||||
|
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
|
||||||
|
|
||||||
|
### 当前职责
|
||||||
|
|
||||||
|
- 汇总页面路由
|
||||||
|
|
||||||
|
### 本次作用
|
||||||
|
|
||||||
|
- 挂入新的写作助手路由
|
||||||
|
|
||||||
|
### 后续动作
|
||||||
|
|
||||||
|
- 引入 `WritingRoutes`
|
||||||
|
- 增加 `page/writing/*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||||
|
|
||||||
|
### 当前职责
|
||||||
|
|
||||||
|
- 左聊天 + 右 Artifact 双栏布局
|
||||||
|
|
||||||
|
### 本次作用
|
||||||
|
|
||||||
|
- 提供双栏工作区组织思路
|
||||||
|
- 可复用布局比例和 panel 交互经验
|
||||||
|
|
||||||
|
### 不建议直接复用的部分
|
||||||
|
|
||||||
|
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
|
||||||
|
|
||||||
|
### 本次目标替代
|
||||||
|
|
||||||
|
- `WritingWorkspacePage.tsx`
|
||||||
|
- `WritingShell.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||||
|
|
||||||
|
### 当前职责
|
||||||
|
|
||||||
|
- 管理文件型 artifact 列表、选中项和开关状态
|
||||||
|
|
||||||
|
### 本次作用
|
||||||
|
|
||||||
|
- 仅作为旧 Artifact 模块保留
|
||||||
|
|
||||||
|
### 本次目标替代
|
||||||
|
|
||||||
|
- `WritingEditorContext.tsx`
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
- 写作编辑器需要的不是文件路径状态
|
||||||
|
- 而是文档状态、选区状态、候选结果状态、工具层状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
|
||||||
|
|
||||||
|
### 当前职责
|
||||||
|
|
||||||
|
- 聊天输入区
|
||||||
|
- 已有 `writing_mode` 入口
|
||||||
|
|
||||||
|
### 本次作用
|
||||||
|
|
||||||
|
- 保留为用户进入写作模式的现有入口
|
||||||
|
|
||||||
|
### 本次改造建议
|
||||||
|
|
||||||
|
- 写作模式打开时,跳转或唤起新的写作工作区
|
||||||
|
- 不再只停留在“生成 Markdown 文件”这个层面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 新模块间的依赖关系
|
||||||
|
|
||||||
|
## 9.1 页面层
|
||||||
|
|
||||||
|
- `WritingRoutes.tsx`
|
||||||
|
- `WritingWorkspacePage.tsx`
|
||||||
|
- `WritingShell.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 页面路由
|
||||||
|
- 双栏或工作区布局
|
||||||
|
- 上下文装配
|
||||||
|
|
||||||
|
## 9.2 编辑器层
|
||||||
|
|
||||||
|
- `TiptapEditor.tsx`
|
||||||
|
- `TiptapExtensions.ts`
|
||||||
|
- `commands.ts`
|
||||||
|
- `selection.ts`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 编辑器内核
|
||||||
|
- 节点和 marks
|
||||||
|
- 基础命令
|
||||||
|
- 选区抽取
|
||||||
|
|
||||||
|
## 9.3 交互层
|
||||||
|
|
||||||
|
- `WritingToolbar.tsx`
|
||||||
|
- `WritingBubbleMenu.tsx`
|
||||||
|
- `WritingAiMenu.tsx`
|
||||||
|
- `WritingRewritePreview.tsx`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 工具栏
|
||||||
|
- 浮层
|
||||||
|
- AI 菜单
|
||||||
|
- 候选内容应用
|
||||||
|
|
||||||
|
## 9.4 状态层
|
||||||
|
|
||||||
|
- `WritingEditorContext.tsx`
|
||||||
|
- `useWritingEditor.ts`
|
||||||
|
- `useWritingSelection.ts`
|
||||||
|
- `useWritingRewrite.ts`
|
||||||
|
- `useWritingToolbar.ts`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 主状态
|
||||||
|
- 选区状态
|
||||||
|
- 改写状态
|
||||||
|
- 工具栏状态
|
||||||
|
|
||||||
|
## 9.5 API 层
|
||||||
|
|
||||||
|
- `api/rewrite.ts`
|
||||||
|
- `api/documents.ts`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 对接后端写作改写接口
|
||||||
|
- 对接文档加载与保存接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 能力到文件的精确映射
|
||||||
|
|
||||||
|
| 能力 | 新文件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
|
||||||
|
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
|
||||||
|
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
|
||||||
|
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
|
||||||
|
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
|
||||||
|
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
|
||||||
|
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
|
||||||
|
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
|
||||||
|
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
|
||||||
|
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
|
||||||
|
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
|
||||||
|
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
|
||||||
|
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 开发时的参考顺序
|
||||||
|
|
||||||
|
建议开发顺序如下:
|
||||||
|
|
||||||
|
1. 先看 `PageRoutes.tsx`,确定路由接法
|
||||||
|
2. 再看 `chat-box.tsx`,理解当前左右布局
|
||||||
|
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
|
||||||
|
4. 再接 `TiptapEditor.tsx`
|
||||||
|
5. 再做 `WritingToolbar.tsx`
|
||||||
|
6. 再做 `WritingBubbleMenu.tsx`
|
||||||
|
7. 再做 `WritingAiMenu.tsx`
|
||||||
|
8. 最后做 `WritingRewritePreview.tsx`
|
||||||
|
|
||||||
|
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 明确哪些是“参考”,哪些是“目标”
|
||||||
|
|
||||||
|
### 12.1 参考
|
||||||
|
|
||||||
|
- 豆包:交互体验参考
|
||||||
|
- Novel:页面和编辑器质感参考
|
||||||
|
- open-canvas:AI 工作流参考
|
||||||
|
- Tiptap:编辑器技术实现参考
|
||||||
|
|
||||||
|
### 12.2 目标
|
||||||
|
|
||||||
|
目标不是复刻任何一个外部项目,而是:
|
||||||
|
|
||||||
|
- 在 DeerFlow 现有架构中
|
||||||
|
- 用 React + Vite + 现有线程体系
|
||||||
|
- 做出一个更成熟的 AI 写作编辑器模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 结论
|
||||||
|
|
||||||
|
这份映射表的核心作用,是避免团队在开发时迷路。
|
||||||
|
|
||||||
|
真正推荐的姿势是:
|
||||||
|
|
||||||
|
- 用豆包定义目标体验
|
||||||
|
- 用 Novel 定义页面质感
|
||||||
|
- 用 open-canvas 定义 AI 工作流
|
||||||
|
- 用 Tiptap 作为编辑器内核
|
||||||
|
- 用 DeerFlow 现有路由、线程、模型体系做宿主
|
||||||
|
|
||||||
|
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
|||||||
|
# Open Canvas 集成进度文档
|
||||||
|
|
||||||
|
> 更新时间:2026-05-16
|
||||||
|
> 当前分支:dhk-1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、整体进度概览
|
||||||
|
|
||||||
|
| 阶段 | 名称 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
|
||||||
|
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||||
|
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
|
||||||
|
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
|
||||||
|
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||||
|
| 前端阶段四 | API 适配层 | ✅ 已完成 |
|
||||||
|
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
|
||||||
|
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
|
||||||
|
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
|
||||||
|
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
|
||||||
|
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
|
||||||
|
|
||||||
|
**当前状态**:全部 9 个阶段已完成 🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、已完成工作详情
|
||||||
|
|
||||||
|
### 2.1 前端路由接入
|
||||||
|
|
||||||
|
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||||
|
// 在 PageLayout 内部添加:
|
||||||
|
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
子路由:
|
||||||
|
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
|
||||||
|
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
|
||||||
|
|
||||||
|
### 2.2 前端目录结构(已建立,26 个文件)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend-web/src/open-canvas/
|
||||||
|
api/
|
||||||
|
client.ts # stub,Phase 5 实现
|
||||||
|
models.ts # stub,Phase 5 实现
|
||||||
|
threads.ts # stub,Phase 5 实现
|
||||||
|
runs.ts # stub,Phase 5 实现
|
||||||
|
components/
|
||||||
|
layout/
|
||||||
|
CanvasShell.tsx # 顶层布局壳,flex-col
|
||||||
|
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
|
||||||
|
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
|
||||||
|
chat/
|
||||||
|
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
|
||||||
|
artifact/
|
||||||
|
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
|
||||||
|
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
|
||||||
|
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
|
||||||
|
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
|
||||||
|
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
|
||||||
|
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
|
||||||
|
contexts/
|
||||||
|
CanvasThreadContext.tsx # threadId、modelName
|
||||||
|
CanvasGraphContext.tsx # messages、artifact、isStreaming
|
||||||
|
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
|
||||||
|
hooks/
|
||||||
|
useCanvasStream.ts # stub,Phase 5 实现
|
||||||
|
useCanvasArtifact.ts # stub,Phase 6 实现
|
||||||
|
useCanvasSelection.ts # stub,Phase 7 实现
|
||||||
|
useCanvasQuickActions.ts # stub,Phase 8 实现
|
||||||
|
pages/
|
||||||
|
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
|
||||||
|
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
|
||||||
|
routes/
|
||||||
|
OpenCanvasRoutes.tsx # React Router 子路由
|
||||||
|
types/
|
||||||
|
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||||
|
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
|
||||||
|
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
|
||||||
|
selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||||
|
quick-action.ts # QuickAction、QuickActionId
|
||||||
|
utils/
|
||||||
|
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
|
||||||
|
selection.ts # stub
|
||||||
|
thread.ts # stub
|
||||||
|
stream.ts # stub
|
||||||
|
quick-actions.ts # stub
|
||||||
|
constants/
|
||||||
|
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 核心类型定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// types/artifact.ts
|
||||||
|
export type CanvasArtifact = {
|
||||||
|
currentIndex: number; // 从 1 开始
|
||||||
|
contents: CanvasArtifactContent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasArtifactText = {
|
||||||
|
index: number; type: "text"; title: string; fullMarkdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasArtifactCode = {
|
||||||
|
index: number; type: "code"; title: string;
|
||||||
|
language: ProgrammingLanguageOptions; code: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Context 结构
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// CanvasThreadPage.tsx 的装配顺序
|
||||||
|
<CanvasThreadProvider initialThreadId={thread_id}>
|
||||||
|
<CanvasGraphProvider>
|
||||||
|
<CanvasUIProvider>
|
||||||
|
<CanvasShell />
|
||||||
|
</CanvasUIProvider>
|
||||||
|
</CanvasGraphProvider>
|
||||||
|
</CanvasThreadProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、已解决的技术难题
|
||||||
|
|
||||||
|
### 3.1 react-resizable-panels v4.6.2 API 差异
|
||||||
|
|
||||||
|
此版本与旧版 API 不同,必须使用:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 正确写法
|
||||||
|
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
|
|
||||||
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal" // ← 不是 direction
|
||||||
|
groupRef={layoutRef} // ← 不是 ref
|
||||||
|
>
|
||||||
|
<ResizablePanel id="chat" ... /> // ← 必须有 id
|
||||||
|
<ResizablePanel id="artifact" ... /> // ← 必须有 id
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
// 切换折叠
|
||||||
|
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Flex 高度链(防止底部内容被裁剪)
|
||||||
|
|
||||||
|
```
|
||||||
|
PageLayout(h-screen)
|
||||||
|
└── main(flex-1)
|
||||||
|
└── CanvasShell div(h-full flex flex-col)
|
||||||
|
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
|
||||||
|
└── ResizablePanelGroup(h-full)
|
||||||
|
└── 面板内容(h-full)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
|
||||||
|
|
||||||
|
### 3.3 折叠/展开按钮
|
||||||
|
|
||||||
|
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
|
||||||
|
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
layoutRef.current?.setLayout(
|
||||||
|
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
|
||||||
|
);
|
||||||
|
}, [isChatCollapsed]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、下一步:后端 ThreadState 扩展
|
||||||
|
|
||||||
|
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
|
||||||
|
|
||||||
|
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 新增 TypedDict(建议单独定义)
|
||||||
|
class CanvasArtifactText(TypedDict):
|
||||||
|
index: int
|
||||||
|
type: Literal["text"]
|
||||||
|
title: str
|
||||||
|
fullMarkdown: str
|
||||||
|
|
||||||
|
class CanvasArtifactCode(TypedDict):
|
||||||
|
index: int
|
||||||
|
type: Literal["code"]
|
||||||
|
title: str
|
||||||
|
language: str
|
||||||
|
code: str
|
||||||
|
|
||||||
|
class CanvasArtifact(TypedDict):
|
||||||
|
currentIndex: int
|
||||||
|
contents: list[CanvasArtifactText | CanvasArtifactCode]
|
||||||
|
|
||||||
|
# 在 ThreadState 中新增字段
|
||||||
|
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
|
||||||
|
highlighted_text: Optional[str] # 用户选中的文本片段
|
||||||
|
highlighted_code: Optional[str] # 用户选中的代码片段
|
||||||
|
canvas_mode: Optional[Literal["text", "code"]]
|
||||||
|
quick_action_id: Optional[str] # 快捷动作 id
|
||||||
|
web_search_enabled: bool # 默认 False
|
||||||
|
web_search_results: Optional[list[dict]]
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- 旧线程无 `artifact` 字段时不报错
|
||||||
|
- `pnpm typecheck` 无新增错误(前端字段已对齐)
|
||||||
|
- `make test` 全量回归通过
|
||||||
|
|
||||||
|
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、关键文件位置
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
|
||||||
|
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
|
||||||
|
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
|
||||||
|
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
|
||||||
|
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
|
||||||
|
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
|
||||||
|
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、架构决策记录
|
||||||
|
|
||||||
|
| 决策 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
|
||||||
|
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
|
||||||
|
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
|
||||||
|
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
|
||||||
|
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
|
||||||
|
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、开始新对话的推荐提示词
|
||||||
|
|
||||||
|
```
|
||||||
|
我们在集成 open-canvas 到 DeerFlow 项目。
|
||||||
|
参考进度文档:frontend-web/docs/openCanvas-progress.md
|
||||||
|
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
|
||||||
|
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
|
||||||
|
```
|
||||||
@@ -21,6 +21,13 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
useCuratorConfig,
|
useCuratorConfig,
|
||||||
@@ -31,6 +38,7 @@ import {
|
|||||||
} from "@/core/curator/hooks";
|
} from "@/core/curator/hooks";
|
||||||
import type { CuratorConfig } from "@/core/curator/types";
|
import type { CuratorConfig } from "@/core/curator/types";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function SkillEvolutionPanel() {
|
export function SkillEvolutionPanel() {
|
||||||
@@ -68,6 +76,7 @@ function StatusCard() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const tt = t.settings.skillEvolution.status;
|
const tt = t.settings.skillEvolution.status;
|
||||||
const { status, isLoading } = useCuratorStatus();
|
const { status, isLoading } = useCuratorStatus();
|
||||||
|
const { config } = useCuratorConfig();
|
||||||
const runCurator = useRunCurator();
|
const runCurator = useRunCurator();
|
||||||
|
|
||||||
const lastRunDisplay = useMemo(() => {
|
const lastRunDisplay = useMemo(() => {
|
||||||
@@ -79,10 +88,25 @@ function StatusCard() {
|
|||||||
}
|
}
|
||||||
}, [status?.last_run_at, tt.lastRunNever]);
|
}, [status?.last_run_at, tt.lastRunNever]);
|
||||||
|
|
||||||
|
// 总开关或 curator 子开关任一关闭时,手动触发跑不起来 —— 直接禁用按钮。
|
||||||
|
const runDisabled = !config || !config.enabled || !config.curator.enabled;
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
try {
|
try {
|
||||||
await runCurator.mutateAsync();
|
const res = await runCurator.mutateAsync();
|
||||||
toast.success(tt.runStarted);
|
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) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
@@ -107,7 +131,8 @@ function StatusCard() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRun}
|
onClick={handleRun}
|
||||||
disabled={runCurator.isPending}
|
disabled={runCurator.isPending || runDisabled}
|
||||||
|
title={runDisabled ? tt.runDisabledHint : undefined}
|
||||||
>
|
>
|
||||||
{runCurator.isPending ? (
|
{runCurator.isPending ? (
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
@@ -154,6 +179,73 @@ function StatusCard() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</StatRow>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -178,11 +270,15 @@ function StatRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
|
||||||
|
const DEFAULT_MODEL_VALUE = "__default__";
|
||||||
|
|
||||||
function ConfigCard() {
|
function ConfigCard() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const tt = t.settings.skillEvolution.config;
|
const tt = t.settings.skillEvolution.config;
|
||||||
const { config, isLoading } = useCuratorConfig();
|
const { config, isLoading } = useCuratorConfig();
|
||||||
const updateConfig = useUpdateCuratorConfig();
|
const updateConfig = useUpdateCuratorConfig();
|
||||||
|
const { models } = useModels();
|
||||||
|
|
||||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -311,22 +407,35 @@ function ConfigCard() {
|
|||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow
|
<FieldRow
|
||||||
label={tt.moderationModelLabel}
|
label={tt.curatorModelLabel}
|
||||||
helper={tt.moderationModelHelper}
|
helper={tt.curatorModelHelper}
|
||||||
>
|
>
|
||||||
<Input
|
<Select
|
||||||
type="text"
|
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
|
||||||
className="max-w-[20rem]"
|
onValueChange={(v) =>
|
||||||
value={draft.moderation_model_name ?? ""}
|
|
||||||
placeholder=""
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
moderation_model_name:
|
curator: {
|
||||||
e.target.value.trim() === "" ? null : e.target.value,
|
...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>
|
</FieldRow>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ export async function updateCuratorConfig(
|
|||||||
return response.json() as Promise<CuratorConfig>;
|
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`, {
|
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
return response.json();
|
return response.json() as Promise<RunCuratorResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
||||||
|
|||||||
@@ -3,14 +3,39 @@ export interface CuratorSubConfig {
|
|||||||
interval_hours: number;
|
interval_hours: number;
|
||||||
stale_after_days: number;
|
stale_after_days: number;
|
||||||
archive_after_days: number;
|
archive_after_days: number;
|
||||||
|
review_timeout_seconds: number;
|
||||||
|
/** curator 评审使用的模型名;null = 用主模型。 */
|
||||||
|
model_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CuratorConfig {
|
export interface CuratorConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
moderation_model_name: string | null;
|
|
||||||
curator: CuratorSubConfig;
|
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 {
|
export interface CuratorStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
run_count: number;
|
run_count: number;
|
||||||
@@ -23,6 +48,10 @@ export interface CuratorStatus {
|
|||||||
stale: number;
|
stale: number;
|
||||||
archived: number;
|
archived: number;
|
||||||
};
|
};
|
||||||
|
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
|
||||||
|
last_consolidation_suggestions: CuratorSuggestions;
|
||||||
|
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
|
||||||
|
last_actual_actions: CuratorAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CuratorLogRecord {
|
export interface CuratorLogRecord {
|
||||||
|
|||||||
@@ -567,6 +567,16 @@ export const enUS: Translations = {
|
|||||||
total: "Total",
|
total: "Total",
|
||||||
runNow: "Run now",
|
runNow: "Run now",
|
||||||
runStarted: "Background run started",
|
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: {
|
config: {
|
||||||
title: "Configuration",
|
title: "Configuration",
|
||||||
@@ -587,9 +597,10 @@ export const enUS: Translations = {
|
|||||||
archiveAfterDaysLabel: "Archive threshold (days)",
|
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||||
archiveAfterDaysHelper:
|
archiveAfterDaysHelper:
|
||||||
"Stale skills inactive for this many additional days are archived.",
|
"Stale skills inactive for this many additional days are archived.",
|
||||||
moderationModelLabel: "Moderation model (optional)",
|
curatorModelLabel: "Curator review model",
|
||||||
moderationModelHelper:
|
curatorModelHelper:
|
||||||
"Leave blank to use the default chat model.",
|
"Model for the curator's LLM review and skill security moderation. Defaults to the primary model.",
|
||||||
|
curatorModelDefault: "Primary model (default)",
|
||||||
save: "Save changes",
|
save: "Save changes",
|
||||||
saveSuccess: "Configuration updated",
|
saveSuccess: "Configuration updated",
|
||||||
reset: "Reset",
|
reset: "Reset",
|
||||||
|
|||||||
@@ -490,6 +490,14 @@ export interface Translations {
|
|||||||
total: string;
|
total: string;
|
||||||
runNow: string;
|
runNow: string;
|
||||||
runStarted: string;
|
runStarted: string;
|
||||||
|
runBlockedEvolutionDisabled: string;
|
||||||
|
runBlockedCuratorDisabled: string;
|
||||||
|
runBlockedAlreadyRunning: string;
|
||||||
|
runDisabledHint: string;
|
||||||
|
lastActions: string;
|
||||||
|
llmSuggested: string;
|
||||||
|
actuallyDid: string;
|
||||||
|
noActions: string;
|
||||||
};
|
};
|
||||||
config: {
|
config: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -504,8 +512,9 @@ export interface Translations {
|
|||||||
staleAfterDaysHelper: string;
|
staleAfterDaysHelper: string;
|
||||||
archiveAfterDaysLabel: string;
|
archiveAfterDaysLabel: string;
|
||||||
archiveAfterDaysHelper: string;
|
archiveAfterDaysHelper: string;
|
||||||
moderationModelLabel: string;
|
curatorModelLabel: string;
|
||||||
moderationModelHelper: string;
|
curatorModelHelper: string;
|
||||||
|
curatorModelDefault: string;
|
||||||
save: string;
|
save: string;
|
||||||
saveSuccess: string;
|
saveSuccess: string;
|
||||||
reset: string;
|
reset: string;
|
||||||
|
|||||||
@@ -541,6 +541,14 @@ export const zhCN: Translations = {
|
|||||||
total: "合计",
|
total: "合计",
|
||||||
runNow: "立即执行",
|
runNow: "立即执行",
|
||||||
runStarted: "已触发后台执行",
|
runStarted: "已触发后台执行",
|
||||||
|
runBlockedEvolutionDisabled: "自我进化未开启 —— 请先在下方打开开关。",
|
||||||
|
runBlockedCuratorDisabled: "curator 未开启 —— 请先在下方打开 curator 开关。",
|
||||||
|
runBlockedAlreadyRunning: "已有一次 curator 运行进行中。",
|
||||||
|
runDisabledHint: "请先在下方开启自我进化与 curator 开关。",
|
||||||
|
lastActions: "最近一次运行",
|
||||||
|
llmSuggested: "LLM 建议(自述)",
|
||||||
|
actuallyDid: "实际执行(工具调用)",
|
||||||
|
noActions: "无",
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
title: "配置",
|
title: "配置",
|
||||||
@@ -556,8 +564,9 @@ export const zhCN: Translations = {
|
|||||||
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||||
archiveAfterDaysLabel: "归档阈值(天)",
|
archiveAfterDaysLabel: "归档阈值(天)",
|
||||||
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||||
moderationModelLabel: "安全审核模型(可选)",
|
curatorModelLabel: "技能优化模型",
|
||||||
moderationModelHelper: "为空时使用默认主模型。",
|
curatorModelHelper: "用于 curator 技能评审和技能安全审核的模型,为空则使用主模型。",
|
||||||
|
curatorModelDefault: "主模型(默认)",
|
||||||
save: "保存修改",
|
save: "保存修改",
|
||||||
saveSuccess: "配置已更新",
|
saveSuccess: "配置已更新",
|
||||||
reset: "重置",
|
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 WorkspaceRoutes from "./WorkspaceRoutes";
|
||||||
import StrategyRoutes from "./StrategyRoutes";
|
import StrategyRoutes from "./StrategyRoutes";
|
||||||
import Header from "@/strategy-components/Header";
|
import Header from "@/strategy-components/Header";
|
||||||
|
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||||
|
|
||||||
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
||||||
const SIDEBAR_ENABLED = false;
|
const SIDEBAR_ENABLED = false;
|
||||||
@@ -41,6 +42,7 @@ function PageLayout() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||||
|
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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
|
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
- **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`)
|
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
||||||
|
|
||||||
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
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")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Application lifespan handler."""
|
"""Application lifespan handler."""
|
||||||
@@ -302,12 +390,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Skill ownership migration failed (non-fatal)")
|
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:
|
try:
|
||||||
from deerflow.skills.curator import maybe_run_curator
|
app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
|
||||||
asyncio.create_task(maybe_run_curator())
|
|
||||||
except Exception:
|
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
|
# Start IM channel service if any channels are configured
|
||||||
try:
|
try:
|
||||||
@@ -335,6 +423,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
yield
|
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:
|
try:
|
||||||
scheduler_service = getattr(app.state, "scheduled_task_service", None)
|
scheduler_service = getattr(app.state, "scheduled_task_service", None)
|
||||||
if scheduler_service is not None:
|
if scheduler_service is not None:
|
||||||
|
|||||||
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.model_config import ModelConfig
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["models"])
|
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):
|
class ModelResponse(BaseModel):
|
||||||
"""Response model for model information."""
|
"""Response model for model information."""
|
||||||
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
|
|||||||
description: str | None = Field(None, description="Model description")
|
description: str | None = Field(None, description="Model description")
|
||||||
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
|
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_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):
|
class TokenUsageResponse(BaseModel):
|
||||||
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
models = [
|
models = [_build_model_response(model) for model in config.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
|
|
||||||
]
|
|
||||||
return ModelsListResponse(
|
return ModelsListResponse(
|
||||||
models=models,
|
models=models,
|
||||||
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
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:
|
if model is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||||
|
|
||||||
return ModelResponse(
|
return _build_model_response(model)
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1045,12 +1045,21 @@ async def curator_status(request: Request) -> dict:
|
|||||||
@router.post("/curator/run", summary="Trigger Curator Run")
|
@router.post("/curator/run", summary="Trigger Curator Run")
|
||||||
async def run_curator(request: Request) -> dict:
|
async def run_curator(request: Request) -> dict:
|
||||||
"""Manually trigger a curator review pass for the authenticated user
|
"""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:
|
try:
|
||||||
import threading
|
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)
|
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(
|
thread = threading.Thread(
|
||||||
target=_curator_thread_target,
|
target=_curator_thread_target,
|
||||||
args=(user_id,),
|
args=(user_id,),
|
||||||
@@ -1058,7 +1067,7 @@ async def run_curator(request: Request) -> dict:
|
|||||||
name=f"skill-curator-manual-{user_id}",
|
name=f"skill-curator-manual-{user_id}",
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
return {"triggered": True, "message": "Curator run started in background"}
|
return {"triggered": True, "reason": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to trigger curator: %s", e, exc_info=True)
|
logger.error("Failed to trigger curator: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}")
|
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):
|
class CuratorSubConfig(BaseModel):
|
||||||
"""Nested curator configuration mirroring CuratorConfig."""
|
"""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.")
|
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.")
|
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.")
|
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):
|
class CuratorConfigResponse(BaseModel):
|
||||||
"""Response model exposing the effective skill_evolution config."""
|
"""Response model exposing the effective skill_evolution config."""
|
||||||
|
|
||||||
enabled: bool
|
enabled: bool
|
||||||
moderation_model_name: str | None
|
|
||||||
curator: CuratorSubConfig
|
curator: CuratorSubConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -1089,7 +1100,6 @@ class CuratorConfigRequest(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
|
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
|
curator: CuratorSubConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -1113,12 +1123,13 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
|||||||
"""
|
"""
|
||||||
return CuratorConfigResponse(
|
return CuratorConfigResponse(
|
||||||
enabled=evo.enabled,
|
enabled=evo.enabled,
|
||||||
moderation_model_name=evo.moderation_model_name,
|
|
||||||
curator=CuratorSubConfig(
|
curator=CuratorSubConfig(
|
||||||
enabled=evo.curator.enabled,
|
enabled=evo.curator.enabled,
|
||||||
interval_hours=evo.curator.interval_hours,
|
interval_hours=evo.curator.interval_hours,
|
||||||
stale_after_days=evo.curator.stale_after_days,
|
stale_after_days=evo.curator.stale_after_days,
|
||||||
archive_after_days=evo.curator.archive_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"
|
_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]:
|
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.
|
"""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):
|
def resolve_agent_factory(assistant_id: str | None):
|
||||||
"""Resolve the agent factory callable from config.
|
"""Resolve the agent factory callable from config.
|
||||||
|
|
||||||
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
|
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
|
||||||
injected into ``configurable`` or ``context`` — see
|
to their own factory. Custom agents share the ``lead_agent`` factory with
|
||||||
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
``agent_id`` injected into configurable/context by :func:`build_run_config`.
|
||||||
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
|
||||||
``cfg["agent_id"]``.
|
|
||||||
"""
|
"""
|
||||||
|
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
|
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||||
|
|
||||||
return make_lead_agent
|
return make_lead_agent
|
||||||
@@ -244,8 +251,9 @@ def build_run_config(
|
|||||||
config["configurable"] = {"thread_id": thread_id}
|
config["configurable"] = {"thread_id": thread_id}
|
||||||
|
|
||||||
# Inject custom agent id when the caller specified a non-default assistant.
|
# Inject custom agent id when the caller specified a non-default assistant.
|
||||||
# Honour an explicit agent_id in the active runtime options container.
|
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
|
||||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
# factory and must NOT receive an agent_id override.
|
||||||
|
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||||
normalized = assistant_id.strip()
|
normalized = assistant_id.strip()
|
||||||
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
|
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.")
|
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]:
|
def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||||
"""Collect agent ids from assistant_id/config/context for access checks."""
|
"""Collect agent ids from assistant_id/config/context for access checks."""
|
||||||
agent_ids: set[str] = set()
|
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))
|
agent_ids.add(str(body.assistant_id))
|
||||||
|
|
||||||
def collect(container: Mapping[str, Any] | None) -> None:
|
def collect(container: Mapping[str, Any] | None) -> None:
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
],
|
],
|
||||||
"env": ".env",
|
"env": ".env",
|
||||||
"graphs": {
|
"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": {
|
"auth": {
|
||||||
"path": "./app/gateway/langgraph_auth.py: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)],
|
||||||
|
}
|
||||||
|
)
|
||||||
+66
-49
@@ -69,8 +69,25 @@ _enabled_skills_refresh_version = 0
|
|||||||
_enabled_skills_refresh_event = threading.Event()
|
_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]:
|
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:
|
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:
|
if app_config is None:
|
||||||
return _get_enabled_skills()
|
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:
|
def _skill_mutability_label(category: SkillCategory | str) -> str:
|
||||||
@@ -479,11 +496,11 @@ You: "Deploying to staging..." [proceed]
|
|||||||
|
|
||||||
{skills_section}
|
{skills_section}
|
||||||
|
|
||||||
{deferred_tools_section}
|
{deferred_tools_section}
|
||||||
|
|
||||||
{writing_mode_section}
|
{writing_mode_section}
|
||||||
|
|
||||||
{subagent_section}
|
{subagent_section}
|
||||||
|
|
||||||
<working_directory existed="true">
|
<working_directory existed="true">
|
||||||
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
||||||
@@ -796,17 +813,17 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
|
|||||||
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
||||||
|
|
||||||
|
|
||||||
def apply_prompt_template(
|
def apply_prompt_template(
|
||||||
subagent_enabled: bool = False,
|
subagent_enabled: bool = False,
|
||||||
max_concurrent_subagents: int = 3,
|
max_concurrent_subagents: int = 3,
|
||||||
*,
|
*,
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
agent_name: str | None = None,
|
agent_name: str | None = None,
|
||||||
writing_mode: bool = False,
|
writing_mode: bool = False,
|
||||||
writing_artifact_path: str | None = None,
|
writing_artifact_path: str | None = None,
|
||||||
available_skills: set[str] | None = None,
|
available_skills: set[str] | None = None,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
|
|
||||||
# Get memory context
|
# Get memory context
|
||||||
@@ -838,33 +855,33 @@ def apply_prompt_template(
|
|||||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||||
|
|
||||||
# Get deferred tools section (tool_search)
|
# Get deferred tools section (tool_search)
|
||||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||||
current_writing_artifact = (
|
current_writing_artifact = (
|
||||||
f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n"
|
f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n"
|
||||||
if writing_artifact_path
|
if writing_artifact_path
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
writing_mode_section = (
|
writing_mode_section = (
|
||||||
f"""
|
f"""
|
||||||
<writing_mode>
|
<writing_mode>
|
||||||
Writing mode is enabled for this run.
|
Writing mode is enabled for this run.
|
||||||
|
|
||||||
**Mandatory Output Contract**
|
**Mandatory Output Contract**
|
||||||
- Treat the user's request as a writing/report-generation task.
|
- Treat the user's request as a writing/report-generation task.
|
||||||
- Your final deliverable MUST be a Markdown file, not only an inline chat answer.
|
- Your final deliverable MUST be a Markdown file, not only an inline chat answer.
|
||||||
- If a current Markdown artifact is provided below, you MUST revise that existing file rather than creating a new file, unless the user explicitly asks for a separate/new document.
|
- If a current Markdown artifact is provided below, you MUST revise that existing file rather than creating a new file, unless the user explicitly asks for a separate/new document.
|
||||||
- When revising an existing Markdown artifact, read the file first if needed, then overwrite the same path with the complete updated Markdown content.
|
- When revising an existing Markdown artifact, read the file first if needed, then overwrite the same path with the complete updated Markdown content.
|
||||||
- Write the deliverable to `/mnt/user-data/outputs/` with a clear `.md` filename, for example `/mnt/user-data/outputs/report.md`.
|
- Write the deliverable to `/mnt/user-data/outputs/` with a clear `.md` filename, for example `/mnt/user-data/outputs/report.md`.
|
||||||
- Use `write_file` or an equivalent file-writing tool to save the complete Markdown content.
|
- Use `write_file` or an equivalent file-writing tool to save the complete Markdown content.
|
||||||
- After writing the file, call `present_files` with the Markdown file path so the user can open it as an artifact/canvas.
|
- After writing the file, call `present_files` with the Markdown file path so the user can open it as an artifact/canvas.
|
||||||
- The final chat response should be brief and point to the generated Markdown artifact. Do not duplicate the full report inline unless the user explicitly asks.
|
- The final chat response should be brief and point to the generated Markdown artifact. Do not duplicate the full report inline unless the user explicitly asks.
|
||||||
- If you revise an existing Markdown artifact, update the Markdown file and present it again.
|
- If you revise an existing Markdown artifact, update the Markdown file and present it again.
|
||||||
{current_writing_artifact}
|
{current_writing_artifact}
|
||||||
</writing_mode>
|
</writing_mode>
|
||||||
"""
|
"""
|
||||||
if writing_mode
|
if writing_mode
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build ACP agent section only if ACP agents are configured
|
# Build ACP agent section only if ACP agents are configured
|
||||||
acp_section = _build_acp_section(app_config=app_config)
|
acp_section = _build_acp_section(app_config=app_config)
|
||||||
@@ -878,10 +895,10 @@ Writing mode is enabled for this run.
|
|||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||||
soul=get_agent_soul(storage_agent_id),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
writing_mode_section=writing_mode_section,
|
writing_mode_section=writing_mode_section,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
network_policy_section=network_policy_section,
|
network_policy_section=network_policy_section,
|
||||||
subagent_section=subagent_section,
|
subagent_section=subagent_section,
|
||||||
subagent_reminder=subagent_reminder,
|
subagent_reminder=subagent_reminder,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Annotated, NotRequired, TypedDict
|
from typing import Annotated, Literal, NotRequired, Optional, TypedDict
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
|
|
||||||
@@ -18,6 +18,26 @@ class ViewedImageData(TypedDict):
|
|||||||
mime_type: str
|
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]:
|
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
||||||
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
||||||
if existing is None:
|
if existing is None:
|
||||||
@@ -53,3 +73,11 @@ class ThreadState(AgentState):
|
|||||||
todos: NotRequired[list | None]
|
todos: NotRequired[list | None]
|
||||||
uploaded_files: NotRequired[list[dict] | None]
|
uploaded_files: NotRequired[list[dict] | None]
|
||||||
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
|
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):
|
class CuratorConfig(BaseModel):
|
||||||
"""Configuration for the background skill curator."""
|
"""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).")
|
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.")
|
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.")
|
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):
|
class SkillEvolutionConfig(BaseModel):
|
||||||
@@ -17,10 +24,6 @@ class SkillEvolutionConfig(BaseModel):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Whether the agent can create and modify skills under skills/custom.",
|
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(
|
curator: CuratorConfig = Field(
|
||||||
default_factory=CuratorConfig,
|
default_factory=CuratorConfig,
|
||||||
description="Configuration for the background skill curator.",
|
description="Configuration for the background skill curator.",
|
||||||
|
|||||||
@@ -67,3 +67,13 @@ class SkillStore(ABC):
|
|||||||
async def count_owned(self, user_id: str) -> int:
|
async def count_owned(self, user_id: str) -> int:
|
||||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||||
raise NotImplementedError
|
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:
|
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)
|
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)
|
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
return await session.scalar(stmt) or 0
|
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 asyncio
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import contextvars
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -25,6 +26,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_CURATOR_STATE_FILENAME = ".curator_state"
|
_CURATOR_STATE_FILENAME = ".curator_state"
|
||||||
|
|
||||||
|
# 周期调度一轮里同时执行的 curator 数量上限。到期用户多时(尤其首次部署所有用户一起
|
||||||
|
# 到期)用线程池排队消费,避免一次性起 N 个线程齐调 LLM 打爆 provider 速率限制。
|
||||||
|
_CURATOR_MAX_CONCURRENCY = 5
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_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)
|
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):
|
def _get_curator_config(user_id: str | None = None):
|
||||||
"""Return the curator config object for ``user_id``.
|
"""Return the curator config object for ``user_id``.
|
||||||
|
|
||||||
@@ -135,6 +215,21 @@ def _get_curator_config(user_id: str | None = None):
|
|||||||
return 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:
|
def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
|
||||||
"""Return skill names owned by ``user_id`` (used to filter transitions).
|
"""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
|
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(
|
def _eligible_for_curation(
|
||||||
name: str,
|
name: str,
|
||||||
entry: dict,
|
entry: dict,
|
||||||
*,
|
*,
|
||||||
owned_set: set[str] | None,
|
owned_set: set[str] | None,
|
||||||
referenced_set: set[str],
|
referenced_set: set[str],
|
||||||
|
published_set: set[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Three-condition gate for whether curator may touch a skill.
|
"""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
|
1. source == "agent" (caller is expected to have already filtered, but
|
||||||
we double-check here for safety)
|
we double-check here for safety)
|
||||||
2. NOT referenced by any agent's skills config
|
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
|
``owned_set`` is the set of skill names owned by the running user. When
|
||||||
None, the legacy "process every agent-created skill" fallback applies
|
None, the legacy "process every agent-created skill" fallback applies
|
||||||
for ownership only (the other two conditions still hold).
|
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":
|
if entry.get("source") != "agent":
|
||||||
return False
|
return False
|
||||||
@@ -256,7 +382,7 @@ def _eligible_for_curation(
|
|||||||
return False
|
return False
|
||||||
if name in referenced_set:
|
if name in referenced_set:
|
||||||
return False
|
return False
|
||||||
if _is_skill_published(name):
|
if name in published_set:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -264,21 +390,52 @@ def _eligible_for_curation(
|
|||||||
def should_run_now(user_id: str) -> bool:
|
def should_run_now(user_id: str) -> bool:
|
||||||
"""Return True if the curator interval has elapsed for ``user_id`` and it
|
"""Return True if the curator interval has elapsed for ``user_id`` and it
|
||||||
is not paused."""
|
is not paused."""
|
||||||
curator_cfg = _get_curator_config()
|
# 总开关:技能进化整体关闭时,curator 一律不跑。
|
||||||
if curator_cfg is None or not getattr(curator_cfg, "enabled", True):
|
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
|
return False
|
||||||
state = _read_curator_state(user_id)
|
state = _read_curator_state(user_id)
|
||||||
if state.get("paused"):
|
if state.get("paused"):
|
||||||
return False
|
return False
|
||||||
|
# 运行锁:已有 curator 在跑(且锁未过期)时不再触发新的一轮。
|
||||||
|
if _run_lock_active(state, datetime.now(UTC)):
|
||||||
|
return False
|
||||||
last_run = state.get("last_run_at")
|
last_run = state.get("last_run_at")
|
||||||
if not last_run:
|
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)
|
interval_hours = getattr(curator_cfg, "interval_hours", 168)
|
||||||
try:
|
last_dt = _parse_state_time(last_run)
|
||||||
last_dt = datetime.fromisoformat(last_run)
|
if last_dt is None:
|
||||||
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
|
# 时间串损坏 —— 视同"从未跑过",允许重跑并重写正确时间戳,而不是像旧逻辑那样
|
||||||
except Exception:
|
# except 返回 False 导致该用户永久不再触发。
|
||||||
return True
|
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:
|
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:
|
if now is None:
|
||||||
now = datetime.now(UTC)
|
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
|
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
|
archive_days = getattr(curator_cfg, "archive_after_days", 90) if curator_cfg else 90
|
||||||
|
|
||||||
owned = _list_user_owned_skill_names(user_id)
|
owned = _list_user_owned_skill_names(user_id)
|
||||||
referenced = _collect_referenced_skill_names()
|
referenced = _collect_referenced_skill_names()
|
||||||
|
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
|
||||||
raw_entries = agent_created_entries()
|
raw_entries = agent_created_entries()
|
||||||
|
|
||||||
skipped_referenced = 0
|
skipped_referenced = 0
|
||||||
@@ -314,7 +472,7 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
|
|||||||
if name in referenced:
|
if name in referenced:
|
||||||
skipped_referenced += 1
|
skipped_referenced += 1
|
||||||
continue
|
continue
|
||||||
if _is_skill_published(name):
|
if name in published:
|
||||||
skipped_published += 1
|
skipped_published += 1
|
||||||
continue
|
continue
|
||||||
eligible[name] = entry
|
eligible[name] = entry
|
||||||
@@ -377,9 +535,10 @@ def _render_candidate_list(user_id: str) -> str:
|
|||||||
|
|
||||||
owned = _list_user_owned_skill_names(user_id)
|
owned = _list_user_owned_skill_names(user_id)
|
||||||
referenced = _collect_referenced_skill_names()
|
referenced = _collect_referenced_skill_names()
|
||||||
|
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
|
||||||
entries: dict[str, dict] = {}
|
entries: dict[str, dict] = {}
|
||||||
for k, v in all_entries().items():
|
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
|
continue
|
||||||
if v.get("state") == STATE_ARCHIVED:
|
if v.get("state") == STATE_ARCHIVED:
|
||||||
continue
|
continue
|
||||||
@@ -590,8 +749,72 @@ def _make_skill_archive_tool(user_id: str):
|
|||||||
return _skill_archive
|
return _skill_archive
|
||||||
|
|
||||||
|
|
||||||
def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
def _make_curator_skill_manage_tool():
|
||||||
"""Run the curator consolidation pass as a React agent with skill tools bound."""
|
"""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
|
from deerflow.skills import curator_log
|
||||||
|
|
||||||
try:
|
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.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
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()
|
curator_cfg = _get_curator_config(user_id)
|
||||||
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(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
|
# Reuse the lead-agent error middleware: when the LLM mis-calls a tool
|
||||||
# (wrong action / missing required args), the exception is converted
|
# (wrong action / missing required args), the exception is converted
|
||||||
# into a ToolMessage(status="error") so the LLM can read the error and
|
# 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.
|
# middleware kwarg — use LangChain 1.x's ``create_agent`` which does.
|
||||||
agent = create_agent(model=model, tools=tools, middleware=[ToolErrorHandlingMiddleware()])
|
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}"
|
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", [])
|
messages = result.get("messages", [])
|
||||||
|
tool_actions = _extract_tool_actions(messages)
|
||||||
for msg in reversed(messages):
|
for msg in reversed(messages):
|
||||||
if not getattr(msg, "tool_calls", None):
|
if not getattr(msg, "tool_calls", None):
|
||||||
content = getattr(msg, "content", "")
|
content = getattr(msg, "content", "")
|
||||||
if isinstance(content, str) and content.strip():
|
if isinstance(content, str) and content.strip():
|
||||||
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
||||||
return content
|
return content, tool_actions
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||||
for block in content
|
for block in content
|
||||||
).strip()
|
).strip()
|
||||||
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
|
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)
|
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
|
||||||
return ""
|
return "", tool_actions
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Curator LLM review failed: %s", e)
|
logger.warning("Curator LLM review failed: %s", e)
|
||||||
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
|
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
|
||||||
return ""
|
return "", []
|
||||||
|
|
||||||
|
|
||||||
def _parse_structured_summary(text: str) -> dict:
|
def _parse_structured_summary(text: str) -> dict:
|
||||||
@@ -653,6 +908,44 @@ def _parse_structured_summary(text: str) -> dict:
|
|||||||
return {"consolidations": [], "prunings": []}
|
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:
|
def run_curator_review(user_id: str) -> dict:
|
||||||
"""Main curator run for ``user_id``: transitions → LLM consolidation → state.
|
"""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
|
"info", f"进入 LLM 评审的候选技能:共 {candidate_line_count} 个", user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
llm_text = _run_llm_review(candidate_list, user_id=user_id)
|
if candidate_line_count == 0:
|
||||||
suggestions = _parse_structured_summary(llm_text)
|
# 没有候选技能 —— 跳过 LLM 评审,省掉无意义的模型调用。
|
||||||
curator_log.record(
|
# 年龄流转(上面的 apply_automatic_transitions)已完成,且不依赖 LLM。
|
||||||
"info",
|
curator_log.record("info", "无候选技能,跳过 LLM 评审", user_id=user_id)
|
||||||
(
|
suggestions = {"consolidations": [], "prunings": []}
|
||||||
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
tool_actions: list[dict] = []
|
||||||
f"建议删减 {len(suggestions['prunings'])} 项"
|
else:
|
||||||
),
|
llm_text, tool_actions = _run_llm_review(candidate_list, user_id=user_id)
|
||||||
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
|
# Surface per-item reasoning so operators can audit *why* each action
|
||||||
# was taken, not just the count.
|
# was taken, not just the count.
|
||||||
for item in suggestions.get("consolidations", []) or []:
|
for item in suggestions.get("consolidations", []) or []:
|
||||||
@@ -718,15 +1018,26 @@ def run_curator_review(user_id: str) -> dict:
|
|||||||
user_id=user_id,
|
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 = _read_curator_state(user_id)
|
||||||
state["last_run_at"] = now.isoformat()
|
state["last_run_at"] = now.isoformat()
|
||||||
state["run_count"] = state.get("run_count", 0) + 1
|
state["run_count"] = state.get("run_count", 0) + 1
|
||||||
state["last_run_summary"] = (
|
state["last_run_summary"] = (
|
||||||
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
||||||
f"归档 {transition_counters['archived']} 个;"
|
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)
|
_write_curator_state(state, user_id)
|
||||||
|
|
||||||
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
|
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.runtime.user_context import reset_current_user, set_current_user
|
||||||
from deerflow.skills import curator_log
|
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
|
# Bind the per-thread user context for the entire curator run so every
|
||||||
# downstream tool / repository call resolves the correct user_id.
|
# downstream tool / repository call resolves the correct user_id.
|
||||||
token = set_current_user(_CuratorUser(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)
|
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
|
||||||
finally:
|
finally:
|
||||||
reset_current_user(token)
|
reset_current_user(token)
|
||||||
|
# 无论成功或异常,必定释放运行锁。
|
||||||
|
_release_run_lock(user_id)
|
||||||
|
|
||||||
|
|
||||||
async def maybe_run_curator() -> None:
|
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
|
由 app 的 curator 调度器周期性调用(也用于手动触发)。无候选技能的用户,其
|
||||||
blocks lifespan startup on failure.
|
LLM 评审会在 run_curator_review 内被跳过 —— 只做不耗费 LLM 的年龄流转。
|
||||||
|
任何失败都不会阻断调用方。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -789,20 +1120,42 @@ async def maybe_run_curator() -> None:
|
|||||||
logger.exception("Curator: failed to enumerate users at startup")
|
logger.exception("Curator: failed to enumerate users at startup")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
due_users: list[str] = []
|
||||||
for user_id in user_ids:
|
for user_id in user_ids:
|
||||||
try:
|
try:
|
||||||
if not should_run_now(user_id):
|
if should_run_now(user_id):
|
||||||
continue
|
due_users.append(user_id)
|
||||||
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()
|
|
||||||
except Exception:
|
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:
|
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)
|
s = entry.get("state", STATE_ACTIVE)
|
||||||
counts[s] = counts.get(s, 0) + 1
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
|
||||||
cfg = _get_curator_config()
|
cfg = _get_curator_config(user_id)
|
||||||
return {
|
return {
|
||||||
"enabled": cfg is not None and getattr(cfg, "enabled", True),
|
"enabled": cfg is not None and getattr(cfg, "enabled", True),
|
||||||
"run_count": state.get("run_count", 0),
|
"run_count": state.get("run_count", 0),
|
||||||
@@ -832,4 +1185,8 @@ def get_curator_status(user_id: str) -> dict:
|
|||||||
"stale": counts[STATE_STALE],
|
"stale": counts[STATE_STALE],
|
||||||
"archived": counts[STATE_ARCHIVED],
|
"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:
|
try:
|
||||||
config = app_config or get_app_config()
|
config = app_config or get_app_config()
|
||||||
model_name = config.skill_evolution.moderation_model_name
|
# 安全审核复用 curator 的评审模型配置(skill_evolution.curator.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)
|
# 二者用同一个模型。未配置时 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(
|
response = await model.ainvoke(
|
||||||
[
|
[
|
||||||
{"role": "system", "content": rubric},
|
{"role": "system", "content": rubric},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,6 +14,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_USAGE_FILENAME = ".usage.json"
|
_USAGE_FILENAME = ".usage.json"
|
||||||
|
|
||||||
|
# 串行化对 .usage.json 的"读-改-写",避免并发(尤其 curator 多线程)丢更新。
|
||||||
|
# .usage.json 是全局单文件;此锁覆盖进程内并发。leader 选举已使 curator 只在
|
||||||
|
# 单进程跑,故进程内锁即可保证状态流转不丢。
|
||||||
|
_usage_lock = threading.Lock()
|
||||||
|
|
||||||
STATE_ACTIVE = "active"
|
STATE_ACTIVE = "active"
|
||||||
STATE_STALE = "stale"
|
STATE_STALE = "stale"
|
||||||
STATE_ARCHIVED = "archived"
|
STATE_ARCHIVED = "archived"
|
||||||
@@ -75,13 +81,20 @@ def _now_iso() -> str:
|
|||||||
return datetime.now(UTC).isoformat()
|
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:
|
try:
|
||||||
data = _read_all()
|
with _usage_lock:
|
||||||
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
|
data = _read_all()
|
||||||
entry.update(updates)
|
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
|
||||||
data[name] = entry
|
resolved = updates(entry) if callable(updates) else updates
|
||||||
_write_all(data)
|
entry.update(resolved)
|
||||||
|
data[name] = entry
|
||||||
|
_write_all(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to update usage entry for %r", name, exc_info=True)
|
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:
|
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:
|
def bump_use(name: str) -> None:
|
||||||
entry = get_entry(name)
|
_update_entry(name, lambda e: {"use_count": e.get("use_count", 0) + 1, "last_used_at": _now_iso()})
|
||||||
_update_entry(name, {"use_count": entry.get("use_count", 0) + 1, "last_used_at": _now_iso()})
|
|
||||||
|
|
||||||
|
|
||||||
def bump_patch(name: str) -> None:
|
def bump_patch(name: str) -> None:
|
||||||
entry = get_entry(name)
|
_update_entry(name, lambda e: {"patch_count": e.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
|
||||||
_update_entry(name, {"patch_count": entry.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
|
|
||||||
|
|
||||||
|
|
||||||
def set_state(name: str, state: str) -> None:
|
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:
|
def forget(name: str) -> None:
|
||||||
try:
|
try:
|
||||||
data = _read_all()
|
with _usage_lock:
|
||||||
data.pop(name, None)
|
data = _read_all()
|
||||||
_write_all(data)
|
data.pop(name, None)
|
||||||
|
_write_all(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
|
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
|
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")
|
@tool("skill_view")
|
||||||
def skill_view_tool(name: str) -> str:
|
def skill_view_tool(name: str) -> str:
|
||||||
"""Read the full SKILL.md content of a skill by name.
|
"""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
|
under the ``skills:`` field — the agent only sees what it was wired to
|
||||||
use.
|
use.
|
||||||
|
|
||||||
For the **default lead agent** (no agent_id in runtime), there is no
|
For the **default lead agent** (no agent_id in runtime), the result is
|
||||||
per-agent whitelist and every visible skill is returned.
|
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,
|
Returns a JSON array with fields: name, description, category, enabled,
|
||||||
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
||||||
source.
|
source.
|
||||||
"""
|
"""
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
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)
|
agent_id = _resolve_active_agent_id(runtime)
|
||||||
allowed = _load_agent_skill_whitelist(agent_id)
|
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:
|
try:
|
||||||
storage = get_or_new_skill_storage()
|
storage = get_or_new_skill_storage()
|
||||||
skills = storage.load_skills(enabled_only=False)
|
skills = storage.load_skills(enabled_only=False)
|
||||||
usage = all_entries()
|
usage = all_entries()
|
||||||
result = []
|
result = []
|
||||||
for skill in skills:
|
for skill in skills:
|
||||||
if allowed is not None and skill.name not in allowed:
|
|
||||||
continue
|
|
||||||
entry = usage.get(skill.name, {})
|
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({
|
result.append({
|
||||||
"name": skill.name,
|
"name": skill.name,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
|
|||||||
@@ -84,12 +84,26 @@ def get_available_tools(
|
|||||||
builtin_tools = BUILTIN_TOOLS.copy()
|
builtin_tools = BUILTIN_TOOLS.copy()
|
||||||
if not is_scheduled_run:
|
if not is_scheduled_run:
|
||||||
builtin_tools.append(scheduled_task_tool)
|
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):
|
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
|
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
|
# Add subagent tools only if enabled via runtime parameter
|
||||||
if subagent_enabled:
|
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):
|
def test_save_and_load_overrides_roundtrip(overrides_path):
|
||||||
payload = {
|
payload = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"moderation_model_name": "moderation-mini",
|
|
||||||
"curator": {
|
"curator": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"interval_hours": 12,
|
"interval_hours": 12,
|
||||||
"stale_after_days": 7,
|
"stale_after_days": 7,
|
||||||
"archive_after_days": 30,
|
"archive_after_days": 30,
|
||||||
|
"model_name": "curator-mini",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
written = skill_evolution_runtime.save_overrides(payload)
|
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, "_list_user_owned_skill_names", lambda uid: {"public-tool"})
|
||||||
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
|
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)
|
_isolated_skills_root(isolated_paths, monkeypatch)
|
||||||
_seed_old_agent_skill("public-tool")
|
_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