Merge remote-tracking branch 'origin/master' into ji

This commit is contained in:
吉鑫升 committed 2026-05-18 10:03:49 +08:00
commit 4761677676
74 files changed
+9723 -2840

No files matched your search

@@ -0,0 +1,726 @@
# AI 写作助手后端接口与 Prompt 协议文档
## 1. 文档目标
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
---
## 2. 本轮后端职责
后端负责:
- 接收当前线程信息
- 接收用户选区文本
- 接收改写动作
- 选取必要上下文
- 调用模型生成候选改写结果
- 返回纯净候选文本
后端本轮不负责:
- 直接编辑 Tiptap JSON
- 直接操作前端编辑器选区
- 直接替换前端文档内容
- 做复杂富文本 patch
- 做 tracked changes
---
## 3. 设计原则
### 3.1 纯文本协议优先
首版只让后端处理:
- 选区文本
- 前后文文本
- 动作类型
- 风格参数
不让后端直接理解完整富文本 JSON。
### 3.2 前端负责应用,后端负责生成
- 前端负责选区获取
- 前端负责候选结果展示
- 前端负责替换 / 插入
- 后端只负责“给出候选文本”
### 3.3 结果必须可直接落文档
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
- “当然可以,以下是润色版本:”
- “我帮你改写如下:”
- “下面是优化后的内容:”
这类内容必须禁止。
---
## 4. 推荐接口设计
## 4.1 主接口
推荐新增统一接口:
```text
POST /api/writing/rewrite
```
由 `action` 决定具体重写类型。
## 4.2 为什么推荐统一接口
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
- 前端判断分支增多
- 埋点与异常处理分散
- 后续新增动作不方便扩展
统一接口更适合本轮需求。
---
## 5. 请求体定义
建议请求体如下:
```json
{
"threadId": "string",
"documentId": "string",
"selection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"action": "polish",
"tone": "professional",
"surroundingContext": {
"before": "选区前文",
"after": "选区后文"
}
}
```
---
## 6. 字段说明
### 6.1 `threadId`
用途:
- 关联当前 DeerFlow 线程
- 必要时取线程上下文或会话元数据
### 6.2 `documentId`
用途:
- 标识当前右侧文档
- 首版可选
- 如果当前系统还没有独立文档 ID,可先允许为空
### 6.3 `selection.from` / `selection.to`
用途:
- 帮助前后端联调排查
- 首版后端通常不需要真正使用偏移量
- 建议保留,便于日志定位
### 6.4 `selection.text`
用途:
- 这是本轮最核心输入
- 后端实际重写的主文本
### 6.5 `action`
允许值:
- `polish`
- `expand`
- `shorten`
- `tone`
### 6.6 `tone`
仅当 `action = tone` 时使用。
建议允许值:
- `professional`
- `casual`
- `readable`
- `academic`
- `expressive`
### 6.7 `surroundingContext.before`
用途:
- 提供选区前文
- 帮助模型理解语气、主语、叙事连续性
建议:
- 控制长度
- 不要塞太多无关上下文
### 6.8 `surroundingContext.after`
用途:
- 提供选区后文
- 帮助模型在扩写、改语气时与后续内容衔接
---
## 7. 返回体定义
建议返回体如下:
```json
{
"output": "AI 改写后的候选文本",
"originalSelection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"metadata": {
"action": "polish",
"tone": null,
"model": "your-model-name"
}
}
```
---
## 8. 返回字段要求
## 8.1 `output`
必须:
- 只包含候选正文
- 尽量不夹带解释语
- 能直接被前端替换或插入
## 8.2 `originalSelection`
作用:
- 前端可以做校验
- 联调时方便确认是针对哪段文本返回的
## 8.3 `metadata`
作用:
- 便于埋点
- 便于日志记录
- 便于问题追踪
---
## 9. 错误响应规范
建议统一错误结构:
```json
{
"error": {
"code": "WRITING_REWRITE_FAILED",
"message": "rewrite failed"
}
}
```
建议错误码:
- `WRITING_REWRITE_FAILED`
- `WRITING_INVALID_ACTION`
- `WRITING_INVALID_TONE`
- `WRITING_EMPTY_SELECTION`
- `WRITING_MODEL_UNAVAILABLE`
- `WRITING_CONTEXT_TOO_LARGE`
---
## 10. 动作语义定义
## 10.1 `polish`
目标:
- 保持原意
- 改善表达
- 提升通顺度、自然度和可读性
不应:
- 引入新事实
- 改写成完全不同内容
## 10.2 `expand`
目标:
- 保持主旨
- 扩充细节
- 增强画面感、解释性或完整度
不应:
- 无限制发散
- 添加明显脱离主题的新内容
## 10.3 `shorten`
目标:
- 压缩篇幅
- 保留关键信息
- 减少重复表达
不应:
- 丢失关键论点
- 压缩到语义残缺
## 10.4 `tone`
目标:
- 不改变原意
- 调整语言风格
不应:
- 改写事实
- 改写逻辑结构到完全不同
---
## 11. Prompt 设计原则
## 11.1 通用系统约束
所有改写类 prompt 都应包含以下约束:
- 直接输出最终可用正文
- 不输出标题,除非原文本身就是标题改写
- 不输出解释
- 不输出分析过程
- 不输出“以下是结果”之类前缀
- 保持与上下文语气一致
## 11.2 上下文使用原则
前后文只用于理解,不要求模型重写前后文。
明确告诉模型:
- 只改写选中文本
- 前后文仅用于参考衔接
## 11.3 长度控制原则
- `polish`:长度与原文接近
- `expand`:长度可明显增加,但不要失控
- `shorten`:长度明显缩短,但要保留关键信息
- `tone`:长度尽量与原文接近
---
## 12. Prompt 模板建议
## 12.1 润色模板
```text
你是一名中文写作编辑助手。
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
要求:
1. 保留原文事实和核心意思。
2. 不要添加原文没有的新事实。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出润色后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.2 扩写模板
```text
你是一名中文写作编辑助手。
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
要求:
1. 保持原文主旨不变。
2. 增加内容时要自然,不要跑题。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出扩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.3 缩写模板
```text
你是一名中文写作编辑助手。
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
要求:
1. 不要丢掉核心信息。
2. 去掉重复、冗余和拖沓表达。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出缩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.4 改语气模板
```text
你是一名中文写作编辑助手。
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
要求:
1. 保持原文信息不变。
2. 只调整表达风格,不改变事实和逻辑。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出改写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
---
## 13. Tone 映射建议
建议前端传标准值,后端映射中文标签:
- `professional` -> 专业、正式
- `casual` -> 口语化、轻松自然
- `readable` -> 更易读、更清晰
- `academic` -> 学术、严谨
- `expressive` -> 更有文采、更有表现力
---
## 14. 上下文截取策略
## 14.1 推荐策略
前端传:
- 选区前固定长度文本
- 选区后固定长度文本
例如:
- 前文 200 到 500 字
- 后文 200 到 500 字
### 原因
- 足够帮助模型理解文气和衔接
- 不会让请求太重
## 14.2 不建议
- 把整篇长文全量塞进去
- 把整个线程消息都塞进改写接口
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
---
## 15. LangGraph 接入建议
## 15.1 推荐接入方式
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
建议二选一:
### 方案 A
新增专用 router:
- `POST /api/writing/rewrite`
内部调用现有模型层 / agent 层。
### 方案 B
新增专用 graph node 或 tool:
- `rewrite_selection`
再由 router 调用该能力。
## 15.2 更推荐方案
推荐:
- 对外暴露统一 router
- 内部走已有模型封装或 LangGraph 节点
这样前端接口更稳定,后续后端内部重构不会影响前端。
---
## 16. 后端模块拆解
## 16.1 模块 A:接口层
负责:
- 请求校验
- 错误响应
- 参数标准化
需要完成:
- 统一 rewrite API
- action / tone 校验
- 空选区校验
## 16.2 模块 B:上下文拼装层
负责:
- 拼装 before / selection / after
- 必要时提取线程相关信息
需要完成:
- 上下文长度控制
- 清洗空白和异常字符
## 16.3 模块 C:Prompt 层
负责:
- 选择模板
- 填充 action 和 tone
需要完成:
- 4 类动作模板
- tone 映射
## 16.4 模块 D:模型调用层
负责:
- 走现有模型配置体系
- 执行大模型请求
需要完成:
- 统一调用入口
- 模型不可用错误处理
## 16.5 模块 E:结果清洗层
负责:
- 去掉解释性前缀
- 去掉多余包裹
- 标准化返回
需要完成:
- 输出清洗
- metadata 填充
---
## 17. 返回结果清洗建议
即使 prompt 已经约束,模型仍可能输出:
- “以下是润色后的内容:”
- “当然,这里是改写结果:”
- “优化如下:”
建议后端加一层轻量清洗:
- 去掉常见说明性前缀
- 去掉包裹性 markdown 代码块
- 去掉多余首尾空白
注意:
- 清洗必须保守
- 不要误删正文
---
## 18. 日志与观测建议
建议记录:
- `threadId`
- `documentId`
- `action`
- `tone`
- `selection_length`
- `before_length`
- `after_length`
- `model_name`
- `latency_ms`
- `success / fail`
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
---
## 19. 后端测试清单
## 19.1 单元测试
- action 校验
- tone 校验
- 空选区校验
- prompt 选择逻辑
- 输出清洗逻辑
## 19.2 集成测试
- `polish`
- `expand`
- `shorten`
- `tone-professional`
- `tone-casual`
- `tone-readable`
- `tone-academic`
- `tone-expressive`
## 19.3 边界测试
- 选区过短
- 选区过长
- 前后文为空
- 多段文本带换行
- 模型超时
- 模型不可用
---
## 20. 联调约束
## 20.1 前后端必须统一
- `action` 枚举值
- `tone` 枚举值
- 错误码
- 返回体字段名
## 20.2 联调重点
- 多段落文本中换行保留是否正确
- AI 返回内容是否适合直接落文档
- 超时和失败时前端是否能优雅重试
## 20.3 联调建议顺序
1. 先 mock 返回固定文本
2. 再接真实模型
3. 先验证单段落
4. 再验证多段落
5. 最后验证大段文本
---
## 21. 优先级建议
### P0
- 统一 rewrite API
- 4 类动作支持
- 输出纯净正文
- 错误结构标准化
### P1
- 输出清洗增强
- 埋点与日志
- 上下文截断策略优化
### P2
- 更细粒度风格控制
- 结果质量二次校验
---
## 22. 完成定义
后端满足以下条件即可视为本轮完成:
- 前端能通过统一接口发起改写
- 4 类动作均可稳定返回候选文本
- 返回文本基本可直接用于替换或插入
- 多段落文本能稳定处理
- 错误结构规范统一
---
## 23. 结论
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
最稳妥的路线是:
- 统一接口
- 统一动作枚举
- 统一 prompt 模板
- 统一错误结构
- 统一输出约束
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
@@ -0,0 +1,703 @@
# AI 写作助手前端开发任务清单
## 1. 文档目标
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
目标是让前端团队按模块并行推进,最终完成:
- 保留左侧聊天区
- 右侧升级为 `Tiptap` 写作编辑器
- 支持选区浮层
- 支持润色 / 扩写 / 缩写 / 改语气
- 支持替换 / 插入
- 支持标题 / 列表 / 引用 / 代码块工具条
- UI 参考豆包 + Novel + open-canvas
---
## 2. 本轮前端改造范围
### 2.1 保留不动
- 左侧聊天主链路
- 现有线程消息流
- 现有模型选择器
- 现有输入框的 `writing_mode` 总入口
### 2.2 重点改造
- 右侧 Artifact 面板承载方式
- 新增独立写作工作区
- 富文本编辑器内核
- 选区浮层
- AI 改写结果预览
- 文档级工具条
---
## 3. 推荐目录结构
建议新增独立模块目录:
```text
frontend-web/src/open-canvas-writing/
routes/
WritingRoutes.tsx
pages/
WritingWorkspacePage.tsx
components/
WritingShell.tsx
WritingHeader.tsx
WritingToolbar.tsx
WritingEditorPane.tsx
WritingBubbleMenu.tsx
WritingAiMenu.tsx
WritingRewritePreview.tsx
WritingStatusBar.tsx
WritingEmptyState.tsx
editor/
TiptapEditor.tsx
TiptapExtensions.ts
commands.ts
selection.ts
context/
WritingEditorContext.tsx
hooks/
useWritingEditor.ts
useWritingSelection.ts
useWritingRewrite.ts
useWritingToolbar.ts
api/
rewrite.ts
documents.ts
types/
document.ts
rewrite.ts
utils/
apply-rewrite.ts
markdown.ts
editor-style.ts
```
---
## 4. 现有代码落点
## 4.1 需要接入的现有文件
- `frontend-web/src/pages/PageRoutes.tsx`
- 增加新写作路由入口
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
- 当前双栏布局参考来源
- 后续决定是否复用布局容器,或在新页面中重建双栏
- `frontend-web/src/components/workspace/input-box.tsx`
- 保留 `writing_mode`
- 后续可在切换写作模式时跳转到新写作页面
- `frontend-web/src/components/workspace/artifacts/context.tsx`
- 仅保留给旧 Artifact 逻辑
- 不要继续承载写作编辑器状态
## 4.2 不建议继续沿用的方式
- 不要把编辑器主状态存成 `string[] artifacts`
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
- 不要让 AI 改写结果先回聊天,再由用户手动复制
---
## 5. 任务分阶段拆解
## 阶段 A:路由与页面壳
### 目标
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
### 任务
- 新增 `WritingRoutes.tsx`
- 新增 `WritingWorkspacePage.tsx`
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
- 确认新页面进入方式
- 确定是:
- 通过左侧聊天上下文进入
- 通过 `writing_mode` 入口进入
- 或二者都支持
### 交付物
- 新写作页面能被路由访问
- 页面有基础左右布局和空态
### 验收
- 打开 `page/writing` 能进入工作区
- 页面不报错
---
## 阶段 B:编辑器底座
### 目标
把右侧正式替换成富文本编辑器。
### 任务
- 安装 Tiptap 依赖
- 建立 `TiptapEditor.tsx`
- 建立 `TiptapExtensions.ts`
- 配置节点:
- paragraph
- heading
- bulletList
- orderedList
- listItem
- blockquote
- codeBlock
- hardBreak
- 配置 marks:
- bold
- italic
- underline
- strike
- code
- 建立编辑器初始化逻辑
- 建立编辑器销毁逻辑
- 定义最小文档状态
### 交付物
- 可编辑正文区域
- 初始文档可加载
- 内容可读可写
### 验收
- 可以直接输入正文
- 可以换行、加标题、切换列表
- 不出现内容错位和明显闪烁
---
## 阶段 C:主工具栏
### 目标
右侧具备正式写作工具栏。
### 任务
- 新增 `WritingToolbar.tsx`
- 新增 `useWritingToolbar.ts`
- 实现按钮:
- 正文
- H1
- H2
- H3
- 有序列表
- 无序列表
- 引用
- 代码块
- 加粗
- 斜体
- 下划线
- 删除线
- 处理 active 状态
- 处理不可用状态
- 处理光标块状态联动
### 交付物
- 主工具栏可用
### 验收
- 工具条按钮能正确反映当前块状态
- 操作后编辑器内容正确变化
---
## 阶段 D:选区状态与浮层
### 目标
实现豆包式选区浮层基础能力。
### 任务
- 新增 `useWritingSelection.ts`
- 新增 `selection.ts`
- 新增 `WritingBubbleMenu.tsx`
- 基于 Tiptap 监听 selection 变化
- 过滤掉空选区
- 处理多段连续选区
- 浮层按钮:
- AI 改写
- 加粗
- 斜体
- 标题
- 复制
- 处理滚动定位
- 处理窗口尺寸变化
### 交付物
- 选中内容后出现浮层
### 验收
- 单段可触发
- 多段可触发
- 浮层位置稳定
---
## 阶段 E:AI 改写菜单
### 目标
把选区浮层接到 AI 改写工作流。
### 任务
- 新增 `WritingAiMenu.tsx`
- 新增 `useWritingRewrite.ts`
- 新增 `api/rewrite.ts`
- 实现一级动作:
- 润色
- 扩写
- 缩写
- 改语气
- 实现改语气二级菜单:
- 更专业
- 更口语化
- 更易读
- 更学术
- 更有文采
- 封装请求入参
- 增加 loading 态
- 增加 error 态
- 增加 retry 入口
### 交付物
- AI 改写菜单和请求链路
### 验收
- 4 个动作都能发请求
- 改语气的二级菜单可正常展开
- 请求失败时有反馈
---
## 阶段 F:候选结果预览
### 目标
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
### 任务
- 新增 `WritingRewritePreview.tsx`
- 新增 `apply-rewrite.ts`
- 显示原文与候选内容
- 提供按钮:
- 替换
- 插入
- 重试
- 取消
- 用 transaction 应用结果
- 处理应用成功后的焦点与滚动
### 交付物
- 改写结果预览卡片或面板
### 验收
- 替换准确
- 插入准确
- 取消后文档保持原样
---
## 阶段 G:文档状态与容器体验
### 目标
补齐正式产品的壳层体验。
### 任务
- 新增 `WritingShell.tsx`
- 新增 `WritingHeader.tsx`
- 新增 `WritingStatusBar.tsx`
- 新增 `WritingEmptyState.tsx`
- 补充文档标题区域
- 补充文档状态提示
- 补充空态和加载态
### 交付物
- 页面完整壳层
### 验收
- 页面不是裸编辑器
- 有完整的文档工作区感
---
## 阶段 H:视觉打磨
### 目标
让整体观感接近成熟产品。
### 任务
- 调整右侧阅读宽度
- 调整字体大小和行高
- 调整段距和标题层级
- 调整工具栏阴影、边框、圆角
- 调整浮层动效
- 调整 AI 预览卡片质感
- 优化深浅色模式兼容
### 交付物
- 完整视觉优化
### 验收
- 明显优于当前版本
- 有豆包 / Novel 风格的编辑器质感
---
## 6. 组件级开发清单
## 6.1 `WritingWorkspacePage.tsx`
职责:
- 页面级容器
- 获取线程 / 文档上下文
- 组织左右布局
必须完成:
- 页面初始化
- 与左侧聊天协同
- 右侧编辑区挂载
## 6.2 `WritingShell.tsx`
职责:
- 写作工作区总壳层
- 布局标题区、工具栏、正文区、状态区
必须完成:
- 自适应布局
- 桌面端优先
- 窄屏降级
## 6.3 `TiptapEditor.tsx`
职责:
- 包装 Tiptap EditorContent
- 负责 editor 实例初始化
必须完成:
- `onUpdate`
- `onSelectionUpdate`
- 内容同步
## 6.4 `WritingToolbar.tsx`
职责:
- 顶部主工具栏
必须完成:
- 所有基础格式化命令
- 当前状态高亮
## 6.5 `WritingBubbleMenu.tsx`
职责:
- 选区浮层
必须完成:
- 跟随选区
- 基础快捷操作
- AI 改写入口
## 6.6 `WritingAiMenu.tsx`
职责:
- AI 改写动作菜单
必须完成:
- 一级菜单
- 改语气二级菜单
- 动作触发
## 6.7 `WritingRewritePreview.tsx`
职责:
- 展示候选内容
必须完成:
- 替换
- 插入
- 重试
- 取消
---
## 7. Hook 级开发清单
## 7.1 `useWritingEditor.ts`
负责:
- editor 生命周期
- 初始内容装载
- 内容变更同步
## 7.2 `useWritingSelection.ts`
负责:
- 当前选区抽取
- 选区文本缓存
- 多段落纯文本拼接
## 7.3 `useWritingRewrite.ts`
负责:
- 改写请求
- loading / success / error
- 结果缓存
- 重试
## 7.4 `useWritingToolbar.ts`
负责:
- toolbar active 状态
- editor command 映射
---
## 8. API 对接清单
前端需向后端约定以下接口能力:
- `POST /api/writing/rewrite`
前端请求内容至少包括:
- `threadId`
- `documentId`
- `selection.from`
- `selection.to`
- `selection.text`
- `action`
- `tone`
- `surroundingContext.before`
- `surroundingContext.after`
前端需处理的返回字段:
- `output`
- `originalSelection`
- `metadata`
---
## 9. 状态管理建议
建议新增独立 Context:
- `WritingEditorContext.tsx`
建议持有:
- 当前文档内容
- 当前 editor 实例
- 当前选区
- 当前改写请求状态
- 当前候选结果
- 当前 UI 面板状态
不建议继续复用旧 Artifact Context 的原因:
- 旧 Context 关注文件列表和文件路径
- 新编辑器关注选区、结构、候选结果、应用动作
- 关注点完全不同
---
## 10. 前端联调检查点
## 10.1 与后端联调前
- 先用 mock 数据打通浮层和预览
- 先验证替换 / 插入逻辑
## 10.2 与后端联调时
- 检查多段落选区拼接结果
- 检查 AI 返回内容是否带说明性前缀
- 检查替换后位置是否准确
## 10.3 联调后
- 验证大段文本性能
- 验证撤销 / 重做
- 验证快速连续触发是否会状态错乱
---
## 11. 前端测试清单
## 11.1 功能测试
- 输入正文
- 插入标题
- 切换列表
- 插入引用
- 插入代码块
- 加粗 / 斜体 / 下划线 / 删除线
- 选区浮层显示
- 选区浮层隐藏
- AI 改写菜单打开
- 改语气二级菜单打开
- 改写结果替换
- 改写结果插入
- 改写结果取消
## 11.2 场景测试
- 单段短文本润色
- 多段文本润色
- 多段文本扩写
- 多段文本缩写
- 多段文本改语气
- 连续两次改写同一段
- 快速切换线程后重新进入
## 11.3 回归测试
- 左侧聊天是否正常
- 模型切换是否正常
- `writing_mode` 是否正常
- 原有 artifact 模块是否仍可使用
---
## 12. 任务优先级
### P0
- 路由与页面壳
- Tiptap 接入
- 主工具栏
- 选区浮层
- 改写菜单
- 替换 / 插入
### P1
- 页面头部与状态
- 优雅 loading / error / retry
- 多段落稳定性
- UI 打磨
### P2
- 空态优化
- 动效优化
- 快捷键支持
---
## 13. 建议分工
### 前端 1 号
负责:
- 路由与页面壳
- Tiptap 接入
- 主工具栏
### 前端 2 号
负责:
- 选区状态
- BubbleMenu
- AI 菜单
### 前端 3 号
负责:
- 候选结果预览
- 替换 / 插入
- 状态栏与页面体验
### 联调负责人
负责:
- 前后端请求协议
- 错误态规范
- 多段落测试和回归
---
## 14. 前端完成定义
满足以下条件可视为前端完成:
- 右侧能作为正式编辑器独立使用
- 选区后可直接发起 AI 改写
- 改写后可替换或插入
- 视觉上明显优于当前版本
- 左侧聊天和现有系统不被破坏
---
## 15. 下一步建议
前端团队可以按以下顺序开工:
1. 先起页面和路由
2. 再接 Tiptap 与主工具栏
3. 再做选区浮层
4. 再接 AI 菜单和结果预览
5. 最后做视觉打磨
这个顺序风险最小,也最容易在中途验收。
@@ -0,0 +1,457 @@
# AI 写作助手源码与实现映射表
## 1. 文档目标
本文档用于把以下几类信息对应起来:
- 豆包 AI 写作助手里要借鉴的交互
- Novel / open-canvas / Tiptap 中可参考的实现思路
- DeerFlow 当前项目里已经存在的落点
- 本次新建模块应该落到哪里
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
---
## 2. 使用方式
开发时建议按以下顺序看:
1. 先看“目标能力”
2. 再看“参考来源”
3. 再看“DeerFlow 当前落点”
4. 最后看“本次目标文件”
这样可以避免出现两种常见问题:
- 直接照搬外部项目结构,结果和你们现有架构冲突
- 只看自己项目,不知道成熟产品交互应该怎么落
---
## 3. 总体映射
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
| --- | --- | --- | --- | --- |
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
---
## 4. 豆包功能到 DeerFlow 的映射
## 4.1 滑词 / 选区浮层
### 豆包里的体验
- 选中文本后出现轻量浮层
- 浮层里有:
- AI 改写
- 文本样式
- 复制
### DeerFlow 当前状态
- 右侧还没有正式的选区级工具层
### 本次实现方式
- 使用 Tiptap 的 `BubbleMenu`
- 再叠加自定义菜单逻辑
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
---
## 4.2 多段落改写
### 豆包里的体验
- 不是只改单句
- 能对连续几段一起润色、扩写、缩写、改语气
### DeerFlow 当前状态
- 现有右侧编辑区没有正式的多段落选区改写模型
### 本次实现方式
- 前端提取连续选区纯文本
- 前端附带前后文
- 后端返回候选纯文本
- 前端决定替换或插入
### 本次落点
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
---
## 4.3 替换 / 插入
### 豆包里的体验
- 改写结果不是直接覆盖原文
- 用户可选择:
- 替换
- 插入
### DeerFlow 当前状态
- 当前没有候选文本应用层
### 本次实现方式
- 建立候选结果预览区
- 基于 Tiptap transaction 应用文本
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
---
## 4.4 标题 / 列表 / 引用 / 代码块工具条
### 豆包里的体验
- 右侧本身就是正式文档编辑器
- 可以快速切换文档结构
### DeerFlow 当前状态
- 当前右侧偏预览,不是正式写作编辑器
### 本次实现方式
- 使用 Tiptap 节点和命令
- 在主工具栏中集中承载
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
---
## 5. Novel 参考映射
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
- 编辑器主容器的产品感
- 工具栏和空态的组织方式
- 正文阅读宽度、排版、留白
## 5.2 对应到 DeerFlow 的实现位置
- 页面壳:`WritingShell.tsx`
- 顶部文档区:`WritingHeader.tsx`
- 编辑容器:`TiptapEditor.tsx`
- 工具栏:`WritingToolbar.tsx`
- 空态:`WritingEmptyState.tsx`
## 5.3 不建议直接照搬的部分
- Next.js 页面结构
- 依赖其服务端 AI 调用方式
- 与 Vercel AI SDK 深绑定的部分
---
## 6. open-canvas 参考映射
## 6.1 主要借鉴对象
open-canvas 这次最值得借鉴的不是页面外观,而是:
- AI 工作流组织方式
- 改写结果应用节奏
- 编辑器和聊天协作的模式
## 6.2 借鉴重点
### 借鉴 1:独立工作区思想
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
### 借鉴 2:画布状态独立维护
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
### 借鉴 3:AI 输出先预览,再应用
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
---
## 7. Tiptap 参考映射
## 7.1 Tiptap 在本项目中的职责
- 作为右侧正式富文本编辑器内核
- 提供结构节点与 marks
- 提供 BubbleMenu 能力
- 提供 transaction 应用能力
## 7.2 建议直接参考的实现点
- `EditorContent` 的封装模式
- `BubbleMenu` 的接入方式
- heading / list / blockquote / codeBlock 扩展配置
- `editor.chain().focus()` 命令调用模式
- selection 变化监听
## 7.3 DeerFlow 本次落点
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
---
## 8. DeerFlow 现有文件到新模块的映射
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
### 当前职责
- 汇总页面路由
### 本次作用
- 挂入新的写作助手路由
### 后续动作
- 引入 `WritingRoutes`
- 增加 `page/writing/*`
---
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
### 当前职责
- 左聊天 + 右 Artifact 双栏布局
### 本次作用
- 提供双栏工作区组织思路
- 可复用布局比例和 panel 交互经验
### 不建议直接复用的部分
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
### 本次目标替代
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
---
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
### 当前职责
- 管理文件型 artifact 列表、选中项和开关状态
### 本次作用
- 仅作为旧 Artifact 模块保留
### 本次目标替代
- `WritingEditorContext.tsx`
### 原因
- 写作编辑器需要的不是文件路径状态
- 而是文档状态、选区状态、候选结果状态、工具层状态
---
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
### 当前职责
- 聊天输入区
- 已有 `writing_mode` 入口
### 本次作用
- 保留为用户进入写作模式的现有入口
### 本次改造建议
- 写作模式打开时,跳转或唤起新的写作工作区
- 不再只停留在“生成 Markdown 文件”这个层面
---
## 9. 新模块间的依赖关系
## 9.1 页面层
- `WritingRoutes.tsx`
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
职责:
- 页面路由
- 双栏或工作区布局
- 上下文装配
## 9.2 编辑器层
- `TiptapEditor.tsx`
- `TiptapExtensions.ts`
- `commands.ts`
- `selection.ts`
职责:
- 编辑器内核
- 节点和 marks
- 基础命令
- 选区抽取
## 9.3 交互层
- `WritingToolbar.tsx`
- `WritingBubbleMenu.tsx`
- `WritingAiMenu.tsx`
- `WritingRewritePreview.tsx`
职责:
- 工具栏
- 浮层
- AI 菜单
- 候选内容应用
## 9.4 状态层
- `WritingEditorContext.tsx`
- `useWritingEditor.ts`
- `useWritingSelection.ts`
- `useWritingRewrite.ts`
- `useWritingToolbar.ts`
职责:
- 主状态
- 选区状态
- 改写状态
- 工具栏状态
## 9.5 API 层
- `api/rewrite.ts`
- `api/documents.ts`
职责:
- 对接后端写作改写接口
- 对接文档加载与保存接口
---
## 10. 能力到文件的精确映射
| 能力 | 新文件 | 说明 |
| --- | --- | --- |
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
---
## 11. 开发时的参考顺序
建议开发顺序如下:
1. 先看 `PageRoutes.tsx`,确定路由接法
2. 再看 `chat-box.tsx`,理解当前左右布局
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
4. 再接 `TiptapEditor.tsx`
5. 再做 `WritingToolbar.tsx`
6. 再做 `WritingBubbleMenu.tsx`
7. 再做 `WritingAiMenu.tsx`
8. 最后做 `WritingRewritePreview.tsx`
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
---
## 12. 明确哪些是“参考”,哪些是“目标”
### 12.1 参考
- 豆包:交互体验参考
- Novel:页面和编辑器质感参考
- open-canvas:AI 工作流参考
- Tiptap:编辑器技术实现参考
### 12.2 目标
目标不是复刻任何一个外部项目,而是:
- 在 DeerFlow 现有架构中
- 用 React + Vite + 现有线程体系
- 做出一个更成熟的 AI 写作编辑器模式
---
## 13. 结论
这份映射表的核心作用,是避免团队在开发时迷路。
真正推荐的姿势是:
- 用豆包定义目标体验
- 用 Novel 定义页面质感
- 用 open-canvas 定义 AI 工作流
- 用 Tiptap 作为编辑器内核
- 用 DeerFlow 现有路由、线程、模型体系做宿主
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
# Open Canvas 集成进度文档
> 更新时间:2026-05-16
> 当前分支:dhk-1
---
## 一、整体进度概览
| 阶段 | 名称 | 状态 |
|------|------|------|
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 前端阶段四 | API 适配层 | ✅ 已完成 |
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
**当前状态**:全部 9 个阶段已完成 🎉
---
## 二、已完成工作详情
### 2.1 前端路由接入
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
```tsx
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 在 PageLayout 内部添加:
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
```
子路由:
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
### 2.2 前端目录结构(已建立,26 个文件)
```
frontend-web/src/open-canvas/
api/
client.ts # stub,Phase 5 实现
models.ts # stub,Phase 5 实现
threads.ts # stub,Phase 5 实现
runs.ts # stub,Phase 5 实现
components/
layout/
CanvasShell.tsx # 顶层布局壳,flex-col
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
chat/
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
artifact/
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
contexts/
CanvasThreadContext.tsx # threadId、modelName
CanvasGraphContext.tsx # messages、artifact、isStreaming
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
hooks/
useCanvasStream.ts # stub,Phase 5 实现
useCanvasArtifact.ts # stub,Phase 6 实现
useCanvasSelection.ts # stub,Phase 7 实现
useCanvasQuickActions.ts # stub,Phase 8 实现
pages/
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
routes/
OpenCanvasRoutes.tsx # React Router 子路由
types/
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
selection.ts # CanvasTextSelection、CanvasCodeSelection
quick-action.ts # QuickAction、QuickActionId
utils/
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
selection.ts # stub
thread.ts # stub
stream.ts # stub
quick-actions.ts # stub
constants/
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
```
### 2.3 核心类型定义
```ts
// types/artifact.ts
export type CanvasArtifact = {
currentIndex: number; // 从 1 开始
contents: CanvasArtifactContent[];
};
export type CanvasArtifactText = {
index: number; type: "text"; title: string; fullMarkdown: string;
};
export type CanvasArtifactCode = {
index: number; type: "code"; title: string;
language: ProgrammingLanguageOptions; code: string;
};
```
### 2.4 Context 结构
```tsx
// CanvasThreadPage.tsx 的装配顺序
<CanvasThreadProvider initialThreadId={thread_id}>
<CanvasGraphProvider>
<CanvasUIProvider>
<CanvasShell />
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
```
---
## 三、已解决的技术难题
### 3.1 react-resizable-panels v4.6.2 API 差异
此版本与旧版 API 不同,必须使用:
```tsx
// ✅ 正确写法
import type { GroupImperativeHandle } from "react-resizable-panels";
const layoutRef = useRef<GroupImperativeHandle>(null);
<ResizablePanelGroup
orientation="horizontal" // ← 不是 direction
groupRef={layoutRef} // ← 不是 ref
>
<ResizablePanel id="chat" ... /> // ← 必须有 id
<ResizablePanel id="artifact" ... /> // ← 必须有 id
</ResizablePanelGroup>
// 切换折叠
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
```
### 3.2 Flex 高度链(防止底部内容被裁剪)
```
PageLayout(h-screen)
└── main(flex-1)
└── CanvasShell div(h-full flex flex-col)
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
└── ResizablePanelGroup(h-full)
└── 面板内容(h-full)
```
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
### 3.3 折叠/展开按钮
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
```tsx
useEffect(() => {
layoutRef.current?.setLayout(
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
);
}, [isChatCollapsed]);
```
---
## 四、下一步:后端 ThreadState 扩展
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
```python
# 新增 TypedDict(建议单独定义)
class CanvasArtifactText(TypedDict):
index: int
type: Literal["text"]
title: str
fullMarkdown: str
class CanvasArtifactCode(TypedDict):
index: int
type: Literal["code"]
title: str
language: str
code: str
class CanvasArtifact(TypedDict):
currentIndex: int
contents: list[CanvasArtifactText | CanvasArtifactCode]
# 在 ThreadState 中新增字段
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
highlighted_text: Optional[str] # 用户选中的文本片段
highlighted_code: Optional[str] # 用户选中的代码片段
canvas_mode: Optional[Literal["text", "code"]]
quick_action_id: Optional[str] # 快捷动作 id
web_search_enabled: bool # 默认 False
web_search_results: Optional[list[dict]]
```
**验收标准**:
- 旧线程无 `artifact` 字段时不报错
- `pnpm typecheck` 无新增错误(前端字段已对齐)
- `make test` 全量回归通过
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
---
## 五、关键文件位置
| 文件 | 用途 |
|------|------|
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
---
## 六、架构决策记录
| 决策 | 内容 |
|------|------|
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
---
## 七、开始新对话的推荐提示词
```
我们在集成 open-canvas 到 DeerFlow 项目。
参考进度文档:frontend-web/docs/openCanvas-progress.md
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
```
@@ -21,6 +21,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
@@ -31,6 +38,7 @@ import {
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
@@ -68,6 +76,7 @@ function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const { config } = useCuratorConfig();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
@@ -79,10 +88,25 @@ function StatusCard() {
}
}, [status?.last_run_at, tt.lastRunNever]);
// 总开关或 curator 子开关任一关闭时,手动触发跑不起来 —— 直接禁用按钮。
const runDisabled = !config || !config.enabled || !config.curator.enabled;
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
const res = await runCurator.mutateAsync();
if (res.triggered) {
toast.success(tt.runStarted);
} else {
// 后端预检拦下了(开关未开 / 已有任务在跑)—— 给出对应原因,而不是误报"已开始"。
const reasonMsg: Record<string, string> = {
evolution_disabled: tt.runBlockedEvolutionDisabled,
curator_disabled: tt.runBlockedCuratorDisabled,
already_running: tt.runBlockedAlreadyRunning,
};
toast.warning(
reasonMsg[res.reason ?? ""] ?? tt.runBlockedEvolutionDisabled,
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -107,7 +131,8 @@ function StatusCard() {
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
disabled={runCurator.isPending || runDisabled}
title={runDisabled ? tt.runDisabledHint : undefined}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
@@ -154,6 +179,73 @@ function StatusCard() {
</span>
</span>
</StatRow>
<StatRow label={tt.lastActions} className="sm:col-span-2">
<div className="flex flex-col gap-2 text-xs">
<div>
<div className="font-medium">{tt.llmSuggested}</div>
{status.last_consolidation_suggestions.consolidations.length ===
0 &&
status.last_consolidation_suggestions.prunings.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
{status.last_consolidation_suggestions.consolidations.map(
(c, i) => (
<li key={`c${i}`}>
{(c.from ?? "?") + " → " + (c.into ?? "?")}
{c.reason ? `(${c.reason})` : ""}
</li>
),
)}
{status.last_consolidation_suggestions.prunings.map(
(p, i) => (
<li key={`p${i}`}>
{p.name ?? "?"}
{p.reason ? `(${p.reason})` : ""}
</li>
),
)}
</ul>
)}
</div>
<div>
<div className="font-medium">{tt.actuallyDid}</div>
{status.last_actual_actions.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="mt-0.5 flex flex-col gap-1">
{status.last_actual_actions.map((a, i) => (
<li
key={i}
className="flex flex-wrap items-center gap-1.5"
>
<span className="font-mono">{a.tool ?? "?"}</span>
{typeof a.args?.name === "string" && (
<span className="text-muted-foreground">
{String(a.args.name)}
</span>
)}
<Badge
variant={
a.status === "error"
? "destructive"
: "secondary"
}
className="px-1 text-[10px]"
>
{a.status}
</Badge>
</li>
))}
</ul>
)}
</div>
</div>
</StatRow>
</div>
) : null}
</CardContent>
@@ -178,11 +270,15 @@ function StatRow({
);
}
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
const DEFAULT_MODEL_VALUE = "__default__";
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const { models } = useModels();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
@@ -311,22 +407,35 @@ function ConfigCard() {
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
label={tt.curatorModelLabel}
helper={tt.curatorModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
<Select
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
onValueChange={(v) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
curator: {
...draft.curator,
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
},
})
}
/>
>
<SelectTrigger className="max-w-[20rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_MODEL_VALUE}>
{tt.curatorModelDefault}
</SelectItem>
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
+8 -2
View File
@@ -28,11 +28,17 @@ export async function updateCuratorConfig(
return response.json() as Promise<CuratorConfig>;
}
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
export interface RunCuratorResult {
triggered: boolean;
/** 未触发时的原因代码:evolution_disabled / curator_disabled / already_running。 */
reason: string | null;
}
export async function runCurator(): Promise<RunCuratorResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
method: "POST",
});
return response.json();
return response.json() as Promise<RunCuratorResult>;
}
export async function getCuratorLogs(): Promise<CuratorLogs> {
+30 -1
View File
@@ -3,14 +3,39 @@ export interface CuratorSubConfig {
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
review_timeout_seconds: number;
/** curator 评审使用的模型名;null = 用主模型。 */
model_name: string | null;
}
export interface CuratorConfig {
enabled: boolean;
moderation_model_name: string | null;
curator: CuratorSubConfig;
}
export interface CuratorAction {
tool: string | null;
args: Record<string, unknown>;
status: string;
result: string;
}
export interface CuratorConsolidation {
from?: string;
into?: string;
reason?: string;
}
export interface CuratorPruning {
name?: string;
reason?: string;
}
export interface CuratorSuggestions {
consolidations: CuratorConsolidation[];
prunings: CuratorPruning[];
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
@@ -23,6 +48,10 @@ export interface CuratorStatus {
stale: number;
archived: number;
};
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
last_consolidation_suggestions: CuratorSuggestions;
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
last_actual_actions: CuratorAction[];
}
export interface CuratorLogRecord {
+14 -3
View File
@@ -567,6 +567,16 @@ export const enUS: Translations = {
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
runBlockedEvolutionDisabled:
"Skill evolution is off — enable it below to run the curator.",
runBlockedCuratorDisabled:
"The curator is off — enable it below to run it manually.",
runBlockedAlreadyRunning: "A curator run is already in progress.",
runDisabledHint: "Enable skill evolution and the curator below first.",
lastActions: "Last run",
llmSuggested: "LLM suggested (self-report)",
actuallyDid: "Actually done (tool calls)",
noActions: "None",
},
config: {
title: "Configuration",
@@ -587,9 +597,10 @@ export const enUS: Translations = {
archiveAfterDaysLabel: "Archive threshold (days)",
archiveAfterDaysHelper:
"Stale skills inactive for this many additional days are archived.",
moderationModelLabel: "Moderation model (optional)",
moderationModelHelper:
"Leave blank to use the default chat model.",
curatorModelLabel: "Curator review model",
curatorModelHelper:
"Model for the curator's LLM review and skill security moderation. Defaults to the primary model.",
curatorModelDefault: "Primary model (default)",
save: "Save changes",
saveSuccess: "Configuration updated",
reset: "Reset",
+11 -2
View File
@@ -490,6 +490,14 @@ export interface Translations {
total: string;
runNow: string;
runStarted: string;
runBlockedEvolutionDisabled: string;
runBlockedCuratorDisabled: string;
runBlockedAlreadyRunning: string;
runDisabledHint: string;
lastActions: string;
llmSuggested: string;
actuallyDid: string;
noActions: string;
};
config: {
title: string;
@@ -504,8 +512,9 @@ export interface Translations {
staleAfterDaysHelper: string;
archiveAfterDaysLabel: string;
archiveAfterDaysHelper: string;
moderationModelLabel: string;
moderationModelHelper: string;
curatorModelLabel: string;
curatorModelHelper: string;
curatorModelDefault: string;
save: string;
saveSuccess: string;
reset: string;
+11 -2
View File
@@ -541,6 +541,14 @@ export const zhCN: Translations = {
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
runBlockedEvolutionDisabled: "自我进化未开启 —— 请先在下方打开开关。",
runBlockedCuratorDisabled: "curator 未开启 —— 请先在下方打开 curator 开关。",
runBlockedAlreadyRunning: "已有一次 curator 运行进行中。",
runDisabledHint: "请先在下方开启自我进化与 curator 开关。",
lastActions: "最近一次运行",
llmSuggested: "LLM 建议(自述)",
actuallyDid: "实际执行(工具调用)",
noActions: "无",
},
config: {
title: "配置",
@@ -556,8 +564,9 @@ export const zhCN: Translations = {
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
archiveAfterDaysLabel: "归档阈值(天)",
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
moderationModelLabel: "安全审核模型(可选)",
moderationModelHelper: "为空时使用默认主模型。",
curatorModelLabel: "技能优化模型",
curatorModelHelper: "用于 curator 技能评审和技能安全审核的模型,为空则使用主模型。",
curatorModelDefault: "主模型(默认)",
save: "保存修改",
saveSuccess: "配置已更新",
reset: "重置",
@@ -0,0 +1 @@
export { getAPIClient } from "@/core/api";
@@ -0,0 +1,39 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type { CanvasModel } from "../types/model";
interface BackendModel {
name: string;
display_name?: string;
provider?: string;
is_local?: boolean;
supports_vision?: boolean;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
supports_tool_calling?: boolean;
// Canvas-phase-9 fields (now returned by backend)
description?: string;
}
export async function loadCanvasModels(): Promise<CanvasModel[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/models`);
const data = (await res.json()) as { models?: BackendModel[] };
return (data.models ?? []).map(
(m): CanvasModel => ({
name: m.name,
display_name: m.display_name ?? m.name,
provider: m.provider,
is_local: m.is_local,
supports_vision: m.supports_vision,
supports_thinking: m.supports_thinking,
supports_reasoning_effort: m.supports_reasoning_effort,
supports_tool_calling: m.supports_tool_calling ?? true,
supports_canvas_generation: true,
supports_canvas_rewrite: true,
supports_canvas_code_edit: true,
supports_streaming_text: true,
supports_streaming_tool_calls: m.supports_tool_calling ?? true,
}),
);
}
+192
View File
@@ -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 {};
+2
View File
@@ -6,6 +6,7 @@ import { PageSidebar } from "@/components/page-sidebar";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
const SIDEBAR_ENABLED = false;
@@ -41,6 +42,7 @@ function PageLayout() {
}
/>
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
</Routes>
</main>
</div>
@@ -302,6 +302,55 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
Background skill lifecycle manager. The Gateway `lifespan` starts a resident
scheduler task (`_curator_scheduler_loop` in `app/gateway/app.py`) that — after a
5-minute startup delay — calls `maybe_run_curator()` every hour. A cross-process
file lock (`.curator_scheduler.lock`) elects a single leader worker so the
scheduler runs once per deployment. Per run:
- **Master switch** — `skill_evolution.enabled` (default **`false`**) gates the
whole feature; `curator.enabled` (default **`false`**) gates the curator
specifically. Both must be `true` for the curator to act. `should_run_now()`
and `_curator_thread_target()` both enforce this — so manual `POST
/api/curator/run` also respects the master switch. Config is
`config.yaml.skill_evolution` deep-merged with per-user
`users/{user_id}/skill_evolution.json`.
- **Per-user run lock** — `.curator_state` carries `running` / `started_at`;
`_try_acquire_run_lock()` prevents the same user's curator from running
concurrently (scheduler vs. manual trigger, or double manual clicks). A lock
whose `started_at` is older than `_CURATOR_STALE_LOCK_HOURS` (6h) is treated as
process-crash residue and can be taken over.
- **Concurrency cap** — `maybe_run_curator()` collects all due users and hands
them to `_dispatch_curator_runs()`, which runs them through a
`ThreadPoolExecutor` capped at `_CURATOR_MAX_CONCURRENCY` (5) — avoids a
thundering herd of LLM calls when many users come due at once (e.g. first
deploy).
- **Failure back-off** — `_release_run_lock()` always stamps `last_attempt_at`.
When a first run keeps crashing (`last_run_at` never set), `should_run_now()`
retries on `_CURATOR_RETRY_HOURS` (6h) instead of every hourly tick.
- **`should_run_now()`** is the real gate: the hourly tick only checks "is the
per-user `interval_hours` (default 168h) elapsed". Timestamp parsing tolerates
naive (tz-less) strings — a corrupt `last_run_at` triggers a re-run rather than
a permanent deadlock.
- **Published-skill filter** — `SkillStore.list_published_names()` is a batch
query; the curator fetches the published-name set once per run instead of one
DB round-trip per candidate skill.
- **LLM review timeout** — `_run_llm_review()` runs `agent.invoke()` in a worker
thread bounded by `curator.review_timeout_seconds` (default 600s). The model's
per-request `request_timeout` can't bound a multi-turn React agent loop, so on
timeout the run is abandoned (a warning is written to `curator_log`, visible in
the Web UI) and the curator finishes cleanly + releases the run lock. The
worker context is copied via `contextvars.copy_context()` so tool calls still
resolve the correct `user_id`.
- **Review model** — `_run_llm_review()` uses `curator.model_name` (a
`config.yaml` `models[]` entry name) if set, else the primary model; it is
editable from the Web UI evolution panel. The skill security scanner
(`scan_skill_content` in `security_scanner.py`) reuses the same
`skill_evolution.curator.model_name` setting — review and moderation share one
model. There is no separate moderation-model config field.
### Model Factory (`packages/harness/deerflow/models/factory.py`)
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
@@ -1,6 +1,7 @@
import asyncio
import logging
import os
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
@@ -268,6 +269,93 @@ async def _run_db_migrations() -> None:
logger.exception("Database migration failed — schema may be out of date")
# Skill curator 调度参数。
# - 检查间隔:每隔这么久检查一次"需不需要进化";真正是否执行由 should_run_now() 判定。
# - 启动延迟:启动后先等这么久再首次检查,避开启动高峰。
_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 小时
_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 分钟
def _acquire_singleton_lock(lock_path):
"""跨平台非阻塞独占文件锁,用于多 worker 下的 leader 选举。
成功 -> 返回持有锁的文件句柄(调用方须保持其存活,直到进程退出/主动释放);
抢不到锁(已有其它 worker 持有)或打开失败 -> 返回 None。
进程异常退出时 OS 会自动释放该锁。
"""
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = open(lock_path, "a+")
except OSError:
logger.exception("Curator scheduler: 打开锁文件失败")
return None
try:
if sys.platform == "win32":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def _release_singleton_lock(handle) -> None:
"""释放并关闭 _acquire_singleton_lock 拿到的锁文件句柄。"""
try:
if sys.platform == "win32":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError:
pass
finally:
try:
handle.close()
except OSError:
pass
async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
运行调度,其余 worker 直接退出,避免 curator 被重复触发、重复调用 LLM。
被 cancel 时 CancelledError 会穿过 ``except Exception``(它是 BaseException
子类,不会被捕获),协程随之干净退出并在 finally 里释放锁。
"""
from deerflow.config.runtime_paths import runtime_home
from deerflow.skills.curator import maybe_run_curator
lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
if lock_handle is None:
logger.info("Curator scheduler: 另一个 worker 已持有调度锁,本 worker 跳过")
return
logger.info("Curator scheduler: 取得调度锁,本 worker 负责 curator 调度")
try:
# 启动后延迟一会儿再首次检查,避开启动高峰
await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
while True:
try:
await maybe_run_curator()
except Exception:
logger.exception("Curator scheduler tick failed (non-fatal)")
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
@@ -302,12 +390,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# Kick off skill curator in background if the run interval has elapsed
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
try:
from deerflow.skills.curator import maybe_run_curator
asyncio.create_task(maybe_run_curator())
app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
except Exception:
logger.exception("Skill curator startup task failed (non-fatal)")
logger.exception("Skill curator scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
@@ -335,6 +423,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield
# 停止 curator 调度器
curator_task = getattr(app.state, "curator_scheduler_task", None)
if curator_task is not None:
curator_task.cancel()
try:
await curator_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Curator scheduler shutdown error")
try:
scheduler_service = getattr(app.state, "scheduled_task_service", None)
if scheduler_service is not None:
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
router = APIRouter(prefix="/api", tags=["models"])
_PROVIDER_KEYWORDS: list[tuple[str, str]] = [
("langchain_openai", "openai"),
("langchain_anthropic", "anthropic"),
("langchain_google", "google"),
("langchain_groq", "groq"),
("langchain_mistralai", "mistral"),
("langchain_cohere", "cohere"),
("ollama", "ollama"),
("vllm", "vllm"),
]
_LOCAL_PROVIDERS = {"ollama", "vllm"}
def _derive_provider(use: str) -> str | None:
use_lower = use.lower()
for keyword, provider in _PROVIDER_KEYWORDS:
if keyword in use_lower:
return provider
return None
def _build_model_response(model: ModelConfig) -> "ModelResponse":
extra = model.model_extra or {}
provider = str(extra["provider"]) if "provider" in extra else _derive_provider(model.use)
is_local = bool(extra["is_local"]) if "is_local" in extra else (provider in _LOCAL_PROVIDERS)
supports_tool_calling = bool(extra["supports_tool_calling"]) if "supports_tool_calling" in extra else True
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
supports_vision=model.supports_vision,
supports_tool_calling=supports_tool_calling,
is_local=is_local,
provider=provider,
)
class ModelResponse(BaseModel):
"""Response model for model information."""
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
supports_vision: bool = Field(default=False, description="Whether model supports image inputs")
supports_tool_calling: bool = Field(default=True, description="Whether model supports tool/function calling")
is_local: bool = Field(default=False, description="Whether this is a locally-hosted model")
provider: str | None = Field(None, description="Model provider name (openai, anthropic, ollama, etc.)")
class TokenUsageResponse(BaseModel):
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
}
```
"""
models = [
ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
for model in config.models
]
models = [_build_model_response(model) for model in config.models]
return ModelsListResponse(
models=models,
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
@@ -122,11 +157,4 @@ async def get_model(model_name: str, config: AppConfig = Depends(get_config)) ->
if model is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
return _build_model_response(model)
@@ -1045,12 +1045,21 @@ async def curator_status(request: Request) -> dict:
@router.post("/curator/run", summary="Trigger Curator Run")
async def run_curator(request: Request) -> dict:
"""Manually trigger a curator review pass for the authenticated user
(bypasses interval check, runs in a background thread)."""
(bypasses the interval check, runs in a background thread).
Pre-checks the master switch / curator switch / run lock so the response
accurately reflects whether a run actually started — instead of always
reporting success. Returns ``{"triggered": bool, "reason": str | None}``.
"""
try:
import threading
from deerflow.skills.curator import _curator_thread_target
from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason
user_id = _current_user_id(request)
reason = manual_trigger_blocked_reason(user_id)
if reason is not None:
return {"triggered": False, "reason": reason}
thread = threading.Thread(
target=_curator_thread_target,
args=(user_id,),
@@ -1058,7 +1067,7 @@ async def run_curator(request: Request) -> dict:
name=f"skill-curator-manual-{user_id}",
)
thread.start()
return {"triggered": True, "message": "Curator run started in background"}
return {"triggered": True, "reason": None}
except Exception as e:
logger.error("Failed to trigger curator: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}")
@@ -1067,17 +1076,19 @@ async def run_curator(request: Request) -> dict:
class CuratorSubConfig(BaseModel):
"""Nested curator configuration mirroring CuratorConfig."""
enabled: bool = Field(default=True, description="Whether the curator runs automatically.")
# 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.")
stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.")
archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.")
review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.")
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.")
class CuratorConfigResponse(BaseModel):
"""Response model exposing the effective skill_evolution config."""
enabled: bool
moderation_model_name: str | None
curator: CuratorSubConfig
@@ -1089,7 +1100,6 @@ class CuratorConfigRequest(BaseModel):
"""
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
moderation_model_name: str | None = Field(default=None, description="Optional model name for skill security moderation.")
curator: CuratorSubConfig
@@ -1113,12 +1123,13 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse:
"""
return CuratorConfigResponse(
enabled=evo.enabled,
moderation_model_name=evo.moderation_model_name,
curator=CuratorSubConfig(
enabled=evo.curator.enabled,
interval_hours=evo.curator.interval_hours,
stale_after_days=evo.curator.stale_after_days,
archive_after_days=evo.curator.archive_after_days,
review_timeout_seconds=evo.curator.review_timeout_seconds,
model_name=evo.curator.model_name,
),
)
@@ -98,6 +98,10 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
_DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json.
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
@@ -180,12 +184,15 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
def resolve_agent_factory(assistant_id: str | None):
"""Resolve the agent factory callable from config.
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
injected into ``configurable`` or ``context`` — see
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
same factory; the routing happens inside ``make_lead_agent`` when it reads
``cfg["agent_id"]``.
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
to their own factory. Custom agents share the ``lead_agent`` factory with
``agent_id`` injected into configurable/context by :func:`build_run_config`.
"""
if assistant_id == "canvas_agent":
from deerflow.agents.canvas_agent.agent import make_canvas_agent
return make_canvas_agent
from deerflow.agents.lead_agent.agent import make_lead_agent
return make_lead_agent
@@ -244,8 +251,9 @@ def build_run_config(
config["configurable"] = {"thread_id": thread_id}
# Inject custom agent id when the caller specified a non-default assistant.
# Honour an explicit agent_id in the active runtime options container.
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
# factory and must NOT receive an agent_id override.
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
normalized = assistant_id.strip()
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.")
@@ -265,7 +273,7 @@ def build_run_config(
def _collect_requested_agent_ids(body: Any) -> set[str]:
"""Collect agent ids from assistant_id/config/context for access checks."""
agent_ids: set[str] = set()
if body.assistant_id and body.assistant_id != _DEFAULT_ASSISTANT_ID:
if body.assistant_id and body.assistant_id not in _BUILTIN_GRAPH_IDS:
agent_ids.add(str(body.assistant_id))
def collect(container: Mapping[str, Any] | None) -> None:
@@ -6,7 +6,8 @@
],
"env": ".env",
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent"
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent"
},
"auth": {
"path": "./app/gateway/langgraph_auth.py:auth"
@@ -0,0 +1,3 @@
from deerflow.agents.canvas_agent.agent import make_canvas_agent
__all__ = ["make_canvas_agent"]
@@ -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,
)
@@ -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>
"""
@@ -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)],
}
)
@@ -69,8 +69,25 @@ _enabled_skills_refresh_version = 0
_enabled_skills_refresh_event = threading.Event()
def _drop_archived_skills(skills: list[Skill]) -> list[Skill]:
"""从注入提示词的技能列表里剔除已归档(usage state=archived)的技能。
技能归档是软状态:SKILL.md 仍留在 custom/<name>/、enabled 标志不变,所以
load_skills(enabled_only=True) 仍会返回它。但归档技能不应再出现在 agent 的系统
提示词里 —— 对 agent 而言它应等同于"不存在"。失败时不过滤(返回原列表)。
"""
try:
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
entries = all_entries()
return [s for s in skills if entries.get(s.name, {}).get("state") != STATE_ARCHIVED]
except Exception:
logger.debug("Failed to filter archived skills for prompt injection", exc_info=True)
return skills
def _load_enabled_skills_sync() -> list[Skill]:
return list(get_or_new_skill_storage().load_skills(enabled_only=True))
return _drop_archived_skills(list(get_or_new_skill_storage().load_skills(enabled_only=True)))
def _start_enabled_skills_refresh_thread() -> None:
@@ -171,7 +188,7 @@ def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[
"""
if app_config is None:
return _get_enabled_skills()
return list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))
return _drop_archived_skills(list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True)))
def _skill_mutability_label(category: SkillCategory | str) -> str:
@@ -1,4 +1,4 @@
from typing import Annotated, NotRequired, TypedDict
from typing import Annotated, Literal, NotRequired, Optional, TypedDict
from langchain.agents import AgentState
@@ -18,6 +18,26 @@ class ViewedImageData(TypedDict):
mime_type: str
class CanvasArtifactText(TypedDict):
index: int
type: Literal["text"]
title: str
fullMarkdown: str
class CanvasArtifactCode(TypedDict):
index: int
type: Literal["code"]
title: str
language: str
code: str
class CanvasArtifact(TypedDict):
currentIndex: int
contents: list[CanvasArtifactText | CanvasArtifactCode]
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
"""Reducer for artifacts list - merges and deduplicates artifacts."""
if existing is None:
@@ -53,3 +73,11 @@ class ThreadState(AgentState):
todos: NotRequired[list | None]
uploaded_files: NotRequired[list[dict] | None]
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
# Canvas fields (coexist with artifacts: list[str] which tracks file outputs)
artifact: NotRequired[Optional[CanvasArtifact]]
highlighted_text: NotRequired[Optional[str]]
highlighted_code: NotRequired[Optional[str]]
canvas_mode: NotRequired[Optional[Literal["text", "code"]]]
quick_action_id: NotRequired[Optional[str]]
web_search_enabled: NotRequired[bool]
web_search_results: NotRequired[Optional[list[dict]]]
@@ -4,10 +4,17 @@ from pydantic import BaseModel, Field
class CuratorConfig(BaseModel):
"""Configuration for the background skill curator."""
enabled: bool = Field(default=True, description="Whether the curator runs automatically.")
# 默认关闭:curator 仅在用户显式开启时才运行(配合 SkillEvolutionConfig.enabled
# 总开关,二者均为安全默认 False —— 未显式 opt-in 则整套技能进化不动作)。
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).")
stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.")
archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.")
# LLM 评审整体超时:agent.invoke 内部可能多轮调 LLM,模型层的单次 request_timeout
# 兜不住整体;超过此秒数则中止本次评审,curator 正常收尾并释放运行锁。
review_timeout_seconds: int = Field(default=600, description="Overall timeout (seconds) for the LLM review pass.")
# curator LLM 评审使用的模型名(对应 config.yaml models[].name);为空则用主模型。
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass. Defaults to the primary chat model.")
class SkillEvolutionConfig(BaseModel):
@@ -17,10 +24,6 @@ class SkillEvolutionConfig(BaseModel):
default=False,
description="Whether the agent can create and modify skills under skills/custom.",
)
moderation_model_name: str | None = Field(
default=None,
description="Optional model name for skill security moderation. Defaults to the primary chat model.",
)
curator: CuratorConfig = Field(
default_factory=CuratorConfig,
description="Configuration for the background skill curator.",
@@ -67,3 +67,13 @@ class SkillStore(ABC):
async def count_owned(self, user_id: str) -> int:
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
raise NotImplementedError
@abstractmethod
async def list_published_names(self) -> set[str]:
"""Return the set of skill names with ``published = True`` (any owner).
Batch alternative to calling ``get_any`` per skill — the curator uses
this to filter out published skills in a single query instead of one
DB round-trip per candidate skill.
"""
raise NotImplementedError
@@ -133,3 +133,6 @@ class MemorySkillStore(SkillStore):
async def count_owned(self, user_id: str) -> int:
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
async def list_published_names(self) -> set[str]:
return {name for name, record in self._skills.items() if record.get("published")}
@@ -148,3 +148,9 @@ class SkillRepository(SkillStore):
stmt = select(func.count()).select_from(SkillRow).where(SkillRow.owner_user_id == user_id)
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def list_published_names(self) -> set[str]:
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
async with self._sf() as session:
result = await session.execute(stmt)
return {row[0] for row in result.all()}
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import contextvars
import json
import logging
import os
@@ -25,6 +26,10 @@ logger = logging.getLogger(__name__)
_CURATOR_STATE_FILENAME = ".curator_state"
# 周期调度一轮里同时执行的 curator 数量上限。到期用户多时(尤其首次部署所有用户一起
# 到期)用线程池排队消费,避免一次性起 N 个线程齐调 LLM 打爆 provider 速率限制。
_CURATOR_MAX_CONCURRENCY = 5
_T = TypeVar("_T")
@@ -119,6 +124,81 @@ def _write_curator_state(state: dict, user_id: str | None) -> None:
logger.debug("Failed to write curator state", exc_info=True)
def _parse_state_time(value: str | None) -> datetime | None:
"""解析 ``.curator_state`` 里的 ISO 时间字符串,返回 aware datetime(UTC)。
naive(无时区)字符串按 UTC 补全 —— 与 ``apply_automatic_transitions`` 的处理
一致,避免 naive 时间串与 ``datetime.now(UTC)`` 直接比较时抛异常。无法解析时
返回 None,由调用方决定降级行为。
"""
if not value:
return None
try:
dt = datetime.fromisoformat(value)
except Exception:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt
# Curator 运行锁:防止同一用户的 curator 并发执行(定时器 vs 手动触发、连点手动触发)。
# 锁状态写进 .curator_state 的 running/started_at 字段,跨 worker 可见(手动请求可能
# 落到非调度 worker)。started_at 超过下面的阈值视为进程残留,允许接管以免永久死锁。
_CURATOR_STALE_LOCK_HOURS = 6
# 上一轮"尝试"过但未成功(last_run_at 始终为空)时的重试间隔 —— 比正常 interval_hours
# 短得多,但也不至于每个调度 tick(1h)都重试、每次都烧一份 LLM 配额。
_CURATOR_RETRY_HOURS = 6
# 进程内串行化运行锁的"读-改-写",保证同进程内的抢锁是原子的(跨进程的极小竞态窗口
# 可接受 —— curator 触发频率极低)。
_run_lock_guard = threading.Lock()
def _run_lock_active(state: dict, now: datetime) -> bool:
"""判断 ``.curator_state`` 里的运行锁当前是否有效(running 且未过期)。
``started_at`` 缺失或不可解析、或距今已超过 ``_CURATOR_STALE_LOCK_HOURS`` 时,
视为进程残留留下的死锁,返回 False 以便后续触发能接管。
"""
if not state.get("running"):
return False
started_dt = _parse_state_time(state.get("started_at"))
if started_dt is None:
return False
return now < started_dt + timedelta(hours=_CURATOR_STALE_LOCK_HOURS)
def _try_acquire_run_lock(user_id: str, now: datetime) -> bool:
"""尝试为 ``user_id`` 抢占 curator 运行锁。
抢到锁(已把 running=True / started_at 写回)返回 True;已有未过期的运行时返回
False。读-改-写在 ``_run_lock_guard`` 下进行,保证同进程内原子。
"""
with _run_lock_guard:
state = _read_curator_state(user_id)
if _run_lock_active(state, now):
return False
state["running"] = True
state["started_at"] = now.isoformat()
_write_curator_state(state, user_id)
return True
def _release_run_lock(user_id: str) -> None:
"""释放 curator 运行锁并记录本次尝试时间。无论运行成功或异常都应调用。
除了把 running 置回 False,还写入 ``last_attempt_at`` —— 用于失败退避:首次运行
若一直崩溃,``last_run_at`` 永远为空,``should_run_now`` 改用
``last_attempt_at + _CURATOR_RETRY_HOURS`` 判定,避免每个 tick 都重试。
成功跑完后 ``last_run_at`` 已被写入,``should_run_now`` 不再看 ``last_attempt_at``。
"""
with _run_lock_guard:
state = _read_curator_state(user_id)
state["running"] = False
state["last_attempt_at"] = datetime.now(UTC).isoformat()
_write_curator_state(state, user_id)
def _get_curator_config(user_id: str | None = None):
"""Return the curator config object for ``user_id``.
@@ -135,6 +215,21 @@ def _get_curator_config(user_id: str | None = None):
return None
def _skill_evolution_enabled(user_id: str | None = None) -> bool:
"""返回技能进化总开关 ``skill_evolution.enabled``(已合并 per-user 覆盖)。
这是整套技能进化功能的总闸:关闭时 curator 完全不动作。任何读取异常都按
"关闭"处理(fail-safe)—— 宁可不跑,也不在配置不可读时偷偷运行。
"""
try:
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
skill_evo = load_skill_evolution_config_for_user(user_id)
return bool(getattr(skill_evo, "enabled", False))
except Exception:
return False
def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
"""Return skill names owned by ``user_id`` (used to filter transitions).
@@ -231,12 +326,39 @@ def _is_skill_published(skill_name: str) -> bool:
return False
def _list_published_skill_names() -> set[str]:
"""一次性返回所有 ``published=True`` 的技能名集合。
取代"每个技能调一次 ``_is_skill_published``"的逐条查库 —— curator 一轮里会遍历
所有候选技能,逐条查会产生 ≈2N 次独立 event loop + DB 往返。出错时返回空集
(fail-open:与 ``_is_skill_published`` 出错返回 False 语义一致,即 DB 不可用时
所有技能都视为未发布,回退到 curator 的 legacy 行为)。
"""
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.skills import make_skill_store
factory = get_session_factory()
if factory is None:
return set()
store = make_skill_store(factory)
async def _run() -> set[str]:
return await store.list_published_names()
return _run_async_blocking(_run())
except Exception:
logger.debug("_list_published_skill_names failed", exc_info=True)
return set()
def _eligible_for_curation(
name: str,
entry: dict,
*,
owned_set: set[str] | None,
referenced_set: set[str],
published_set: set[str],
) -> bool:
"""Three-condition gate for whether curator may touch a skill.
@@ -244,11 +366,15 @@ def _eligible_for_curation(
1. source == "agent" (caller is expected to have already filtered, but
we double-check here for safety)
2. NOT referenced by any agent's skills config
3. NOT published (queried via SkillStore)
3. NOT published
``owned_set`` is the set of skill names owned by the running user. When
None, the legacy "process every agent-created skill" fallback applies
for ownership only (the other two conditions still hold).
``published_set`` is the set of published skill names, pre-fetched once by
the caller via ``_list_published_skill_names()`` — avoids a per-skill DB
round-trip.
"""
if entry.get("source") != "agent":
return False
@@ -256,7 +382,7 @@ def _eligible_for_curation(
return False
if name in referenced_set:
return False
if _is_skill_published(name):
if name in published_set:
return False
return True
@@ -264,21 +390,52 @@ def _eligible_for_curation(
def should_run_now(user_id: str) -> bool:
"""Return True if the curator interval has elapsed for ``user_id`` and it
is not paused."""
curator_cfg = _get_curator_config()
if curator_cfg is None or not getattr(curator_cfg, "enabled", True):
# 总开关:技能进化整体关闭时,curator 一律不跑。
if not _skill_evolution_enabled(user_id):
return False
curator_cfg = _get_curator_config(user_id)
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
return False
state = _read_curator_state(user_id)
if state.get("paused"):
return False
# 运行锁:已有 curator 在跑(且锁未过期)时不再触发新的一轮。
if _run_lock_active(state, datetime.now(UTC)):
return False
last_run = state.get("last_run_at")
if not last_run:
return True
# 从未成功跑完过。若此前已尝试过(last_attempt_at 存在),按较短的重试间隔
# 退避 —— 避免首次运行持续失败时每个 tick(1h)都重试、每次烧一份 LLM 配额。
attempt_dt = _parse_state_time(state.get("last_attempt_at"))
if attempt_dt is None:
return True
return datetime.now(UTC) >= attempt_dt + timedelta(hours=_CURATOR_RETRY_HOURS)
interval_hours = getattr(curator_cfg, "interval_hours", 168)
try:
last_dt = datetime.fromisoformat(last_run)
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
except Exception:
last_dt = _parse_state_time(last_run)
if last_dt is None:
# 时间串损坏 —— 视同"从未跑过",允许重跑并重写正确时间戳,而不是像旧逻辑那样
# except 返回 False 导致该用户永久不再触发。
return True
return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours)
def manual_trigger_blocked_reason(user_id: str) -> str | None:
"""手动触发 curator 的预检。返回 None 表示可以跑;否则返回阻塞原因代码:
``evolution_disabled`` / ``curator_disabled`` / ``already_running``。
供 ``POST /api/curator/run`` 在派发后台线程**前**同步判定 —— 后台线程
(``_curator_thread_target``)本身也会再查一遍,这里只是为了让前端拿到准确反馈,
而不是无论开关状态如何都提示"已开始"。
"""
if not _skill_evolution_enabled(user_id):
return "evolution_disabled"
curator_cfg = _get_curator_config(user_id)
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
return "curator_disabled"
state = _read_curator_state(user_id)
if _run_lock_active(state, datetime.now(UTC)):
return "already_running"
return None
def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict:
@@ -297,12 +454,13 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
if now is None:
now = datetime.now(UTC)
curator_cfg = _get_curator_config()
curator_cfg = _get_curator_config(user_id)
stale_days = getattr(curator_cfg, "stale_after_days", 30) if curator_cfg else 30
archive_days = getattr(curator_cfg, "archive_after_days", 90) if curator_cfg else 90
owned = _list_user_owned_skill_names(user_id)
referenced = _collect_referenced_skill_names()
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
raw_entries = agent_created_entries()
skipped_referenced = 0
@@ -314,7 +472,7 @@ def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> di
if name in referenced:
skipped_referenced += 1
continue
if _is_skill_published(name):
if name in published:
skipped_published += 1
continue
eligible[name] = entry
@@ -377,9 +535,10 @@ def _render_candidate_list(user_id: str) -> str:
owned = _list_user_owned_skill_names(user_id)
referenced = _collect_referenced_skill_names()
published = _list_published_skill_names() # 一次性查出,避免逐技能查库
entries: dict[str, dict] = {}
for k, v in all_entries().items():
if not _eligible_for_curation(k, v, owned_set=owned, referenced_set=referenced):
if not _eligible_for_curation(k, v, owned_set=owned, referenced_set=referenced, published_set=published):
continue
if v.get("state") == STATE_ARCHIVED:
continue
@@ -590,8 +749,72 @@ def _make_skill_archive_tool(user_id: str):
return _skill_archive
def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
"""Run the curator consolidation pass as a React agent with skill tools bound."""
def _make_curator_skill_manage_tool():
"""curator 受限版 ``skill_manage``:在工具层屏蔽 delete 动作。
curator 做 umbrella 合并需要 create / patch / edit / write_file,但绝不能删整个
技能 —— prompt 硬规则也写明"归档(skill_archive)是允许的最大操作"。通用
skill_manage 的 delete 走 ``_assert_agent_may_delete``,**绕过了** skill_archive
对"已发布 / 被 agent 引用"技能的拒绝保护。只靠 prompt 文字约束不可靠,这里在
工具层直接拒掉 delete,curator 的 LLM 无论如何都删不掉技能。
"""
from langchain.tools import tool as lc_tool
from deerflow.tools.skill_manage_tool import _skill_manage_impl
@lc_tool("skill_manage")
def _curator_skill_manage(
action: str,
name: str,
content: str | None = None,
path: str | None = None,
find: str | None = None,
replace: str | None = None,
expected_count: int | None = None,
) -> str:
"""管理 skills/custom/ 下的自定义技能(curator 受限版)。
支持 action:create / patch / edit / write_file / remove_file / list / view。
**不支持 delete** —— curator 不得删除技能;如需下架请改用 skill_archive。
Args:
action: 操作类型(见上;delete 会被拒绝)。
name: 连字符命名的技能名。
content: create / edit / write_file 的新文件内容。
path: write_file / remove_file 的支持文件路径。
find: patch 要替换的原文本。
replace: patch 的替换文本。
expected_count: patch 的预期替换次数(可选)。
"""
if action == "delete":
return (
"Refused: curator 不允许删除技能。归档是允许的最大操作 —— "
"请改用 skill_archive(name, reason) 软下架(文件保留,用户可在 Web UI 恢复)。"
)
# curator 不绑定到具体会话线程,runtime 传 None(_get_thread_id 兼容 None)。
return _run_async_blocking(
_skill_manage_impl(
runtime=None,
action=action,
name=name,
content=content,
path=path,
find=find,
replace=replace,
expected_count=expected_count,
)
)
return _curator_skill_manage
def _run_llm_review(candidate_list: str, *, user_id: str) -> tuple[str, list[dict]]:
"""Run the curator consolidation pass as a React agent with skill tools bound.
返回 ``(llm_text, tool_actions)``:
- ``llm_text`` —— LLM 最终回复文字(它"自述"做了什么);
- ``tool_actions`` —— 从消息流提取的**真实**工具调用记录(它"实际"做了什么)。
"""
from deerflow.skills import curator_log
try:
@@ -601,10 +824,14 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
from deerflow.models import create_chat_model
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
from deerflow.tools.skill_manage_tool import skill_manage_tool
model = create_chat_model()
tools = [skill_list_tool, skill_view_tool, skill_manage_tool, _make_skill_archive_tool(user_id)]
curator_cfg = _get_curator_config(user_id)
# curator 评审用的模型:per-user 可配 curator.model_name(对应 config.yaml
# models[].name);为空则用主模型。
model_name = getattr(curator_cfg, "model_name", None) if curator_cfg else None
model = create_chat_model(name=model_name)
# 用 curator 受限版 skill_manage(屏蔽 delete),不用通用版 —— 见 _make_curator_skill_manage_tool。
tools = [skill_list_tool, skill_view_tool, _make_curator_skill_manage_tool(), _make_skill_archive_tool(user_id)]
# Reuse the lead-agent error middleware: when the LLM mis-calls a tool
# (wrong action / missing required args), the exception is converted
# into a ToolMessage(status="error") so the LLM can read the error and
@@ -613,30 +840,58 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
# middleware kwarg — use LangChain 1.x's ``create_agent`` which does.
agent = create_agent(model=model, tools=tools, middleware=[ToolErrorHandlingMiddleware()])
curator_log.record("info", "LLM 评审:开始调用 React Agent", user_id=user_id)
timeout_seconds = getattr(curator_cfg, "review_timeout_seconds", 600) if curator_cfg else 600
curator_log.record(
"info",
f"LLM 评审:开始调用 React Agent(模型 {model_name or '主模型'},整体超时 {timeout_seconds}s)",
user_id=user_id,
)
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
# 给整个 React Agent 评审套一个总超时:agent.invoke 内部可能多轮调 LLM,模型层的
# 单次 request_timeout 兜不住整体。超时后本 curator 线程能正常收尾、释放运行锁,
# 而不是无限期挂起。注意:Python 无法强杀线程 —— 超时后那个 agent.invoke 线程会
# 成为孤儿(最终被模型层 request_timeout 或自然返回所终结),但不再拖住 curator。
# copy_context():把 current_user 等 ContextVar 传进工作线程,否则工具调用会把
# user_id 误解析为 DEFAULT_USER_ID。
ctx = contextvars.copy_context()
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="curator-llm")
future = pool.submit(ctx.run, agent.invoke, {"messages": [HumanMessage(content=prompt)]})
try:
result = future.result(timeout=timeout_seconds)
except concurrent.futures.TimeoutError:
curator_log.record(
"warning",
f"LLM 评审超时(超过 {timeout_seconds}s),已中止本次评审",
user_id=user_id,
)
logger.warning("Curator[%s] LLM review timed out after %ss", user_id, timeout_seconds)
return "", []
finally:
# wait=False:超时时不阻塞等待挂死的 invoke 线程。
pool.shutdown(wait=False)
messages = result.get("messages", [])
tool_actions = _extract_tool_actions(messages)
for msg in reversed(messages):
if not getattr(msg, "tool_calls", None):
content = getattr(msg, "content", "")
if isinstance(content, str) and content.strip():
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
return content
return content, tool_actions
if isinstance(content, list):
text = " ".join(
block.get("text", "") if isinstance(block, dict) else str(block)
for block in content
).strip()
curator_log.record("info", "LLM 评审:Agent 已返回最终结论", user_id=user_id)
return text
return text, tool_actions
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
return ""
return "", tool_actions
except Exception as e:
logger.warning("Curator LLM review failed: %s", e)
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
return ""
return "", []
def _parse_structured_summary(text: str) -> dict:
@@ -653,6 +908,44 @@ def _parse_structured_summary(text: str) -> dict:
return {"consolidations": [], "prunings": []}
def _extract_tool_actions(messages: list) -> list[dict]:
"""从 agent 消息流里提取**真实发生的**工具调用 + 结果。
与 _parse_structured_summary(解析 LLM 自述的 YAML)互补:这里记录的是 curator
实际调了哪些工具、入参是什么、成功还是失败 —— 用于审计"嘴上说的"与"实际做的"
是否一致。每条形如 ``{tool, args, status, result}``。
"""
# 先按 tool_call_id 收集每次调用的工具名与入参(来自 AIMessage.tool_calls)
calls: dict[str, dict] = {}
for msg in messages:
for tc in getattr(msg, "tool_calls", None) or []:
tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
if not tc_id:
continue
calls[tc_id] = {
"tool": tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None),
"args": (tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", None)) or {},
}
# 再按 ToolMessage(带 tool_call_id)收集真实结果
actions: list[dict] = []
for msg in messages:
tc_id = getattr(msg, "tool_call_id", None)
if not tc_id:
continue
call = calls.get(tc_id, {})
raw = getattr(msg, "content", "")
result = raw if isinstance(raw, str) else str(raw)
actions.append(
{
"tool": call.get("tool") or getattr(msg, "name", None),
"args": call.get("args", {}),
"status": getattr(msg, "status", None) or "success",
"result": result[:500],
}
)
return actions
def run_curator_review(user_id: str) -> dict:
"""Main curator run for ``user_id``: transitions → LLM consolidation → state.
@@ -684,16 +977,23 @@ def run_curator_review(user_id: str) -> dict:
"info", f"进入 LLM 评审的候选技能:共 {candidate_line_count} 个", user_id=user_id
)
llm_text = _run_llm_review(candidate_list, user_id=user_id)
suggestions = _parse_structured_summary(llm_text)
curator_log.record(
"info",
(
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
f"建议删减 {len(suggestions['prunings'])} 项"
),
user_id=user_id,
)
if candidate_line_count == 0:
# 没有候选技能 —— 跳过 LLM 评审,省掉无意义的模型调用。
# 年龄流转(上面的 apply_automatic_transitions)已完成,且不依赖 LLM。
curator_log.record("info", "无候选技能,跳过 LLM 评审", user_id=user_id)
suggestions = {"consolidations": [], "prunings": []}
tool_actions: list[dict] = []
else:
llm_text, tool_actions = _run_llm_review(candidate_list, user_id=user_id)
suggestions = _parse_structured_summary(llm_text)
curator_log.record(
"info",
(
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
f"建议删减 {len(suggestions['prunings'])} 项"
),
user_id=user_id,
)
# Surface per-item reasoning so operators can audit *why* each action
# was taken, not just the count.
for item in suggestions.get("consolidations", []) or []:
@@ -718,15 +1018,26 @@ def run_curator_review(user_id: str) -> dict:
user_id=user_id,
)
# 上面是 LLM"自述"的建议;下面记录它"实际"调用了哪些工具,供对照审计。
curator_log.record("info", f"LLM 实际调用工具 {len(tool_actions)} 次", user_id=user_id)
for act in tool_actions:
target = act.get("args", {}).get("name", "")
curator_log.record(
"info",
f" 实际调用 [{act.get('tool')}]{f' [{target}]' if target else ''} → {act.get('status')}",
user_id=user_id,
)
state = _read_curator_state(user_id)
state["last_run_at"] = now.isoformat()
state["run_count"] = state.get("run_count", 0) + 1
state["last_run_summary"] = (
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
f"归档 {transition_counters['archived']} 个;"
f"LLM:建议合并 {len(suggestions['consolidations'])} 项"
f"LLM:建议合并 {len(suggestions['consolidations'])} 项、实际工具调用 {len(tool_actions)} 次"
)
state["last_consolidation_suggestions"] = suggestions
state["last_consolidation_suggestions"] = suggestions # LLM 自述
state["last_actual_actions"] = tool_actions # 真实工具调用记录
_write_curator_state(state, user_id)
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
@@ -755,6 +1066,23 @@ def _curator_thread_target(user_id: str) -> None:
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.skills import curator_log
# 总开关 + curator 子开关:任一关闭则直接跳过。手动触发 /api/curator/run 也经由
# 本函数,因此手动触发同样受总开关与 curator.enabled 约束。
if not _skill_evolution_enabled(user_id):
logger.info("Curator[%s]: 技能进化总开关关闭,跳过", user_id)
return
curator_cfg = _get_curator_config(user_id)
if curator_cfg is None or not getattr(curator_cfg, "enabled", False):
logger.info("Curator[%s]: curator 未启用,跳过", user_id)
return
# 运行锁:防止同一用户的 curator 并发(定时器 vs 手动触发、连点手动触发)。抢不到
# 锁说明已有一轮在跑,直接跳过本次触发。
if not _try_acquire_run_lock(user_id, datetime.now(UTC)):
logger.info("Curator[%s]: 已有 curator 正在运行,跳过本次触发", user_id)
curator_log.record("info", "已有 curator 正在运行,跳过本次触发", user_id=user_id)
return
# Bind the per-thread user context for the entire curator run so every
# downstream tool / repository call resolves the correct user_id.
token = set_current_user(_CuratorUser(user_id))
@@ -765,13 +1093,16 @@ def _curator_thread_target(user_id: str) -> None:
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
finally:
reset_current_user(token)
# 无论成功或异常,必定释放运行锁。
_release_run_lock(user_id)
async def maybe_run_curator() -> None:
"""Async startup entry — kicks off a thread per user whose interval elapsed.
"""周期调度入口 —— 给每个 interval 已到期的用户起一个后台策展线程。
Skips users with no DB-tracked entries (a fresh deployment) and never
blocks lifespan startup on failure.
由 app 的 curator 调度器周期性调用(也用于手动触发)。无候选技能的用户,其
LLM 评审会在 run_curator_review 内被跳过 —— 只做不耗费 LLM 的年龄流转。
任何失败都不会阻断调用方。
"""
try:
from sqlalchemy import select
@@ -789,20 +1120,42 @@ async def maybe_run_curator() -> None:
logger.exception("Curator: failed to enumerate users at startup")
return
due_users: list[str] = []
for user_id in user_ids:
try:
if not should_run_now(user_id):
continue
logger.info("Curator[%s]: interval elapsed, spawning background thread", user_id)
thread = threading.Thread(
target=_curator_thread_target,
args=(user_id,),
daemon=True,
name=f"skill-curator-{user_id}",
)
thread.start()
if should_run_now(user_id):
due_users.append(user_id)
except Exception:
logger.exception("Curator: failed to spawn for user %s", user_id)
logger.exception("Curator: should_run_now check failed for user %s", user_id)
_dispatch_curator_runs(due_users)
def _dispatch_curator_runs(due_users: list[str]) -> None:
"""用线程池(并发上限 ``_CURATOR_MAX_CONCURRENCY``)派发到期用户的 curator 执行。
到期用户排队消费,而非一次性全部起线程齐调 LLM —— 防首次部署所有用户同时到期时
的惊群。``shutdown(wait=False)``:提交后立即返回,线程池在所有任务跑完后自行回收。
抽成独立函数便于对并发上限做单元测试。
"""
if not due_users:
return
logger.info(
"Curator: %d 个用户到期,以并发上限 %d 派发执行",
len(due_users),
_CURATOR_MAX_CONCURRENCY,
)
executor = concurrent.futures.ThreadPoolExecutor(
max_workers=_CURATOR_MAX_CONCURRENCY,
thread_name_prefix="skill-curator",
)
try:
for user_id in due_users:
executor.submit(_curator_thread_target, user_id)
except Exception:
logger.exception("Curator: failed to submit curator tasks")
finally:
executor.shutdown(wait=False)
def get_curator_status(user_id: str) -> dict:
@@ -819,7 +1172,7 @@ def get_curator_status(user_id: str) -> dict:
s = entry.get("state", STATE_ACTIVE)
counts[s] = counts.get(s, 0) + 1
cfg = _get_curator_config()
cfg = _get_curator_config(user_id)
return {
"enabled": cfg is not None and getattr(cfg, "enabled", True),
"run_count": state.get("run_count", 0),
@@ -832,4 +1185,8 @@ def get_curator_status(user_id: str) -> dict:
"stale": counts[STATE_STALE],
"archived": counts[STATE_ARCHIVED],
},
# 上次运行的两份记录:LLM 自述的建议 + 实际发生的工具调用,供前端对照展示。
"last_consolidation_suggestions": state.get("last_consolidation_suggestions")
or {"consolidations": [], "prunings": []},
"last_actual_actions": state.get("last_actual_actions") or [],
}
@@ -50,8 +50,10 @@ async def scan_skill_content(content: str, *, executable: bool = False, location
try:
config = app_config or get_app_config()
model_name = config.skill_evolution.moderation_model_name
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config) if model_name else create_chat_model(thinking_enabled=False, app_config=config)
# 安全审核复用 curator 的评审模型配置(skill_evolution.curator.model_name):
# 二者用同一个模型。未配置时 model_name 为 None,create_chat_model 回退主模型。
model_name = config.skill_evolution.curator.model_name
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[
{"role": "system", "content": rubric},
@@ -6,6 +6,7 @@ import json
import logging
import os
import tempfile
import threading
from datetime import UTC, datetime
from pathlib import Path
@@ -13,6 +14,11 @@ logger = logging.getLogger(__name__)
_USAGE_FILENAME = ".usage.json"
# 串行化对 .usage.json 的"读-改-写",避免并发(尤其 curator 多线程)丢更新。
# .usage.json 是全局单文件;此锁覆盖进程内并发。leader 选举已使 curator 只在
# 单进程跑,故进程内锁即可保证状态流转不丢。
_usage_lock = threading.Lock()
STATE_ACTIVE = "active"
STATE_STALE = "stale"
STATE_ARCHIVED = "archived"
@@ -75,13 +81,20 @@ def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _update_entry(name: str, updates: dict) -> None:
def _update_entry(name: str, updates) -> None:
"""更新一个 usage 条目。``updates`` 可以是 dict,或 ``(entry) -> dict`` 回调。
用回调形式可让"读当前值 -> 算增量"也落在锁内(例如计数自增),
避免并发下的 lost update。
"""
try:
data = _read_all()
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
entry.update(updates)
data[name] = entry
_write_all(data)
with _usage_lock:
data = _read_all()
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
resolved = updates(entry) if callable(updates) else updates
entry.update(resolved)
data[name] = entry
_write_all(data)
except Exception:
logger.debug("Failed to update usage entry for %r", name, exc_info=True)
@@ -103,18 +116,16 @@ def agent_created_entries() -> dict:
def bump_view(name: str) -> None:
entry = get_entry(name)
_update_entry(name, {"view_count": entry.get("view_count", 0) + 1, "last_viewed_at": _now_iso()})
# 用回调:读当前计数 + 自增在同一把锁内完成,避免并发丢计数。
_update_entry(name, lambda e: {"view_count": e.get("view_count", 0) + 1, "last_viewed_at": _now_iso()})
def bump_use(name: str) -> None:
entry = get_entry(name)
_update_entry(name, {"use_count": entry.get("use_count", 0) + 1, "last_used_at": _now_iso()})
_update_entry(name, lambda e: {"use_count": e.get("use_count", 0) + 1, "last_used_at": _now_iso()})
def bump_patch(name: str) -> None:
entry = get_entry(name)
_update_entry(name, {"patch_count": entry.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
_update_entry(name, lambda e: {"patch_count": e.get("patch_count", 0) + 1, "last_patched_at": _now_iso()})
def set_state(name: str, state: str) -> None:
@@ -132,9 +143,10 @@ def set_pinned(name: str, pinned: bool) -> None:
def forget(name: str) -> None:
try:
data = _read_all()
data.pop(name, None)
_write_all(data)
with _usage_lock:
data = _read_all()
data.pop(name, None)
_write_all(data)
except Exception:
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
@@ -59,6 +59,66 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
return None
def _resolve_effective_user_id() -> str | None:
"""当前请求/线程绑定的用户 id;解析失败时返回 None。"""
try:
from deerflow.runtime.user_context import get_effective_user_id
return get_effective_user_id()
except Exception:
logger.debug("Failed to resolve effective user id for skill_list", exc_info=True)
return None
def _run_coro_blocking(coro):
"""在同步工具里把一个协程跑到完成。
``skill_list`` 是同步 ``@tool``:LangChain 通常在线程池里执行它(无运行中的
事件循环),但 curator 评审会在已有事件循环的线程里调用它。两种情况都要能
拿到 ``SkillStore`` 的异步查询结果 —— 检测到运行中的事件循环就转交独立线程
执行 ``asyncio.run``,否则直接跑。
"""
import asyncio
try:
asyncio.get_running_loop()
in_loop = True
except RuntimeError:
in_loop = False
if in_loop:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
return asyncio.run(coro)
def _load_owned_skill_names(user_id: str | None) -> set[str] | None:
"""返回 ``owner_user_id == user_id`` 的自定义技能名集合。
用于默认 lead agent 的可见性过滤:只算"用户自己拥有的"自定义技能,不含
其他用户发布的。返回 ``None`` 表示 ``SkillStore`` 不可用(未配置数据库
等)—— 调用方应降级为"放行全部自定义技能",避免离线无库部署下技能凭空
消失。
"""
if not user_id:
return None
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.skills import make_skill_store
factory = get_session_factory()
if factory is None:
return None
store = make_skill_store(factory)
records = _run_coro_blocking(store.list_visible(user_id))
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
except Exception:
logger.debug("Failed to load owned skill names for skill_list filter", exc_info=True)
return None
@tool("skill_view")
def skill_view_tool(name: str) -> str:
"""Read the full SKILL.md content of a skill by name.
@@ -106,28 +166,47 @@ def skill_list_tool(runtime: ToolRuntime) -> str:
under the ``skills:`` field — the agent only sees what it was wired to
use.
For the **default lead agent** (no agent_id in runtime), there is no
per-agent whitelist and every visible skill is returned.
For the **default lead agent** (no agent_id in runtime), the result is
filtered to non-archived skills that are either built-in (public) or
owned by the current user. Other users' published custom skills and
archived skills are excluded.
Returns a JSON array with fields: name, description, category, enabled,
state, use_count, view_count, patch_count, last_activity_at, pinned,
source.
"""
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.usage import all_entries, latest_activity_at
from deerflow.skills.types import SkillCategory
from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at
agent_id = _resolve_active_agent_id(runtime)
allowed = _load_agent_skill_whitelist(agent_id)
# 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于
# 当前用户"的技能。owned 为 None 表示 SkillStore 不可用,降级放行全部自定义
# 技能(见 _load_owned_skill_names)。
owned: set[str] | None = None
if allowed is None:
owned = _load_owned_skill_names(_resolve_effective_user_id())
try:
storage = get_or_new_skill_storage()
skills = storage.load_skills(enabled_only=False)
usage = all_entries()
result = []
for skill in skills:
if allowed is not None and skill.name not in allowed:
continue
entry = usage.get(skill.name, {})
if allowed is not None:
# 自定义 agent:严格按 config.yaml 的 skills 白名单过滤。
if skill.name not in allowed:
continue
else:
# 默认 agent:跳过已归档技能。
if entry.get("state") == STATE_ARCHIVED:
continue
# 自定义技能仅当属于当前用户时可见;内置 public 技能始终可见。
if skill.category == SkillCategory.CUSTOM and owned is not None and skill.name not in owned:
continue
result.append({
"name": skill.name,
"description": skill.description,
@@ -84,12 +84,26 @@ def get_available_tools(
builtin_tools = BUILTIN_TOOLS.copy()
if not is_scheduled_run:
builtin_tools.append(scheduled_task_tool)
skill_evolution_config = getattr(config, "skill_evolution", None)
# skill_list / skill_view 是只读工具(发现、查看技能),与"自我进化"写能力无关,
# 始终提供给 agent。
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
builtin_tools.extend([skill_list_tool, skill_view_tool])
# skill_manage 能创建/修改/删除技能,属于"自我进化"写能力,按 skill_evolution.enabled
# 门控。该开关用户可在 per-user 配置里覆盖,所以按"生效配置(含 per-user 覆盖)"判断 ——
# Gateway 每次 run 重建 agent,用户改了配置下次 run 即生效;解析失败退回全局 config.yaml。
try:
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
from deerflow.runtime.user_context import get_effective_user_id
skill_evolution_config = load_skill_evolution_config_for_user(get_effective_user_id())
except Exception: # noqa: BLE001 - 解析失败不能阻断 agent 构建
skill_evolution_config = getattr(config, "skill_evolution", None)
if getattr(skill_evolution_config, "enabled", False):
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
from deerflow.tools.skill_manage_tool import skill_manage_tool
builtin_tools.extend([skill_list_tool, skill_view_tool, skill_manage_tool])
builtin_tools.append(skill_manage_tool)
# Add subagent tools only if enabled via runtime parameter
if subagent_enabled:
@@ -25,12 +25,12 @@ def test_load_overrides_returns_none_when_missing(overrides_path):
def test_save_and_load_overrides_roundtrip(overrides_path):
payload = {
"enabled": True,
"moderation_model_name": "moderation-mini",
"curator": {
"enabled": True,
"interval_hours": 12,
"stale_after_days": 7,
"archive_after_days": 30,
"model_name": "curator-mini",
},
}
written = skill_evolution_runtime.save_overrides(payload)
@@ -501,7 +501,8 @@ def test_curator_skips_published_skill(isolated_paths, monkeypatch) -> None:
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"public-tool"})
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
monkeypatch.setattr(curator, "_is_skill_published", lambda name: name == "public-tool")
# curator 现在用批量接口一次性查 published 集合,而非逐技能 _is_skill_published。
monkeypatch.setattr(curator, "_list_published_skill_names", lambda: {"public-tool"})
_isolated_skills_root(isolated_paths, monkeypatch)
_seed_old_agent_skill("public-tool")
+2692 -2692
View File
File diff suppressed because it is too large. Load diff