# Conflicts:
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
This commit is contained in:
吉鑫升 committed 2026-05-15 11:21:48 +08:00
commit aeba15eb16
88 files changed
+13824 -3572

No files matched your search

+10
View File
@@ -65,6 +65,16 @@ offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+950
View File
@@ -0,0 +1,950 @@
# Open Canvas 模式需求拆解文档
## 1. 文档目标
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
- 前端:基于 `React 19 + Vite + react-router-dom`
- 后端:基于 `Python + FastAPI + LangGraph`
- 模型层:支持云模型与本地模型自由配置
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
---
## 2. 最终目标
### 2.1 用户视角目标
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
- 左侧是聊天区,右侧是 Canvas 编辑区
- 可以从空白文本或代码模板快速开始
- 可以通过聊天让 AI 生成一份结构化 artifact
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
- artifact 支持版本历史切换
- artifact 支持文本和代码两种主要类型
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
- 支持启用 Web 搜索增强
- 支持切换模型
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
### 2.2 工程视角目标
新增的 Canvas 模式需要满足以下工程原则:
- 不破坏现有 DeerFlow 工作区
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
- 可逐阶段上线
- 前后端状态协议清晰
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
---
## 3. 范围定义
### 3.1 本次需求包含
- 前端新增 `open-canvas` 专用目录
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
- 前端新增 Canvas 页面、上下文、组件、状态管理
- 后端新增 Canvas 专用线程状态字段
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
- 模型配置统一为 DeerFlow 后端配置驱动
- 支持本地大模型配置与模型列表动态下发
- 支持结构化 artifact 的保存、读取、更新、版本切换
### 3.2 本次需求不包含
- 一次性复刻 open-canvas 全部 UI 细节
- 完整照搬 Supabase 鉴权体系
- 直接复用 open-canvas 的 Next.js API Route
- 直接运行 open-canvas 的 LangGraph.js 后端
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
### 3.3 迁移原则
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
- 前端尽量复用其交互设计和数据结构
- 后端以 Python 实现同等能力
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
---
## 4. 总体设计思路
### 4.1 前端设计思路
在现有 `frontend-web/src` 下新增一个专用目录,例如:
```text
src/
open-canvas/
pages/
routes/
components/
contexts/
hooks/
types/
api/
utils/
constants/
```
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
建议路由形式:
- `/canvas`
- `/canvas/:threadId`
也可以挂在现有页面布局下,例如:
- `/workspace/canvas`
- `/workspace/canvas/:threadId`
推荐优先采用独立一级功能路由:
- 逻辑更清晰
- 状态隔离更容易
- 不容易污染现有 `workspace` 功能
### 4.2 后端设计思路
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
#### 方案 A:新增独立 Canvas Assistant
- 新增 `canvas_agent`
- 使用单独的 LangGraph 状态定义
- 前端调用时通过 `assistant_id=canvas_agent`
优点:
- 与现有 `lead_agent` 隔离
- 风险更低
- 更容易对齐 open-canvas 的图结构
缺点:
- 会产生一套新的 Agent 图维护成本
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
- Agent 根据模式进入 Canvas 路径
优点:
- 统一入口
缺点:
- 会显著增加现有 Agent 复杂度
- 容易影响现有聊天和文件产物模式
推荐先做方案 A。
### 4.3 模型配置设计思路
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
- `name`
- `display_name`
- `provider`
- `supports_thinking`
- `supports_reasoning_effort`
- `supports_vision`
- `supports_tool_calling`
- `supports_artifact_editing`
- `supports_code_editing`
- `temperature_range`
- `max_tokens_range`
- `base_url_source` 或 provider 标识
- `is_local`
---
## 5. 与当前项目的关键差异
### 5.1 当前 DeerFlow 现状
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
- `artifacts: string[]`
这更接近“生成文件产物”的工作模式。
前端当前 artifact 面板也主要是:
- 文件列表
- 文件详情
- 文件下载/预览
### 5.2 open-canvas 的关键能力
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
- 一个 conversation 对应一个主 artifact
- artifact 具有类型、标题、内容、版本
- 用户可以选中部分内容让 AI 改写
- 每次改写都会生成新版本
### 5.3 需要新增的核心能力
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
- 当前 artifact
- 当前版本索引
- 历史版本列表
- 选区信息
- 当前编辑模式
- 快速动作状态
- Web 搜索结果状态
---
## 6. 分阶段实施方案
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
---
## 7. 阶段一:前端路由与目录骨架
### 7.1 目标
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
### 7.2 需求
- 新增 `src/open-canvas/` 目录
- 新增 Canvas 页面路由
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
- 提供 Canvas 首页和 Canvas Thread 页
- 支持空状态展示
### 7.3 建议目录
```text
src/open-canvas/
api/
threads.ts
runs.ts
models.ts
components/
layout/
chat/
canvas/
artifacts/
quick-actions/
selectors/
contexts/
CanvasThreadContext.tsx
CanvasGraphContext.tsx
CanvasUIContext.tsx
hooks/
useCanvasThread.ts
useCanvasStream.ts
useCanvasArtifact.ts
pages/
CanvasHomePage.tsx
CanvasThreadPage.tsx
routes/
OpenCanvasRoutes.tsx
types/
artifact.ts
thread.ts
model.ts
utils/
artifact.ts
selection.ts
stream.ts
constants/
index.ts
```
### 7.4 路由建议
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
建议形式:
```text
/canvas
/canvas/:threadId
```
### 7.5 验收标准
- 能访问 Canvas 首页
- 能访问指定 thread 的 Canvas 页面
- 不影响现有 `workspace/*` 与 `strategy/*`
- 页面有独立布局骨架
---
## 8. 阶段二:前端 Canvas 布局与交互容器
### 8.1 目标
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
### 8.2 需求
- 左侧聊天区
- 右侧画布区
- 支持面板拖拽或折叠
- 初始空状态
- 支持“快速开始”:
- 新建空白文本 artifact
- 新建代码 artifact
### 8.3 组件拆分建议
- `CanvasShell`
- `CanvasSidebar`
- `CanvasChatPanel`
- `CanvasArtifactPanel`
- `CanvasEmptyState`
- `QuickStartActions`
### 8.4 快速开始能力
用户进入空线程时可以选择:
- 开始写文章
- 开始写代码
- 从模板开始
首版模板可只做最小集:
- 空白文档
- HTML 模板
- React 组件模板
- Python 脚本模板
### 8.5 验收标准
- 页面可稳定展示双栏布局
- 快速开始能创建前端本地 artifact 状态
- 代码和文本可分别进入不同渲染器
---
## 9. 阶段三:结构化 Artifact 数据模型
### 9.1 目标
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
### 9.2 核心要求
需要支持以下能力:
- 文本 artifact
- 代码 artifact
- 标题
- 当前版本索引
- 版本历史
- 当前选中版本切换
- 后续支持局部高亮编辑
### 9.3 建议数据结构
```ts
type CanvasArtifact = {
currentIndex: number;
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
};
type CanvasArtifactText = {
index: number;
type: "text";
title: string;
fullMarkdown: string;
};
type CanvasArtifactCode = {
index: number;
type: "code";
title: string;
language: string;
code: string;
};
```
### 9.4 线程状态建议新增字段
前端与后端需共同支持:
- `artifact`
- `highlighted_text`
- `highlighted_code`
- `canvas_mode`
- `web_search_enabled`
- `web_search_results`
- `quick_action_id`
### 9.5 与现有 `artifacts: string[]` 的关系
建议保留现有 `artifacts: string[]` 不动。
新增字段:
- `artifact` 用于 Canvas 主文档
- `artifacts` 继续用于文件产物
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
### 9.6 验收标准
- 前端类型定义完成
- 后端状态字段设计完成
- 确认与现有线程状态兼容
---
## 10. 阶段四:后端线程状态与 API 扩展
### 10.1 目标
让后端线程状态可以安全存储和返回 Canvas Artifact。
### 10.2 需求
- 扩展 Python `ThreadState`
- 扩展状态序列化逻辑
- 确保 `/api/threads/*` 返回结构化 `artifact`
- 确保 LangGraph 流中可带上 `artifact` 更新
### 10.3 后端改造点
建议关注以下模块:
- `packages/harness/deerflow/agents/thread_state.py`
- 线程 state serializer
- `app/gateway/routers/threads.py`
- 流事件适配层
### 10.4 注意事项
- 旧线程没有 `artifact` 字段时必须兼容
- 状态更新不能破坏现有消息结构
- 必须确保 JSON 可序列化
### 10.5 验收标准
- 新建线程可写入 `artifact`
- 读取线程时可返回 `artifact`
- 不影响现有线程读取
- 前端能从线程 state 中恢复 Canvas 内容
---
## 11. 阶段五:Canvas 专用 Agent 图
### 11.1 目标
实现与 open-canvas 类似的 AI 路由能力。
### 11.2 推荐方案
新增 `canvas_agent`,单独维护图逻辑。
### 11.3 节点拆分建议
首版建议至少拆成这些节点:
- `route_user_intent`
- `reply_general_input`
- `generate_artifact`
- `rewrite_artifact`
- `update_highlighted_text`
- `update_highlighted_code`
- `generate_followup`
二期可加:
- `rewrite_artifact_theme`
- `rewrite_code_theme`
- `change_length`
- `change_reading_level`
- `translate_artifact`
- `add_comments`
- `add_logs`
- `fix_bugs`
- `port_language`
- `web_search_enrich`
### 11.4 路由逻辑要求
系统需要能判断:
- 用户是在闲聊
- 用户要新生成 artifact
- 用户要整体重写当前 artifact
- 用户要局部修改当前 artifact
- 用户要执行某个快捷动作
### 11.5 状态输入要求
节点需要读取:
- 当前消息历史
- 当前 artifact
- 当前选区
- 当前模型配置
- Web 搜索开关
### 11.6 输出要求
节点输出必须能更新:
- `messages`
- `artifact`
- `web_search_results`
- `followup`
### 11.7 验收标准
- 能根据输入创建新 artifact
- 能根据输入整体改写 artifact
- 能保留版本历史
- 能在改写后返回自然语言 followup
---
## 12. 阶段六:前端流式同步与版本管理
### 12.1 目标
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
### 12.2 需求
- 复用现有 LangGraph stream client
- 在 Canvas 模式中解析 `artifact` 更新
- 支持流式文本更新
- 支持版本切换
- 支持断线后恢复线程状态
### 12.3 前端状态层建议
- `CanvasThreadContext`:线程基础信息
- `CanvasGraphContext`:消息、artifact、streaming 状态
- `CanvasUIContext`:面板、编辑态、选中态
### 12.4 关键能力
- 当前版本切换
- 新版本创建后自动定位到最新版本
- 手动编辑内容后保存
- 流结束后线程状态持久化
### 12.5 验收标准
- 发送消息时聊天和画布可同步更新
- 版本数会增加
- 切换历史版本稳定
- 刷新页面后线程状态能恢复
---
## 13. 阶段七:局部选区改写
### 13.1 目标
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
### 13.2 文本选区需求
- 支持在 Markdown 文本中选择一段文本
- 记录选区位置与原始文本
- 用户发出修改请求后,仅重写该选区
- 将选区结果合并为新版本 artifact
### 13.3 代码选区需求
- 支持在代码编辑器中选择一段代码
- 记录 start/end index 或行列信息
- AI 只返回替换片段
- 前端或后端负责将新片段拼接回完整代码
### 13.4 实现建议
首版建议:
- 文本:前端记录纯文本字符位置
- 代码:使用 CodeMirror selection 信息
不建议首版就做复杂块级富文本局部编辑。
### 13.5 风险
- Markdown 渲染态与源码态位置映射复杂
- 富文本块级位置难维护
- 流式更新中选区同步容易错位
### 13.6 首版折中方案
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
### 13.7 验收标准
- 文本局部改写成功
- 代码局部改写成功
- 原内容未改动部分保持不变
- 新版本正确入历史
---
## 14. 阶段八:快捷动作系统
### 14.1 目标
实现 open-canvas 风格的快捷动作体系。
### 14.2 首批推荐动作
文本类:
- 扩写
- 精简
- 改正式语气
- 改口语语气
- 改阅读难度
- 翻译
代码类:
- 加注释
- 加日志
- 修复 bug
- 优化代码
- 转换语言
### 14.3 设计方式
快捷动作不要直接写死在 UI,建议配置化:
- `id`
- `label`
- `type`
- `applicable_to`
- `prompt_template`
### 14.4 后端设计
快捷动作可通过:
- 前端传 `quick_action_id`
- 后端路由节点根据动作 id 选择 prompt
### 14.5 验收标准
- 文本动作可执行
- 代码动作可执行
- 结果进入新版本
- 不同动作提示词可扩展
---
## 15. 阶段九:模型配置统一与本地模型支持
### 15.1 目标
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
### 15.2 原则
- 后端 `config.yaml` 为真源
- 前端只展示后端暴露的模型信息
- 不维护一份脱离后端的前端常量模型表
### 15.3 本地模型支持范围
建议优先支持两类:
- `Ollama`
- OpenAI-compatible 本地服务
- LM Studio
- vLLM
- LocalAI
- one-api 风格代理
### 15.4 后端配置建议
后端模型配置应至少支持:
- `name`
- `display_name`
- `use`
- `model`
- `api_key`
- `base_url`
- `supports_vision`
- `supports_thinking`
- `supports_tool_calling`
- `is_local`
- `canvas_capabilities`
### 15.5 Canvas 模式额外要求
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
- `supports_canvas_generation`
- `supports_canvas_rewrite`
- `supports_canvas_code_edit`
- `supports_streaming_text`
- `supports_streaming_tool_calls`
### 15.6 与 open-canvas 的关系
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
### 15.7 验收标准
- 前端模型选择器可显示后端返回模型
- 可切换本地模型
- Canvas 模式能用本地模型生成和改写
---
## 16. 阶段十:Web 搜索增强
### 16.1 目标
实现类似 open-canvas 的可选 Web 搜索增强。
### 16.2 需求
- UI 提供 Web 搜索开关
- 后端在需要时走搜索节点
- 搜索结果写入线程状态
- 结果可在画布旁侧栏展示
### 16.3 首版建议
只做:
- 开关
- 搜索结果列表
- 搜索结果用于生成 artifact
暂不做复杂网页抓取编辑链路。
### 16.4 验收标准
- 打开搜索后可返回搜索结果
- 搜索结果可参与 artifact 生成
- 结果展示不阻塞主画布
---
## 17. 阶段十一:持久化、导出与文件产物联动
### 17.1 目标
让 Canvas 文档与 DeerFlow 原有文件能力协同。
### 17.2 需求
- artifact 持久化在线程 state 中
- 可手动导出为文件
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
- Canvas artifact 与 `artifacts: string[]` 可双向关联
### 17.3 推荐能力
- 导出 Markdown
- 导出代码文件
- 另存为 artifact file
- 从 artifact file 回填到 Canvas
### 17.4 验收标准
- 当前版本可导出
- 导出文件可在现有文件面板查看
- 文件型 artifact 和 Canvas artifact 能共存
---
## 18. 推荐开发顺序
建议严格按以下顺序推进:
1. 路由和目录骨架
2. Canvas 页面布局
3. 前端 artifact 数据模型
4. 后端线程状态扩展
5. 新建 Canvas Agent 图
6. 前后端流式同步
7. 整体生成与整体改写
8. 局部选区改写
9. 快捷动作
10. 本地模型支持完善
11. Web 搜索增强
12. 导出与文件联动
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
---
## 19. 里程碑定义
### M1:页面可进入
- 路由接通
- 双栏布局完成
- 本地 mock artifact 可渲染
### M2:后端状态打通
- 线程可保存结构化 artifact
- 页面刷新后能恢复
### M3:AI 生成可用
- 可新建文本/代码 artifact
- 可整体改写
- 可产生版本历史
### M4:核心体验可用
- 选区改写可用
- 快捷动作可用
- 模型切换可用
### M5:增强能力完整
- 本地模型支持完善
- Web 搜索增强
- 导出和文件联动
---
## 20. 风险与难点
### 20.1 最大难点
- 结构化 artifact 状态从无到有
- 局部选区改写的精确性
- 流式状态与版本管理的一致性
- 不破坏现有 DeerFlow 文件型 artifact 体系
### 20.2 技术风险
- 线程 state JSON 结构过大
- 大模型对局部改写指令遵循不稳定
- 本地模型 tool calling 与流式能力差异大
- 文本渲染态与源码态位置映射复杂
### 20.3 规避建议
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
- 首版只支持文本和代码两种 artifact 类型
- 首版先做整体生成和整体改写,再做局部改写
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
---
## 21. 验收总清单
### 21.1 前端
- 有独立 Canvas 路由
- 有专用目录和模块边界
- 双栏布局可用
- 文本和代码两类 artifact 可渲染
- 历史版本可切换
- 选区改写可交互
- 快捷动作可触发
- 模型可切换
### 21.2 后端
- 线程状态支持 `artifact`
- 新增 `canvas_agent` 或等价模式
- 生成、重写、局部改写可用
- 流式返回稳定
- 模型列表接口支持 Canvas 能力元数据
### 21.3 本地模型
- 可配置 Ollama
- 可配置 OpenAI-compatible 本地服务
- 前端可见并可切换
- 至少能完成文本生成和整体改写
---
## 22. 推荐首版最小可交付范围
如果要控制风险,建议首版只交付以下内容:
- 新增 `canvas/*` 路由
- 双栏布局
- 结构化 `artifact`
- `canvas_agent`
- 文本/代码 artifact 新建
- 整体生成
- 整体改写
- 历史版本切换
- 后端模型动态加载
- Ollama 或本地 OpenAI-compatible 至少一种可用
先不要把以下内容放进首版强制目标:
- 富文本块级精确编辑
- 全量快捷动作
- 复杂 Web 搜索增强
- 多 artifact 并行管理
---
## 23. 后续实施建议
建议下一步基于本文档继续输出两份落地文档:
### 23.1 前端实施清单
逐文件列出:
- 要新建哪些文件
- 哪些组件可复用
- 哪些页面要接入路由
- 哪些状态要从现有 hooks 抽离
### 23.2 后端实施清单
逐模块列出:
- 要改哪些 Python 文件
- 新增哪些状态字段
- 新增哪些 Agent 节点
- 哪些 API 需要扩展
---
## 24. 结论
这个需求很大,但完全可拆。
正确做法不是“先抄 UI”,而是按下面的主线推进:
- 先建前端 Canvas 子系统
- 再建结构化 artifact 状态
- 再建 Canvas Agent 图
- 再补局部改写和快捷动作
- 最后统一本地模型能力
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
+19 -17
View File
@@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
+27 -27
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
@@ -37,27 +37,27 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };
+36 -26
View File
@@ -1,6 +1,7 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
@@ -10,28 +11,37 @@ function Collapsible({
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
const CollapsibleTrigger = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
>(({ className, ...props }, ref) => {
return (
<CollapsiblePrimitive.CollapsibleTrigger
ref={ref}
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
);
});
CollapsibleTrigger.displayName =
CollapsiblePrimitive.CollapsibleTrigger.displayName;
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ ...props }, ref) => {
return (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
data-slot="collapsible-content"
{...props}
/>
);
});
CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+74 -58
View File
@@ -12,11 +12,20 @@ function Dialog({
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
const DialogTrigger = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DialogPrimitive.Trigger
ref={ref}
data-slot="dialog-trigger"
{...props}
/>
);
});
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
function DialogPortal({
...props
@@ -24,61 +33,68 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
const DialogClose = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
>(({ ...props }, ref) => {
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
});
DialogClose.displayName = DialogPrimitive.Close.displayName;
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
});
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
+102 -89
View File
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
@@ -198,45 +205,51 @@ function DropdownMenuSub({
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
+37 -29
View File
@@ -11,34 +11,42 @@ function HoverCard({
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
const HoverCardTrigger = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<HoverCardPrimitive.Trigger
ref={ref}
data-slot="hover-card-trigger"
{...props}
/>
);
});
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
ref={ref}
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
});
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+90 -87
View File
@@ -24,68 +24,69 @@ function SelectValue({
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
@@ -100,32 +101,34 @@ function SelectLabel({
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
+71 -61
View File
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
const SheetTrigger = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
>(({ ...props }, ref) => {
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
});
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
const SheetClose = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
>(({ ...props }, ref) => {
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
});
SheetClose.displayName = SheetPrimitive.Close.displayName;
function SheetPortal({
...props
@@ -28,58 +34,62 @@ function SheetPortal({
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
});
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}
>(({ className, children, side = "right", ...props }, ref) => {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
});
SheetContent.displayName = SheetPrimitive.Content.displayName;
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
+169 -153
View File
@@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
function SidebarGroupContent({
className,
@@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
function SidebarMenuBadge({
className,
@@ -670,37 +686,37 @@ function SidebarMenuSubItem({
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
+53 -45
View File
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>
>(({ className, variant = "default", ...props }, ref) => {
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Content
ref={ref}
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+38 -28
View File
@@ -28,33 +28,43 @@ function Tooltip({
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<TooltipPrimitive.Trigger
ref={ref}
data-slot="tooltip-trigger"
{...props}
/>
);
});
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset, children, ...props }, ref) => {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
});
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,79 @@
"use client";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
export function UserManagementPage() {
const { t } = useI18n();
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive text-sm py-4">
{String(error)}
</div>
)}
{!isLoading && !error && users && users.length === 0 && (
<div className="text-muted-foreground text-sm py-8 text-center">
{t.adminUsers.empty}
</div>
)}
{!isLoading && !error && users && users.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr
key={user.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">
{user.system_role === "admin" ? (
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
) : (
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
{user.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SettingsSection>
</div>
);
}
@@ -8,6 +8,8 @@ import {
EyeIcon,
LoaderIcon,
PackageIcon,
PencilIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
@@ -32,6 +34,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
@@ -39,6 +43,7 @@ import {
} from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
@@ -119,8 +124,27 @@ export function ArtifactFileDetail({
enabled: isCodeFile && !isWriteFile,
});
const displayContent = content ?? "";
const queryClient = useQueryClient();
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
const [editedContent, setEditedContent] = useState<string | null>(null);
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
// always reflects the original tool-call output, not the on-disk file after save).
const [savedContent, setSavedContent] = useState<string | null>(null);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
const isHtmlStreaming = language === "html" && thread.isLoading;
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
const activeContent = editedContent ?? displayContent;
const isDirty = editedContent !== null && editedContent !== displayContent;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (language === "html") return "HTML";
@@ -130,22 +154,12 @@ export function ArtifactFileDetail({
return "FILE";
}, [isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setEditedContent(null);
setSavedContent(null);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
@@ -308,6 +322,40 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return;
setIsSaving(true);
try {
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: activeContent }),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail ?? "保存失败");
}
if (isWriteFile) {
setSavedContent(activeContent);
} else {
await queryClient.invalidateQueries({
queryKey: ["artifact", filepathFromProps, threadId],
});
}
setEditedContent(null);
toast.success("已保存");
} catch (error) {
console.error("Save artifact failed:", error);
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
@@ -406,7 +454,27 @@ export function ArtifactFileDetail({
)}
</div>
<div className="flex items-center justify-end gap-2">
{canEdit && viewMode === "code" && !isDirty && (
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
<PencilIcon className="size-3" />
可编辑
</span>
)}
{canEdit && viewMode === "code" && isDirty && (
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
未保存
</span>
)}
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEdit && viewMode === "code" && (
<ArtifactAction
icon={isSaving ? LoaderIcon : SaveIcon}
label="保存"
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
disabled={isSaving || !isDirty}
onClick={handleSave}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
@@ -513,17 +581,26 @@ export function ArtifactFileDetail({
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
content={activeContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="size-full">
<div
className="size-full"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
}}
>
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly
value={activeContent}
readonly={!canEdit}
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
/>
</div>
)}
@@ -41,6 +41,7 @@ export function CodeEditor({
readonly,
disabled,
autoFocus,
onChange,
settings,
}: {
className?: string;
@@ -49,6 +50,7 @@ export function CodeEditor({
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
onChange?: (value: string) => void;
settings?: unknown;
}) {
const {
@@ -107,6 +109,7 @@ export function CodeEditor({
}}
autoFocus={autoFocus}
value={value}
onChange={onChange}
/>
)}
</div>
@@ -0,0 +1,212 @@
"use client";
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
SidebarMenuButton,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
export function NotificationBell() {
const { t } = useI18n();
const { open: isSidebarOpen } = useSidebar();
const [open, setOpen] = useState(false);
// Render badge from the lightweight count endpoint so the bell stays cheap
// when nobody opens it. Only when the user actually opens the dropdown do
// we pay for the full list query.
const { data: unreadCount = 0 } = useUnreadCount();
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={t.notifications.bellAria}
>
{isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<div className="relative">
<BellIcon className="size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
<span>{t.notifications.title}</span>
{hasUnread && (
<span className="text-muted-foreground ml-auto text-xs">
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
</span>
)}
</div>
) : (
<div className="flex size-full items-center justify-center">
<div className="relative">
<BellIcon className="text-sidebar-foreground/80 size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
)}
</div>
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
sideOffset={8}
className="w-96 p-0"
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<span className="text-sm font-semibold">
{t.notifications.title}
</span>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
</div>
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.notifications.empty}
</div>
) : (
<ul className="divide-y">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
!notification.read && "bg-blue-500/5",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-2">
{!notification.read && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"truncate text-sm",
!notification.read && "font-medium",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -173,91 +173,89 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -2,7 +2,6 @@
import {
BellIcon,
InfoIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
@@ -136,12 +135,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "skills" && (
<SkillSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -1,6 +1,21 @@
"use client";
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ClockIcon,
EyeIcon,
EyeOffIcon,
FileArchive,
Loader2,
PinIcon,
PinOffIcon,
SparklesIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -10,6 +25,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -29,13 +45,15 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useEnableSkill,
useDeleteCustomSkill,
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -44,6 +62,7 @@ import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
@@ -73,29 +92,110 @@ function SkillSettingsList({
}) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("public");
const [filter, setFilter] = useState<string>("all");
const [installOpen, setInstallOpen] = useState(false);
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// Visible-to-me skills, classified into the three buckets the UI cares about.
// "All" = built-in + anyone-published-custom + mine (active state only).
// "Mine" = strictly my own custom skills (active state only).
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
const stale: Skill[] = [];
const archived: Skill[] = [];
for (const skill of skills) {
const state = skill.state ?? "active";
const isCustom = skill.category === "custom";
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isPublishedByOthers =
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
if (state === "stale") {
stale.push(skill);
continue;
}
if (state === "archived") {
archived.push(skill);
continue;
}
// state === active
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
all.push(skill);
}
if (isOwnedByMe) {
mine.push(skill);
}
}
return {
activeAll: all,
activeMine: mine,
staleVisible: stale,
archivedVisible: archived,
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs defaultValue="public" onValueChange={setFilter}>
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="outline"
disabled={staleCount === 0}
onClick={() => setStaleSheetOpen(true)}
title={t.settings.skills.badgeStaleTitle}
>
<ClockIcon className="size-4" />
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
</Button>
<Button
size="sm"
variant="outline"
disabled={archivedCount === 0}
onClick={() => setArchiveSheetOpen(true)}
title={t.settings.skills.badgeArchivedTitle}
>
<ArchiveIcon className="size-4" />
{t.settings.skills.entryArchived.replace(
"{count}",
String(archivedCount),
)}
</Button>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
onClose?.();
router.push("/page/workspace/skills/evolution");
}}
>
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
@@ -116,34 +216,267 @@ function SkillSettingsList({
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">{skill.name}</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={skill.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked })
}
/>
</ItemActions>
</Item>
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
/>
<SkillStateSheet
kind="stale"
open={staleSheetOpen}
onOpenChange={setStaleSheetOpen}
skills={staleVisible}
/>
<SkillStateSheet
kind="archived"
open={archiveSheetOpen}
onOpenChange={setArchiveSheetOpen}
skills={archivedVisible}
/>
</div>
);
}
function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
// the multitenancy fields still classify public skills correctly.
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
next
? t.settings.skills.publishSuccess
: t.settings.skills.unpublishSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePinned() {
const next = !skill.pinned;
try {
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
toast.success(
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
</ItemContent>
<ItemActions>
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="size-3.5" />
) : (
<PinIcon className="size-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<EyeOffIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
)}
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</ItemActions>
</Item>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -167,9 +500,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
function InstallSkillDialog({
open,
onClose,
isAdmin,
}: {
open: boolean;
onClose: () => void;
isAdmin: boolean;
}) {
const { t } = useI18n();
const dialog = t.settings.skills.installDialog;
@@ -178,6 +513,7 @@ function InstallSkillDialog({
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
@@ -186,6 +522,7 @@ function InstallSkillDialog({
setValidation(null);
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
if (inputRef.current) inputRef.current.value = "";
}, []);
@@ -242,7 +579,7 @@ function InstallSkillDialog({
async (overwrite: boolean) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite });
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
@@ -255,13 +592,15 @@ function InstallSkillDialog({
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
const canOverwrite = !!validation && !validationError && isCustomConflict;
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -309,6 +648,27 @@ function InstallSkillDialog({
/>
</div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
<div className="flex gap-4">
{(["custom", "public"] as const).map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
</label>
))}
</div>
</div>
)}
{validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
@@ -366,10 +726,10 @@ function InstallSkillDialog({
</div>
)}
{isPublicConflict && (
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
</span>
</div>
)}
@@ -0,0 +1,360 @@
"use client";
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { cn } from "@/lib/utils";
type Kind = "stale" | "archived";
export function SkillStateSheet({
kind,
open,
onOpenChange,
skills,
}: {
kind: Kind;
open: boolean;
onOpenChange: (open: boolean) => void;
skills: Skill[];
}) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const isArchived = kind === "archived";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
{isArchived ? (
<ArchiveIcon className="size-4" />
) : (
<ClockIcon className="size-4" />
)}
{isArchived ? ts.archivedTitle : ts.staleTitle}
<span className="text-muted-foreground text-sm font-normal">
({skills.length})
</span>
</SheetTitle>
<SheetDescription>
{isArchived ? ts.archivedDescription : ts.staleDescription}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto p-4">
{skills.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
{ts.empty}
</div>
) : (
<div className="flex flex-col gap-3">
{skills.map((skill) => (
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const setState = useSetSkillState();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const pending = setState.isPending || deleteSkill.isPending;
const isArchived = kind === "archived";
const timeLabel = useMemo(() => {
if (isArchived) {
const when = skill.last_activity_at;
return when
? ts.archivedAt.replace("{when}", formatRelative(when))
: ts.archivedAt.replace("{when}", "—");
}
const when = skill.last_activity_at;
return when
? ts.lastUsedAt.replace("{when}", formatRelative(when))
: ts.neverUsed;
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
async function handleRestore() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "active" });
toast.success(ts.restoreSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleArchive() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
toast.success(ts.archiveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(tt.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{skill.name}</span>
{isArchived ? (
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
{tt.badgeArchived}
</Badge>
) : (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title={tt.badgeStaleTitle}
>
{tt.badgeStale}
</Badge>
)}
{skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={tt.badgeSourceAgentTitle}
>
{tt.badgeSourceAgent}
</Badge>
)}
{skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={tt.badgeSourceUploadTitle}
>
{tt.badgeSourceUpload}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{skill.description}
</p>
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>{timeLabel}</span>
<span>
{skill.use_count && skill.use_count > 0
? tt.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: tt.useCountInlineZero}
</span>
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => void handleRestore()}
disabled={pending}
>
<RotateCcwIcon className="size-3.5" />
{ts.actionRestore}
</Button>
{!isArchived && (
<Button
size="sm"
variant="outline"
onClick={() => void handleArchive()}
disabled={pending}
>
<ArchiveIcon className="size-3.5" />
{ts.actionArchiveNow}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={pending}
title={ts.moreMenu}
>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
{ts.actionView}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setConfirmDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="size-3.5" />
{ts.actionDelete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SkillViewDialog
skill={skill}
open={viewOpen}
onOpenChange={setViewOpen}
/>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{tt.deleteAction}</DialogTitle>
<DialogDescription>
{tt.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function SkillViewDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tv = t.settings.skills.viewDialog;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{tv.title}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 text-sm">
<Row label={tv.descriptionLabel}>
<span className="whitespace-pre-wrap">{skill.description}</span>
</Row>
<Row label={tv.stateLabel}>
<span className={cn("font-medium", stateColor(skill.state))}>
{skill.state ?? "active"}
</span>
</Row>
{skill.use_count !== undefined && (
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
)}
{skill.last_activity_at && (
<Row label={tv.lastUsedLabel}>
{formatRelative(skill.last_activity_at)}
</Row>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{tv.close}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}
function stateColor(state: string | undefined): string {
switch (state) {
case "archived":
return "text-muted-foreground";
case "stale":
return "text-amber-600 dark:text-amber-300";
default:
return "text-emerald-600 dark:text-emerald-300";
}
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return iso;
const sec = Math.max(0, Math.floor(ms / 1000));
const min = Math.floor(sec / 60);
const hour = Math.floor(min / 60);
const day = Math.floor(hour / 24);
if (day >= 1) return `${day}d ago`;
if (hour >= 1) return `${hour}h ago`;
if (min >= 1) return `${min}m ago`;
return "just now";
}
@@ -0,0 +1,456 @@
"use client";
import {
ArrowLeftIcon,
Loader2Icon,
PlayIcon,
RefreshCwIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
useCuratorLogs,
useCuratorStatus,
useRunCurator,
useUpdateCuratorConfig,
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
const { t } = useI18n();
const router = useRouter();
const tt = t.settings.skillEvolution;
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{tt.title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{tt.description}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/page/workspace/skills")}
>
<ArrowLeftIcon className="size-4" />
{tt.backToSkills}
</Button>
</header>
<StatusCard />
<ConfigCard />
<LogsCard />
</div>
);
}
function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
if (!status?.last_run_at) return tt.lastRunNever;
try {
return new Date(status.last_run_at).toLocaleString();
} catch {
return status.last_run_at;
}
}, [status?.last_run_at, tt.lastRunNever]);
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
{status && (
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={status.enabled ? "default" : "secondary"}>
{status.enabled ? tt.enabled : tt.disabled}
</Badge>
{status.paused && (
<Badge variant="outline">{tt.paused}</Badge>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
{tt.runNow}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : status ? (
<div className="grid gap-3 text-sm sm:grid-cols-2">
<StatRow label={tt.runCount}>
{status.run_count}
{tt.runCountUnit && (
<span className="text-muted-foreground ml-1">
{tt.runCountUnit}
</span>
)}
</StatRow>
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
<span className="text-muted-foreground">
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
</span>
</StatRow>
<StatRow label={tt.skillCounts} className="sm:col-span-2">
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
<span>
{tt.total}: {status.agent_created_skills.total}
</span>
<span>
{tt.active}: {status.agent_created_skills.active}
</span>
<span>
{tt.stale}: {status.agent_created_skills.stale}
</span>
<span>
{tt.archived}: {status.agent_created_skills.archived}
</span>
</span>
</StatRow>
</div>
) : null}
</CardContent>
</Card>
);
}
function StatRow({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
<span className="font-medium">{children}</span>
</div>
);
}
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
if (config && !draft) {
setDraft(config);
}
}, [config, draft]);
if (isLoading || !draft) {
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
</CardContent>
</Card>
);
}
const isDirty =
!!config &&
JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
try {
const updated = await updateConfig.mutateAsync(draft);
setDraft(updated);
toast.success(tt.saveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow
label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.interval_hours}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.staleAfterDaysLabel}
helper={tt.staleAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.stale_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.archiveAfterDaysLabel}
helper={tt.archiveAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.archive_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
})
}
/>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)}
>
{tt.reset}
</Button>
<Button
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={handleSave}
>
{updateConfig.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{tt.save}
</Button>
</div>
</CardContent>
</Card>
);
}
function FieldRow({
label,
helper,
children,
}: {
label: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
<div className="flex-1">
<div className="text-sm font-medium">{label}</div>
{helper && (
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
function LogsCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.logs;
const [autoRefresh, setAutoRefresh] = useState(true);
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
const records = logs?.records ?? [];
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<label className="text-muted-foreground flex items-center gap-2 text-xs">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
{tt.autoRefresh}
</label>
<Button size="sm" variant="outline" onClick={() => void refetch()}>
<RefreshCwIcon className="size-3.5" />
{tt.refresh}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading && records.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : records.length === 0 ? (
<div className="text-muted-foreground text-sm">{tt.empty}</div>
) : (
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
{records.map((record, idx) => (
<div
key={`${record.ts}-${idx}`}
className={cn(
"flex gap-3",
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
record.level === "error" && "text-red-600 dark:text-red-300",
)}
>
<span className="text-muted-foreground shrink-0">
{formatTime(record.ts)}
</span>
<span className="text-muted-foreground w-14 shrink-0 uppercase">
{record.level}
</span>
<span className="whitespace-pre-wrap break-all">
{record.message === "run_started"
? "——— 本次策展开始 ———"
: record.message}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return ts;
}
}
@@ -1,6 +1,7 @@
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -10,11 +11,14 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
@@ -48,6 +52,17 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -59,6 +74,19 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
<UsersIcon />
<span>{t.sidebar.users}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -27,6 +27,7 @@ import {
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { NotificationBell } from "./notification-bell";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -56,7 +57,7 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
"appearance" | "memory" | "tools" | "notification" | "about"
>("appearance");
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
@@ -74,6 +75,9 @@ export function WorkspaceNavMenu() {
defaultSection={settingsDefaultSection}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
<NotificationBell />
</SidebarMenuItem>
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
+14
View File
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
export interface UserListItem {
id: string;
email: string;
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
export async function loginByUsername(
username: string,
): Promise<LoginByUsernameResponse> {
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { listAllUsers } from "./api";
export function useAllUsers(enabled: boolean) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
enabled,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
CuratorConfig,
CuratorLogs,
CuratorStatus,
} from "./types";
export async function getCuratorStatus(): Promise<CuratorStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
return response.json() as Promise<CuratorStatus>;
}
export async function getCuratorConfig(): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
return response.json() as Promise<CuratorConfig>;
}
export async function updateCuratorConfig(
payload: CuratorConfig,
): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return response.json() as Promise<CuratorConfig>;
}
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
method: "POST",
});
return response.json();
}
export async function getCuratorLogs(): Promise<CuratorLogs> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
return response.json() as Promise<CuratorLogs>;
}
+90
View File
@@ -0,0 +1,90 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
getCuratorConfig,
getCuratorLogs,
getCuratorStatus,
runCurator,
updateCuratorConfig,
} from "./api";
import type { CuratorConfig } from "./types";
// All curator-related queries are per-user data (status / config / logs all
// live under .deer-flow/users/{user_id}/), so the cache key MUST be scoped by
// user_id. Otherwise switching accounts in the same tab would let user B
// briefly read user A's cached values — and clicking "save" on that stale
// frame would silently overwrite user B's real config with user A's values.
function useCuratorKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(
() => ({
status: ["curator", userId, "status"] as const,
config: ["curator", userId, "config"] as const,
logs: ["curator", userId, "logs"] as const,
}),
[userId],
);
}
export function useCuratorStatus() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.status,
queryFn: getCuratorStatus,
});
return { status: query.data, isLoading: query.isLoading, error: query.error };
}
export function useCuratorConfig() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.config,
queryFn: getCuratorConfig,
});
return { config: query.data, isLoading: query.isLoading, error: query.error };
}
export function useUpdateCuratorConfig() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: keys.config });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useRunCurator() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: () => runCurator(),
onSuccess: () => {
// Logs start from scratch on a new run — refresh so the UI clears
// immediately and then picks up the new run_started record.
void queryClient.invalidateQueries({ queryKey: keys.logs });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
const autoRefresh = options?.autoRefresh ?? true;
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.logs,
queryFn: getCuratorLogs,
refetchInterval: autoRefresh ? 2000 : false,
});
return {
logs: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+38
View File
@@ -0,0 +1,38 @@
export interface CuratorSubConfig {
enabled: boolean;
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
}
export interface CuratorConfig {
enabled: boolean;
moderation_model_name: string | null;
curator: CuratorSubConfig;
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
last_run_at: string | null;
last_run_summary: string | null;
paused: boolean;
agent_created_skills: {
total: number;
active: number;
stale: number;
archived: number;
};
}
export interface CuratorLogRecord {
ts: string;
level: string;
message: string;
}
export interface CuratorLogs {
records: CuratorLogRecord[];
run_count: number;
last_run_at: string | null;
}
+154
View File
@@ -172,6 +172,7 @@ export const enUS: Translations = {
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
users: "Users",
},
// Agents
@@ -434,10 +435,77 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
goToEvolution: "Skill evolution",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
emptyButton: "Create Your First Skill",
badgeBuiltin: "Built-in",
badgeMine: "Mine",
badgeFromOthers: "From others",
badgeFromOthersTitle: "Owned by user {user_id}",
badgePublished: "Published",
badgePublishedTitle: "Visible to all users",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
badgePinned: "Pinned",
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
badgeSourceAgent: "Agent-created",
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
tabAll: "All",
tabMine: "Mine",
entryStale: "Stale {count}",
entryArchived: "Archived {count}",
useCountInline: "Used {count} times",
useCountInlineZero: "Not used yet",
publishAction: "Publish",
unpublishAction: "Unpublish",
publishSuccess: "Skill published",
unpublishSuccess: "Skill unpublished",
pinAction: "Pin (prevent auto-archive)",
unpinAction: "Unpin",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
deleteAction: "Delete skill",
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
stateSheet: {
archivedTitle: "Archived skills",
archivedDescription:
"These skills have been archived by you or the curator and can be restored at any time.",
staleTitle: "Stale skills",
staleDescription:
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
empty: "Nothing here",
actionRestore: "Restore",
actionArchiveNow: "Archive now",
actionView: "View",
actionPin: "Pin",
actionUnpin: "Unpin",
actionDelete: "Delete permanently",
moreMenu: "More",
archivedAt: "Archived {when}",
lastUsedAt: "Last used {when}",
neverUsed: "Never used",
restoreSuccess: "Restored",
archiveSuccess: "Archived",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
},
viewDialog: {
title: "Skill details",
descriptionLabel: "Description",
stateLabel: "State",
sourceLabel: "Source",
useCountLabel: "Use count",
lastUsedLabel: "Last used",
close: "Close",
},
installDialog: {
title: "Install skill",
dropHint: "Drop a .zip or .skill archive here, or",
@@ -456,9 +524,73 @@ export const enUS: Translations = {
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
targetCategoryLabel: "Install to",
categoryCustom: "Custom skill",
categoryPublic: "Built-in (admin)",
},
},
skillEvolution: {
title: "Skill Evolution",
description:
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
backToSkills: "Back to skills",
status: {
title: "Status",
enabled: "Enabled",
disabled: "Disabled",
paused: "Paused",
runCount: "Total runs",
runCountUnit: "",
lastRunAt: "Last run",
lastRunNever: "Never",
lastRunSummary: "Last summary",
lastRunSummaryEmpty: "—",
skillCounts: "Skills",
active: "Active",
stale: "Stale",
archived: "Archived",
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
},
config: {
title: "Configuration",
description:
"Saves to skill_evolution.json and hot-reloads — no restart required.",
enabledLabel: "Enable skill evolution",
enabledHelper:
"When off, the agent cannot create or modify skills under custom/.",
curatorEnabledLabel: "Curator runs automatically",
curatorEnabledHelper:
"When off, the curator only runs when triggered from this page.",
intervalHoursLabel: "Run interval (hours)",
intervalHoursHelper:
"Minimum hours between automatic curator runs.",
staleAfterDaysLabel: "Stale threshold (days)",
staleAfterDaysHelper:
"Skills inactive for this many days are flagged stale.",
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.",
save: "Save changes",
saveSuccess: "Configuration updated",
reset: "Reset",
},
logs: {
title: "Recent run logs",
description:
"Latest curator run only; cleared on backend restart.",
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
autoRefresh: "Auto-refresh (2s)",
refresh: "Refresh now",
},
},
notification: {
@@ -479,4 +611,26 @@ export const enUS: Translations = {
emptyDescription: "Credits and acknowledgements will show here.",
},
},
adminUsers: {
title: "User Management",
description: "View all registered users and their associated skill and agent counts.",
email: "Email",
role: "Role",
userId: "User ID",
skillCount: "Skills",
agentCount: "Agents",
roleAdmin: "Admin",
roleUser: "User",
empty: "No users found",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
unreadShort: "{count} unread",
markAllRead: "Mark all read",
empty: "No notifications",
deleteAria: "Delete notification",
},
};
+145
View File
@@ -1,5 +1,14 @@
import type { LucideIcon } from "lucide-react";
export interface NotificationsTranslations {
title: string;
bellAria: string;
unreadShort: string;
markAllRead: string;
empty: string;
deleteAria: string;
}
export interface Translations {
// Locale meta
locale: {
@@ -116,6 +125,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
users: string;
};
// Agents
@@ -355,9 +365,73 @@ export interface Translations {
description: string;
createSkill: string;
installSkill: string;
goToEvolution: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
badgeBuiltin: string;
badgeMine: string;
badgeFromOthers: string;
badgeFromOthersTitle: string;
badgePublished: string;
badgePublishedTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
badgeArchivedTitle: string;
badgePinned: string;
badgePinnedTitle: string;
badgeSourceAgent: string;
badgeSourceAgentTitle: string;
badgeSourceUpload: string;
badgeSourceUploadTitle: string;
tabAll: string;
tabMine: string;
entryStale: string;
entryArchived: string;
useCountInline: string;
useCountInlineZero: string;
publishAction: string;
unpublishAction: string;
publishSuccess: string;
unpublishSuccess: string;
pinAction: string;
unpinAction: string;
pinSuccess: string;
unpinSuccess: string;
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
stateSheet: {
archivedTitle: string;
archivedDescription: string;
staleTitle: string;
staleDescription: string;
empty: string;
actionRestore: string;
actionArchiveNow: string;
actionView: string;
actionPin: string;
actionUnpin: string;
actionDelete: string;
moreMenu: string;
archivedAt: string;
lastUsedAt: string;
neverUsed: string;
restoreSuccess: string;
archiveSuccess: string;
pinSuccess: string;
unpinSuccess: string;
};
viewDialog: {
title: string;
descriptionLabel: string;
stateLabel: string;
sourceLabel: string;
useCountLabel: string;
lastUsedLabel: string;
close: string;
};
installDialog: {
title: string;
dropHint: string;
@@ -374,9 +448,63 @@ export interface Translations {
warningsLabel: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
installSuccessReplaced: string;
rejectInvalidExt: string;
targetCategoryLabel: string;
categoryCustom: string;
categoryPublic: string;
};
};
skillEvolution: {
title: string;
description: string;
backToSkills: string;
status: {
title: string;
enabled: string;
disabled: string;
paused: string;
runCount: string;
runCountUnit: string;
lastRunAt: string;
lastRunNever: string;
lastRunSummary: string;
lastRunSummaryEmpty: string;
skillCounts: string;
active: string;
stale: string;
archived: string;
total: string;
runNow: string;
runStarted: string;
};
config: {
title: string;
description: string;
enabledLabel: string;
enabledHelper: string;
curatorEnabledLabel: string;
curatorEnabledHelper: string;
intervalHoursLabel: string;
intervalHoursHelper: string;
staleAfterDaysLabel: string;
staleAfterDaysHelper: string;
archiveAfterDaysLabel: string;
archiveAfterDaysHelper: string;
moderationModelLabel: string;
moderationModelHelper: string;
save: string;
saveSuccess: string;
reset: string;
};
logs: {
title: string;
description: string;
empty: string;
autoRefresh: string;
refresh: string;
};
};
notification: {
@@ -395,4 +523,21 @@ export interface Translations {
emptyDescription: string;
};
};
// Admin user management page
adminUsers: {
title: string;
description: string;
email: string;
role: string;
userId: string;
skillCount: string;
agentCount: string;
roleAdmin: string;
roleUser: string;
empty: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+147 -4
View File
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
users: "用户管理",
},
// Agents
@@ -415,10 +416,74 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
goToEvolution: "自我进化",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
emptyButton: "创建你的第一个技能",
badgeBuiltin: "内置",
badgeMine: "我的",
badgeFromOthers: "来自他人",
badgeFromOthersTitle: "来自用户 {user_id}",
badgePublished: "已发布",
badgePublishedTitle: "该技能已对所有用户可见",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
badgePinned: "钉选",
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
badgeSourceAgent: "Agent 创建",
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
badgeSourceUploadTitle: "由用户手动上传或编辑",
tabAll: "全部",
tabMine: "我的",
entryStale: "过时 {count}",
entryArchived: "归档 {count}",
useCountInline: "调用 {count} 次",
useCountInlineZero: "尚未调用",
publishAction: "发布",
unpublishAction: "取消发布",
publishSuccess: "已发布",
unpublishSuccess: "已取消发布",
pinAction: "钉选(防止被自动归档)",
unpinAction: "取消钉选",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
staleTitle: "过时的技能",
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
empty: "暂无内容",
actionRestore: "恢复",
actionArchiveNow: "立即归档",
actionView: "查看详情",
actionPin: "钉选",
actionUnpin: "取消钉选",
actionDelete: "永久删除",
moreMenu: "更多操作",
archivedAt: "归档于 {when}",
lastUsedAt: "最近使用 {when}",
neverUsed: "尚未使用",
restoreSuccess: "已恢复",
archiveSuccess: "已归档",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
},
viewDialog: {
title: "技能详情",
descriptionLabel: "描述",
stateLabel: "状态",
sourceLabel: "来源",
useCountLabel: "调用次数",
lastUsedLabel: "最近使用",
close: "关闭",
},
installDialog: {
title: "安装技能",
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
@@ -435,10 +500,66 @@ export const zhCN: Translations = {
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
installSuccess: "技能 “{name}” 已安装。",
installSuccessReplaced: "技能 “{name}” 已替换。",
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
targetCategoryLabel: `安装到`,
categoryCustom: `自定义技能`,
categoryPublic: `内置技能(管理员)`,
},
},
skillEvolution: {
title: "自我进化",
description:
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
backToSkills: "返回技能",
status: {
title: "当前状态",
enabled: "已启用",
disabled: "已停用",
paused: "已暂停",
runCount: "累计运行",
runCountUnit: "次",
lastRunAt: "上次运行",
lastRunNever: "尚未运行",
lastRunSummary: "上次摘要",
lastRunSummaryEmpty: "—",
skillCounts: "技能",
active: "活跃",
stale: "陈旧",
archived: "已归档",
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
},
config: {
title: "配置",
description:
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
enabledLabel: "启用自我进化",
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
curatorEnabledLabel: "curator 自动运行",
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
intervalHoursLabel: "运行间隔(小时)",
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
staleAfterDaysLabel: "陈旧阈值(天)",
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
archiveAfterDaysLabel: "归档阈值(天)",
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
moderationModelLabel: "安全审核模型(可选)",
moderationModelHelper: "为空时使用默认主模型。",
save: "保存修改",
saveSuccess: "配置已更新",
reset: "重置",
},
logs: {
title: "最近运行日志",
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
autoRefresh: "自动刷新 (2s)",
refresh: "立即刷新",
},
},
notification: {
@@ -459,4 +580,26 @@ export const zhCN: Translations = {
emptyDescription: "相关的致谢信息会展示在这里。",
},
},
adminUsers: {
title: "用户管理",
description: "查看所有注册用户及其关联的技能和智能体数量。",
email: "邮箱",
role: "角色",
userId: "用户 ID",
skillCount: "技能数",
agentCount: "智能体数",
roleAdmin: "管理员",
roleUser: "普通用户",
empty: "暂无用户",
},
notifications: {
title: "通知",
bellAria: "打开通知",
unreadShort: "{count} 条未读",
markAllRead: "全部已读",
empty: "暂无通知",
deleteAria: "删除通知",
},
};
@@ -0,0 +1,60 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
NotificationItem,
NotificationListResponse,
NotificationUnreadCountResponse,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
export async function listNotifications(
options: { unreadOnly?: boolean; limit?: number } = {},
): Promise<NotificationItem[]> {
const params = new URLSearchParams();
if (options.unreadOnly) params.set("unread_only", "true");
if (options.limit) params.set("limit", String(options.limit));
const query = params.toString();
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
const res = await apiFetch(url);
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
const data = (await res.json()) as NotificationListResponse;
return data.items;
}
export async function getUnreadCount(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
const data = (await res.json()) as NotificationUnreadCountResponse;
return data.unread;
}
export async function markNotificationRead(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
{ method: "POST" },
);
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
}
export async function markAllNotificationsRead(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
method: "POST",
});
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
const data = (await res.json()) as { updated: number };
return data.updated;
}
export async function deleteNotification(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
}
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
deleteNotification,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "./api";
// Notifications are per-user data, so the cache key MUST be scoped by user_id.
// Otherwise switching accounts in the same tab would show the previous user's
// inbox until the next fetch lands.
function useNotificationKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(() => {
const root = ["notifications", userId] as const;
return {
root,
list: (limit: number, unreadOnly: boolean) =>
[...root, "list", { limit, unreadOnly }] as const,
count: [...root, "unread-count"] as const,
};
}, [userId]);
}
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
const { limit = 20, unreadOnly = false } = options;
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.list(limit, unreadOnly),
queryFn: () => listNotifications({ limit, unreadOnly }),
});
}
export function useUnreadCount(refetchInterval = 30000) {
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.count,
queryFn: () => getUnreadCount(),
refetchInterval,
// Keep the badge fresh when the user comes back to the tab without
// waiting for the next interval tick.
refetchOnWindowFocus: true,
});
}
function useInvalidateNotifications() {
const queryClient = useQueryClient();
const keys = useNotificationKeys();
return () => {
void queryClient.invalidateQueries({ queryKey: keys.root });
};
}
export function useMarkNotificationRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: invalidate,
});
}
export function useMarkAllNotificationsRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: invalidate,
});
}
export function useDeleteNotification() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => deleteNotification(id),
onSuccess: invalidate,
});
}
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
@@ -0,0 +1,18 @@
export interface NotificationItem {
id: string;
user_id: string;
type: string;
title: string;
body: string;
payload: Record<string, unknown>;
read: boolean;
created_at: string | null;
}
export interface NotificationListResponse {
items: NotificationItem[];
}
export interface NotificationUnreadCountResponse {
unread: number;
}
+76 -3
View File
@@ -1,7 +1,7 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Skill } from "./type";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean } = {},
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const query = options.overwrite ? "?overwrite=true" : "";
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
@@ -110,3 +113,73 @@ export async function installSkillUpload(
}
return response.json() as Promise<InstallSkillResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
published: boolean;
}
export async function setSkillPublished(
skillName: string,
published: boolean,
): Promise<SetSkillPublishedResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ published }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update published state");
}
return response.json() as Promise<SetSkillPublishedResponse>;
}
export async function setSkillState(
skillName: string,
state: SkillState,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillPinned(
skillName: string,
pinned: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill pinned state");
}
return response.json() as Promise<Skill>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
+60 -3
View File
@@ -1,6 +1,15 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
import {
deleteCustomSkill,
enableSkill,
installSkillUpload,
setSkillPinned,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { SkillState } from "./type";
import { loadSkills } from ".";
@@ -39,8 +48,56 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
installSkillUpload(file, { overwrite }),
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
published,
}: {
skillName: string;
published: boolean;
}) => setSkillPublished(skillName, published),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillState() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
setSkillState(skillName, state),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPinned() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
setSkillPinned(skillName, pinned),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDeleteCustomSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
+26 -7
View File
@@ -1,7 +1,26 @@
export interface Skill {
name: string;
description: string;
category: string;
license: string;
enabled: boolean;
}
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
export interface Skill {
name: string;
description: string;
category: string;
license: string;
enabled: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
published?: boolean;
builtin?: boolean;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
pinned?: boolean;
// Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
patch_count?: number;
}
+31 -38
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import {
ArrowLeftIcon,
@@ -17,13 +17,6 @@ import {
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
@@ -120,7 +113,7 @@ export default function NewAgentPage() {
model_name: chatModelInput || undefined,
},
onToolEnd() {
// setup_agent never actually executes in this flow — it gets
// setup_agent never actually executes in this flow 鈥?it gets
// intercepted by SetupAgentApprovalMiddleware and the form is filled
// from the approval ToolMessage payload (handleApproveSetupAgent).
// No agent record is fetched/refreshed during chat.
@@ -156,7 +149,7 @@ export default function NewAgentPage() {
if (!name) {
// Names are display-only and may collide, so a timestamped placeholder
// is safe to send to the backend just to satisfy the required-field
// validation. Intentionally NOT written back into the visible input —
// validation. Intentionally NOT written back into the visible input 鈥?
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
@@ -245,7 +238,7 @@ export default function NewAgentPage() {
// Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler
// prefills the form locally — it does NOT persist anything.
// prefills the form locally 鈥?it does NOT persist anything.
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
@@ -255,7 +248,7 @@ export default function NewAgentPage() {
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist
// anything — it only mirrors the proposal into the form so the user
// anything 鈥?it only mirrors the proposal into the form so the user
// can review it. Creation/update happens later when the user clicks
// the top-right Save button.
const proposedName = payload.name?.trim() ?? "";
@@ -338,22 +331,23 @@ export default function NewAgentPage() {
<span className="text-muted-foreground text-xs">
当前对话模型
</span>
<Select
<select
value={chatModelInput}
onValueChange={setChatModelInput}
onChange={(event) =>
setChatModelInput(event.currentTarget.value)
}
disabled={isModelsLoading || thread.isLoading}
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none"
>
<SelectTrigger className="h-7 w-auto min-w-[10rem] gap-2 px-2 text-xs">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<option value="" disabled>
选择模型
</option>
{models.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.name}
</option>
))}
</select>
</div>
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
@@ -405,22 +399,21 @@ export default function NewAgentPage() {
<div className="space-y-2">
<label className="text-sm font-medium">模型</label>
<Select
<select
value={modelInput}
onValueChange={setModelInput}
onChange={(event) => setModelInput(event.currentTarget.value)}
disabled={isModelsLoading}
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<option value="" disabled>
选择模型
</option>
{models.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.name}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
该专家被使用时调用的模型。
</p>
@@ -0,0 +1,9 @@
import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel";
export default function SkillEvolutionPage() {
return (
<div className="flex h-full flex-col overflow-auto p-6">
<SkillEvolutionPanel />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
export default function SkillsPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<SkillSettingsPage />
</div>
);
}
+4 -14
View File
@@ -1,14 +1,12 @@
import { Navigate, Route, Routes } from "react-router-dom";
import type { ReactNode } from "react";
import { Toaster } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { SidebarProvider } from "@/components/ui/sidebar";
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
import { ChatRuntime } from "./WorkspaceRoutes";
import AgentsPage from "./AgentsPage";
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
@@ -18,19 +16,10 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
@@ -89,6 +78,7 @@ export default function StrategyRoutes() {
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider>
);
+5
View File
@@ -0,0 +1,5 @@
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
export default function UsersPage() {
return <UserManagementPage />;
}
+28 -1
View File
@@ -22,6 +22,9 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -63,7 +66,7 @@ export function WorkspaceLayout({
);
}
function ChatRuntime({ children }: { children: ReactNode }) {
export function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
@@ -136,6 +139,22 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="skills"
element={
<WorkspaceLayout {...layoutProps}>
<SkillsPage />
</WorkspaceLayout>
}
/>
<Route
path="skills/evolution"
element={
<WorkspaceLayout {...layoutProps}>
<SkillEvolutionPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -156,6 +175,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/users"
element={
<WorkspaceLayout {...layoutProps}>
<UsersPage />
</WorkspaceLayout>
}
/>
<Route
path="settings/recommended-questions"
element={
@@ -31,6 +31,7 @@ from app.gateway.routers import (
mcp,
memory,
models,
notifications,
recommended_questions,
runs,
scheduled_tasks,
@@ -155,6 +156,68 @@ async def _iter_store_items(store, namespace, *, page_size: int = 500):
offset += page_size
async def _sync_legacy_skills(app: FastAPI) -> None:
"""Idempotently register existing on-disk ``skills/custom/*`` directories
in the ``skills`` DB table, owned by the first admin and published=True.
Without this step, after upgrading to per-user skill ownership the existing
custom skill folders would not be visible to anyone (the SkillStore
visibility filter relies on a DB row). Running this once on every boot is
cheap (folder scan + one ``ensure_legacy`` per name) and idempotent.
Non-fatal: a failure here MUST NOT block server startup.
"""
from sqlalchemy import select
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.user.model import UserRow
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import SkillCategory
sf = get_session_factory()
if sf is None:
return
skill_store = getattr(app.state, "skill_store", None)
if skill_store is None:
return
async with sf() as session:
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
# No admin yet — leave ownership null; first admin is responsible for
# claiming via the UI once they exist.
admin_id: str | None = None
else:
admin_id = str(row.id)
config = getattr(app.state, "config", None)
storage = get_or_new_skill_storage(app_config=config)
try:
skills = storage.load_skills(enabled_only=False)
except Exception:
logger.exception("_sync_legacy_skills: could not list on-disk skills")
return
inserted = 0
for skill in skills:
if skill.category != SkillCategory.CUSTOM:
continue
try:
existing = await skill_store.get_any(skill.name)
if existing is not None:
continue
await skill_store.ensure_legacy(
{"name": skill.name, "owner_user_id": admin_id, "published": True}
)
inserted += 1
except Exception:
logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
if inserted:
logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
"""Migrate LangGraph store threads with no user_id to the given admin.
@@ -231,6 +294,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
await _ensure_admin_user(app)
# Migrate "ownerless" on-disk skills into the new SkillStore table —
# makes the multitenancy switch transparent for existing deployments.
try:
await _sync_legacy_skills(app)
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# Kick off skill curator in background if the run interval has elapsed
try:
from deerflow.skills.curator import maybe_run_curator
asyncio.create_task(maybe_run_curator())
except Exception:
logger.exception("Skill curator startup task failed (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
@@ -515,6 +592,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)
# Notifications API is mounted at /api/notifications
app.include_router(notifications.router)
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
app.include_router(thread_runs.router)
@@ -108,6 +108,10 @@ class LocalAuthProvider(AuthProvider):
"""Update an existing user."""
return await self._repo.update_user(user)
async def list_users(self) -> list[User]:
"""Return all registered users ordered by creation date."""
return await self._repo.list_users()
async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email."""
return await self._repo.get_user_by_email(email)
@@ -108,6 +108,11 @@ class UserRepository(ABC):
"""Return number of users with system_role == 'admin'."""
raise NotImplementedError
@abstractmethod
async def list_users(self) -> list[User]:
"""Return all users ordered by creation date."""
raise NotImplementedError
@abstractmethod
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID.
@@ -140,6 +140,12 @@ class SQLiteUserRepository(UserRepository):
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def list_users(self) -> list[User]:
stmt = select(UserRow).order_by(UserRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_user(row) for row in result.scalars()]
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
async with self._sf() as session:
@@ -25,7 +25,9 @@ if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.notifications import NotificationStore
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.thread_meta.base import ThreadMetaStore
@@ -98,6 +100,19 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.agent_store = make_agent_store(sf)
from deerflow.persistence.skills import make_skill_store
app.state.skill_store = make_skill_store(sf)
from deerflow.persistence.notifications import (
make_notification_store,
set_notification_store,
)
app.state.notification_store = make_notification_store(sf)
# Make the sender helper (harness layer) able to write without going
# through FastAPI request context — used by cascade.py.
set_notification_store(app.state.notification_store)
from deerflow.persistence.recommended_questions import make_recommended_question_store
app.state.recommended_question_store = make_recommended_question_store(sf)
@@ -112,6 +127,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
try:
yield
finally:
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
await close_engine()
@@ -175,6 +193,22 @@ def get_agent_store(request: Request) -> AgentStore:
return val
def get_skill_store(request: Request) -> SkillStore:
"""Return the custom-skill store."""
val = getattr(request.app.state, "skill_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Skill store not available")
return val
def get_notification_store(request: Request) -> NotificationStore:
"""Return the in-app notification store."""
val = getattr(request.app.state, "notification_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Notification store not available")
return val
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -6,6 +6,7 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
from pydantic import BaseModel
from app.gateway.authz import require_permission
from app.gateway.path_utils import resolve_thread_virtual_path
@@ -181,3 +182,46 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
class ArtifactUpdateRequest(BaseModel):
content: str
@router.put(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Update Artifact File",
description="Update the content of an artifact file.",
)
@require_permission("threads", "write", owner_check=True)
async def update_artifact(thread_id: str, path: str, request: Request, body: ArtifactUpdateRequest) -> dict:
"""Update an artifact file's content.
Args:
thread_id: The thread ID.
path: The artifact path (e.g. mnt/user-data/outputs/index.html).
body: Request body containing the new file content.
Returns:
{"ok": true} on success.
Raises:
HTTPException:
- 400 if path is invalid, not a file, or is a .skill archive
- 403 if access denied
- 404 if file not found
"""
if ".skill/" in path or path.endswith(".skill"):
raise HTTPException(status_code=400, detail="Cannot edit .skill archives")
actual_path = resolve_thread_virtual_path(thread_id, path)
if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
actual_path.write_text(body.content, encoding="utf-8")
logger.info(f"Updated artifact: thread_id={thread_id}, path={path}")
return {"ok": True}
@@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
from app.gateway.auth import (
UserResponse,
create_access_token,
@@ -17,7 +20,7 @@ from app.gateway.auth import (
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_current_user_from_request, get_local_provider
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
logger = logging.getLogger(__name__)
@@ -588,6 +591,43 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
# ── Admin Endpoints ───────────────────────────────────────────────────────
class UserListItem(BaseModel):
id: str
email: str
system_role: Literal["admin", "user"]
skill_count: int
agent_count: int
@router.get("/users", response_model=list[UserListItem])
async def list_all_users(
request: Request,
skill_store: SkillStore = Depends(get_skill_store),
agent_store: AgentStore = Depends(get_agent_store),
):
"""List all users with their skill and agent counts. Admin only."""
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
users = await get_local_provider().list_users()
result = []
for u in users:
uid = str(u.id)
result.append(
UserListItem(
id=uid,
email=u.email,
system_role=u.system_role,
skill_count=await skill_store.count_owned(uid),
agent_count=await agent_store.count_owned(uid),
)
)
return result
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
@@ -0,0 +1,152 @@
"""In-app notification inbox API."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_notification_store
from deerflow.persistence.notifications import NotificationStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["notifications"])
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
class NotificationItem(BaseModel):
id: str
user_id: str
type: str
title: str
body: str
payload: dict[str, Any]
read: bool
created_at: str | None
class NotificationListResponse(BaseModel):
items: list[NotificationItem]
class NotificationUnreadCountResponse(BaseModel):
unread: int = Field(..., description="Number of unread notifications for the current user")
class MarkReadResponse(BaseModel):
success: bool
class MarkAllReadResponse(BaseModel):
updated: int
@router.get(
"/notifications",
response_model=NotificationListResponse,
summary="List Notifications",
)
async def list_notifications(
request: Request,
unread_only: bool = Query(default=False),
limit: int = Query(default=50, ge=1, le=200),
store: NotificationStore = Depends(get_notification_store),
) -> NotificationListResponse:
try:
items = await store.list_for_user(
_current_user_id(request),
unread_only=unread_only,
limit=limit,
)
return NotificationListResponse(items=[NotificationItem(**item) for item in items])
except Exception as e:
logger.error("Failed to list notifications: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list notifications: {e}")
@router.get(
"/notifications/unread_count",
response_model=NotificationUnreadCountResponse,
summary="Unread Notification Count",
)
async def unread_count(
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> NotificationUnreadCountResponse:
try:
count = await store.unread_count(_current_user_id(request))
return NotificationUnreadCountResponse(unread=count)
except Exception as e:
logger.error("Failed to read unread notification count: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to read unread count: {e}")
@router.post(
"/notifications/{notification_id}/read",
response_model=MarkReadResponse,
summary="Mark Notification Read",
)
async def mark_read(
notification_id: str,
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> MarkReadResponse:
try:
ok = await store.mark_read(notification_id, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Notification not found")
return MarkReadResponse(success=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to mark notification read: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to mark read: {e}")
@router.post(
"/notifications/read_all",
response_model=MarkAllReadResponse,
summary="Mark All Notifications Read",
)
async def mark_all_read(
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> MarkAllReadResponse:
try:
count = await store.mark_all_read(_current_user_id(request))
return MarkAllReadResponse(updated=count)
except Exception as e:
logger.error("Failed to mark all notifications read: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to mark all read: {e}")
@router.delete(
"/notifications/{notification_id}",
response_model=MarkReadResponse,
summary="Delete Notification",
)
async def delete_notification(
notification_id: str,
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> MarkReadResponse:
try:
ok = await store.delete(notification_id, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Notification not found")
return MarkReadResponse(success=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete notification: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")
@@ -1,17 +1,22 @@
import json
import logging
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Body, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from app.gateway.deps import get_agent_store, get_config, get_skill_store
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.persistence.skills import SkillStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills import Skill
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
from deerflow.skills.installer import SkillAlreadyExistsError
from deerflow.skills.security_scanner import scan_skill_content
from deerflow.skills.storage import get_or_new_skill_storage
@@ -22,6 +27,56 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["skills"])
# ---------------------------------------------------------------------------
# Per-user authz helpers (mirrors routers/agents.py)
# ---------------------------------------------------------------------------
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _is_admin_user(request: Request) -> bool:
user = getattr(request.state, "user", None)
return getattr(user, "system_role", None) == "admin"
async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
owned = await store.get_owned(name, user_id)
if owned is not None:
return owned
visible = await store.get_visible(name, user_id)
if visible is None:
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
raise HTTPException(status_code=403, detail="You can only modify skills that you own")
async def _get_editable_skill_or_raise(
store: SkillStore,
name: str,
user_id: str,
*,
is_admin: bool,
) -> tuple[dict[str, Any], str]:
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
Admins can edit/delete any skill (including others' published ones, since
the spec lets them force-takedown). Non-admins are limited to their own.
"""
owned = await store.get_owned(name, user_id)
if owned is not None:
return owned, "owner"
record = await store.get_any(name)
if record is None:
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
if is_admin:
return record, "admin"
raise HTTPException(status_code=403, detail="You can only modify skills that you own")
class SkillResponse(BaseModel):
"""Response model for skill information."""
@@ -30,6 +85,16 @@ class SkillResponse(BaseModel):
license: str | None = Field(None, description="License information")
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
enabled: bool = Field(default=True, description="Whether this skill is enabled")
use_count: int = Field(default=0, description="Number of times the skill has been used")
view_count: int = Field(default=0, description="Number of times the skill has been viewed")
patch_count: int = Field(default=0, description="Number of times the skill has been patched")
last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp")
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
class SkillsListResponse(BaseModel):
@@ -49,6 +114,7 @@ class SkillInstallRequest(BaseModel):
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
class SkillInstallResponse(BaseModel):
@@ -58,6 +124,7 @@ class SkillInstallResponse(BaseModel):
skill_name: str = Field(..., description="Name of the installed skill")
message: str = Field(..., description="Installation result message")
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
class SkillUploadValidateResponse(BaseModel):
@@ -89,27 +156,85 @@ class SkillRollbackRequest(BaseModel):
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
def _skill_to_response(skill: Skill) -> SkillResponse:
"""Convert a Skill object to a SkillResponse."""
def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse:
"""Convert a Skill object to a SkillResponse.
``ownership`` is the skill's record from ``SkillStore`` (or None for public
built-in skills which never have a DB row). When present, contributes the
``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user
visibility and the frontend's owner badges.
"""
from deerflow.skills.usage import get_entry, latest_activity_at
entry = get_entry(skill.name)
last_at_str = latest_activity_at(skill.name)
last_at: datetime | None = None
if last_at_str:
try:
last_at = datetime.fromisoformat(last_at_str)
except Exception:
pass
owner_user_id: str | None = None
published = False
builtin = skill.category != SkillCategory.CUSTOM
if ownership is not None:
owner_user_id = ownership.get("owner_user_id")
published = bool(ownership.get("published", False))
builtin = bool(ownership.get("builtin", owner_user_id is None))
return SkillResponse(
name=skill.name,
description=skill.description,
license=skill.license,
category=skill.category,
enabled=skill.enabled,
use_count=entry.get("use_count", 0),
view_count=entry.get("view_count", 0),
patch_count=entry.get("patch_count", 0),
last_activity_at=last_at,
state=entry.get("state", "active"),
pinned=entry.get("pinned", False),
source=entry.get("source", "upload"),
owner_user_id=owner_user_id,
published=published,
builtin=builtin,
)
async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
"""Index visible-to-user skill records by name. Internal call cache for list endpoints."""
records = await skill_store.list_visible(user_id)
return {record["name"]: record for record in records}
@router.get(
"/skills",
response_model=SkillsListResponse,
summary="List All Skills",
description="Retrieve a list of all available skills from both public and custom directories.",
)
async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
async def list_skills(
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillsListResponse:
try:
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
user_id = _current_user_id(request)
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
ownership = await _build_ownership_index(skill_store, user_id)
responses: list[SkillResponse] = []
for skill in all_skills:
# Public/built-in skills (no DB row) are visible to everyone.
# Custom skills must have an ownership record in the visible set.
if skill.category != SkillCategory.CUSTOM:
responses.append(_skill_to_response(skill))
continue
record = ownership.get(skill.name)
if record is None:
continue
responses.append(_skill_to_response(skill, record))
return SkillsListResponse(skills=responses)
except Exception as e:
logger.error(f"Failed to load skills: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
@@ -194,14 +319,56 @@ async def validate_skill_upload(
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
)
async def install_skill_upload(
request: Request,
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
overwrite: bool = False,
target_category: SkillCategory = SkillCategory.CUSTOM,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallResponse:
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
# Only admins may install to the public (built-in) directory.
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
tmp_path = await _persist_uploaded_skill(file)
try:
storage = get_or_new_skill_storage(app_config=config)
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
# Pre-parse the archive to learn the skill name without writing anything,
# so we can reject the upload before touching the filesystem if another
# user already owns this name. Admins bypass the ownership check since
# they install to the shared public directory.
try:
validation = storage.validate_skill_archive(tmp_path)
preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
except Exception:
preview_name = None
if preview_name and not is_admin:
existing = await skill_store.get_any(preview_name)
if existing and existing.get("owner_user_id") not in (None, user_id):
raise HTTPException(
status_code=409,
detail=f"Skill '{preview_name}' already exists and is owned by another user",
)
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
except Exception:
pass
# For public installs (admin), register as built-in with no owner.
# For custom installs, register ownership under the uploading user.
if target_category == SkillCategory.PUBLIC:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": None, "published": True}
)
else:
# ``ensure_legacy`` is idempotent and only patches a NULL owner; it
# never overwrites an existing owner, so the pre-parse check above
# is what really guards cross-user steals.
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
except FileNotFoundError as e:
@@ -225,10 +392,33 @@ async def install_skill_upload(
summary="Install Skill",
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
)
async def install_skill(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
async def install_skill(
body: SkillInstallRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillInstallResponse:
try:
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
target_category = body.target_category
if target_category == SkillCategory.PUBLIC and not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
try:
from deerflow.skills.usage import mark_uploaded
mark_uploaded(result["skill_name"])
except Exception:
pass
if target_category == SkillCategory.PUBLIC:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": None, "published": True}
)
else:
await skill_store.ensure_legacy(
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
)
await refresh_skills_system_prompt_cache_async()
return SkillInstallResponse(**result)
except FileNotFoundError as e:
@@ -245,10 +435,21 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
async def list_custom_skills(
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillsListResponse:
try:
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
user_id = _current_user_id(request)
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
ownership = await _build_ownership_index(skill_store, user_id)
responses = [
_skill_to_response(skill, ownership[skill.name])
for skill in custom
if skill.name in ownership
]
return SkillsListResponse(skills=responses)
except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
@@ -294,6 +495,11 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
"scanner": {"decision": scan.decision, "reason": scan.reason},
},
)
try:
from deerflow.skills.usage import bump_patch
bump_patch(skill_name)
except Exception:
pass
await refresh_skills_system_prompt_cache_async()
return await get_custom_skill(skill_name, config)
except HTTPException:
@@ -308,9 +514,20 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
async def delete_custom_skill(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
agent_store=Depends(get_agent_store),
) -> dict[str, bool]:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete"
storage = get_or_new_skill_storage(app_config=config)
storage.delete_custom_skill(
skill_name,
@@ -324,8 +541,32 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
"scanner": {"decision": "allow", "reason": "Deletion requested."},
},
)
try:
from deerflow.skills.usage import forget
forget(skill_name)
except Exception:
pass
# Persist ownership record removal so the DB stays consistent.
if mode == "admin":
await skill_store.delete_admin(skill_name)
else:
await skill_store.delete(skill_name, user_id)
# Strip references from every agent that pulled this skill in,
# and notify their owners (no-op for self).
await cascade_skill_unpublished_or_deleted(
skill_name,
actor_user_id=user_id,
operation=operation,
skill_owner_user_id=record.get("owner_user_id"),
agent_store=agent_store,
)
await refresh_skills_system_prompt_cache_async()
return {"success": True}
except HTTPException:
raise
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
@@ -335,6 +576,161 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
class SkillPublishRequest(BaseModel):
"""Request body for toggling a custom skill's published flag."""
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
class SkillPublishResponse(BaseModel):
name: str
owner_user_id: str | None
published: bool
@router.put(
"/skills/custom/{skill_name}/published",
response_model=SkillPublishResponse,
summary="Toggle Custom Skill Published State",
)
async def update_custom_skill_published(
skill_name: str,
body: SkillPublishRequest,
request: Request,
skill_store: SkillStore = Depends(get_skill_store),
agent_store=Depends(get_agent_store),
) -> SkillPublishResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
was_published = bool(record.get("published", False))
if mode == "admin":
updated = await skill_store.update_admin(skill_name, {"published": body.published})
else:
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
if updated is None:
raise HTTPException(status_code=500, detail="Failed to persist published state")
# If we are *removing* visibility (was published, now not), strip from
# other users' agents and notify them. admin takedown is signaled by
# the actor not being the owner.
if was_published and not body.published:
skill_owner = record.get("owner_user_id")
operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish"
await cascade_skill_unpublished_or_deleted(
skill_name,
actor_user_id=user_id,
operation=operation,
skill_owner_user_id=skill_owner,
agent_store=agent_store,
)
await refresh_skills_system_prompt_cache_async()
return SkillPublishResponse(
name=updated["name"],
owner_user_id=updated.get("owner_user_id"),
published=bool(updated.get("published", False)),
)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
_ALLOWED_USER_STATES = {"active", "archived"}
class SkillStateRequest(BaseModel):
"""Request body for switching a custom skill's lifecycle state."""
state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)")
class SkillPinRequest(BaseModel):
"""Request body for pinning / unpinning a custom skill."""
pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill")
def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse:
"""Reload a single skill from disk and return its API response form."""
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found on disk")
return _skill_to_response(skill, ownership)
@router.put(
"/skills/custom/{skill_name}/state",
response_model=SkillResponse,
summary="Update Custom Skill Lifecycle State",
description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.",
)
async def update_custom_skill_state(
skill_name: str,
body: SkillStateRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
if body.state not in _ALLOWED_USER_STATES:
raise HTTPException(
status_code=400,
detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}",
)
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
from deerflow.skills.usage import set_state as _usage_set_state
_usage_set_state(skill_name, body.state)
await refresh_skills_system_prompt_cache_async()
return _reload_skill_response(skill_name, record, config)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update state: {e}")
@router.put(
"/skills/custom/{skill_name}/pinned",
response_model=SkillResponse,
summary="Pin or Unpin a Custom Skill",
description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.",
)
async def update_custom_skill_pinned(
skill_name: str,
body: SkillPinRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
from deerflow.skills.usage import set_pinned as _usage_set_pinned
_usage_set_pinned(skill_name, body.pinned)
return _reload_skill_response(skill_name, record, config)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
try:
@@ -469,3 +865,186 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
except Exception as e:
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
class SkillPinRequest(BaseModel):
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
@router.post("/skills/custom/{skill_name}/pin", response_model=SkillResponse, summary="Pin or Unpin Skill")
async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
"""Pin a skill to prevent it from being automatically archived by the curator."""
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
storage = get_or_new_skill_storage(app_config=config)
if not storage.custom_skill_exists(skill_name):
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
from deerflow.skills.usage import set_pinned
set_pinned(skill_name, request.pinned)
skills = storage.load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
return _skill_to_response(skill)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to pin skill %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to pin skill: {str(e)}")
@router.get("/curator/status", summary="Curator Status")
async def curator_status(request: Request) -> dict:
"""Return the current curator state and per-lifecycle skill counts for the
authenticated user."""
try:
from deerflow.skills.curator import get_curator_status
return get_curator_status(_current_user_id(request))
except Exception as e:
logger.error("Failed to get curator status: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}")
@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)."""
try:
import threading
from deerflow.skills.curator import _curator_thread_target
user_id = _current_user_id(request)
thread = threading.Thread(
target=_curator_thread_target,
args=(user_id,),
daemon=True,
name=f"skill-curator-manual-{user_id}",
)
thread.start()
return {"triggered": True, "message": "Curator run started in background"}
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)}")
class CuratorSubConfig(BaseModel):
"""Nested curator configuration mirroring CuratorConfig."""
enabled: bool = Field(default=True, 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.")
class CuratorConfigResponse(BaseModel):
"""Response model exposing the effective skill_evolution config."""
enabled: bool
moderation_model_name: str | None
curator: CuratorSubConfig
class CuratorConfigRequest(BaseModel):
"""Payload for updating skill_evolution overrides.
All fields are required so the frontend always sends a complete config;
backend writes the entire payload to skill_evolution.json atomically.
"""
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
class CuratorLogRecord(BaseModel):
ts: str
level: str
message: str
class CuratorLogsResponse(BaseModel):
records: list[CuratorLogRecord]
run_count: int
last_run_at: str | None
def _build_curator_config_response(evo) -> CuratorConfigResponse:
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
overrides if any).
"""
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,
),
)
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
async def get_curator_config(request: Request) -> CuratorConfigResponse:
"""Return the current curator config for the authenticated user (yaml
defaults + that user's overrides)."""
try:
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
evo = load_skill_evolution_config_for_user(_current_user_id(request))
return _build_curator_config_response(evo)
except Exception as e:
logger.error("Failed to read curator config: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}")
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
and return the effective merged config.
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
fields are rejected.
"""
try:
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
user_id = _current_user_id(request)
payload = body.model_dump()
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
SkillEvolutionConfig.model_validate(payload)
save_user_overrides(user_id, payload)
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update curator config: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update curator config: {str(e)}")
@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs")
async def get_curator_logs(request: Request) -> CuratorLogsResponse:
"""Return in-memory log records from the most recent curator run for the
authenticated user.
The buffer is process-local and cleared when curator starts a new run or
when the backend restarts. Records are ordered oldest → newest.
"""
try:
from deerflow.skills import curator_log
from deerflow.skills.curator import get_curator_status
user_id = _current_user_id(request)
records = curator_log.get_recent(user_id)
status = get_curator_status(user_id)
return CuratorLogsResponse(
records=[CuratorLogRecord(**r) for r in records],
run_count=int(status.get("run_count", 0)),
last_run_at=status.get("last_run_at"),
)
except Exception as e:
logger.error("Failed to read curator logs: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to read curator logs: {str(e)}")
@@ -191,14 +191,23 @@ def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
return ""
return """
## Skill Self-Evolution
After completing a task, consider creating or updating a skill when:
- The task required 5+ tool calls to resolve
- You overcame non-obvious errors or pitfalls
- The user corrected your approach and the corrected version worked
- You discovered a non-trivial, recurring workflow
If you used a skill and encountered issues not covered by it, patch it immediately.
Prefer patch over edit. Before creating a new skill, confirm with the user first.
Skip simple one-off tasks.
You have access to `skill_manage` (plus `skill_list` / `skill_view`).
After completing a non-trivial task — 5+ tool calls to resolve, recovery from
a non-obvious error, a corrected approach that worked, or a recurring
workflow — **offer to save the approach as a reusable skill** so you can
replay it next time. Briefly propose a hyphen-case name and a one-line
description, then ask the user to confirm before calling
`skill_manage(action="create", ...)`. Capture the generalised steps, not the
user's specific data, paths, or credentials.
When using an existing skill and finding it outdated, incomplete, or wrong,
**patch it immediately** with `skill_manage(action="patch")` — don't wait to
be asked. Skills that aren't maintained become liabilities.
Skip the offer for one-off queries, casual chat, or short single-tool
answers — and never create a skill silently without user confirmation.
"""
@@ -106,7 +106,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
messages[-1] = HumanMessage(
content=last_message.content,
id=last_message.id,
name=last_message.name or "user-input",
name=last_message.name,
additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()},
)
@@ -20,6 +20,8 @@ from deerflow.config.run_events_config import RunEventsConfig
from deerflow.config.runtime_paths import existing_project_file
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
@@ -54,6 +56,22 @@ def _legacy_config_candidates() -> tuple[Path, ...]:
return (backend_dir / "config.yaml", repo_root / "config.yaml")
def _deep_merge_dict(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
"""Deep-merge ``overrides`` onto a copy of ``base`` and return the result.
Nested dicts are merged recursively; any other value type in ``overrides``
replaces the corresponding value in ``base`` outright.
"""
merged: dict[str, Any] = dict(base)
for key, value in overrides.items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
merged[key] = _deep_merge_dict(existing, value)
else:
merged[key] = value
return merged
def logging_level_from_config(name: str | None) -> int:
"""Map ``config.yaml`` ``log_level`` string to a :mod:`logging` level constant."""
mapping = logging.getLevelNamesMapping()
@@ -204,6 +222,15 @@ class AppConfig(BaseModel):
extensions_config = ExtensionsConfig.from_file()
config_data["extensions"] = extensions_config.model_dump()
# Apply skill_evolution runtime overrides from skill_evolution.json
# (file is optional; load_overrides returns None if missing/invalid)
overrides = _load_skill_evolution_overrides()
if overrides:
yaml_section = config_data.get("skill_evolution") or {}
if not isinstance(yaml_section, dict):
yaml_section = {}
config_data["skill_evolution"] = _deep_merge_dict(yaml_section, overrides)
result = cls.model_validate(config_data)
return result
@@ -329,6 +356,7 @@ class AppConfig(BaseModel):
_app_config: AppConfig | None = None
_app_config_path: Path | None = None
_app_config_mtime: float | None = None
_app_config_skill_evolution_overrides_mtime: float | None = None
_app_config_is_custom = False
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=())
@@ -344,12 +372,13 @@ def _get_config_mtime(config_path: Path) -> float | None:
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
"""Load config from disk and refresh cache metadata."""
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
resolved_path = AppConfig.resolve_config_path(config_path)
_app_config = AppConfig.from_file(str(resolved_path))
_app_config_path = resolved_path
_app_config_mtime = _get_config_mtime(resolved_path)
_app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
_app_config_is_custom = False
return _app_config
@@ -373,8 +402,14 @@ def get_app_config() -> AppConfig:
resolved_path = AppConfig.resolve_config_path()
current_mtime = _get_config_mtime(resolved_path)
current_overrides_mtime = _get_skill_evolution_overrides_mtime()
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_mtime != current_mtime
should_reload = (
_app_config is None
or _app_config_path != resolved_path
or _app_config_mtime != current_mtime
or _app_config_skill_evolution_overrides_mtime != current_overrides_mtime
)
if should_reload:
if _app_config_path == resolved_path and _app_config_mtime is not None and current_mtime is not None and _app_config_mtime != current_mtime:
logger.info(
@@ -382,6 +417,12 @@ def get_app_config() -> AppConfig:
_app_config_mtime,
current_mtime,
)
elif _app_config_skill_evolution_overrides_mtime != current_overrides_mtime:
logger.info(
"skill_evolution.json overrides changed (mtime: %s -> %s), reloading AppConfig",
_app_config_skill_evolution_overrides_mtime,
current_overrides_mtime,
)
_load_and_cache_app_config(str(resolved_path))
return _app_config
@@ -409,10 +450,11 @@ def reset_app_config() -> None:
`get_app_config()` to reload from file. Useful for testing
or when switching between different configurations.
"""
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
_app_config = None
_app_config_path = None
_app_config_mtime = None
_app_config_skill_evolution_overrides_mtime = None
_app_config_is_custom = False
@@ -424,10 +466,11 @@ def set_app_config(config: AppConfig) -> None:
Args:
config: The AppConfig instance to use.
"""
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
_app_config = config
_app_config_path = None
_app_config_mtime = None
_app_config_skill_evolution_overrides_mtime = None
_app_config_is_custom = True
@@ -1,6 +1,15 @@
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.")
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.")
class SkillEvolutionConfig(BaseModel):
"""Configuration for agent-managed skill evolution."""
@@ -12,3 +21,7 @@ class SkillEvolutionConfig(BaseModel):
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.",
)
@@ -0,0 +1,216 @@
"""Runtime overrides for `skill_evolution` config, stored in skill_evolution.json.
Two scopes exist:
* **Global** (back-compat): ``backend/skill_evolution.json`` deep-merged onto
``config.yaml.skill_evolution`` at AppConfig load time. Used as a fallback
for users who never set a personal override.
* **Per-user**: ``.deer-flow/users/{user_id}/skill_evolution.json``. Read by
the curator on each run and by the ``GET/PUT /api/curator/config`` route
with the active user id. Curator merges this on top of the global config
via :func:`load_curator_config_for_user`.
The file is optional in both scopes; absence (or a malformed payload) is
treated as "no overrides" so the system keeps the YAML-only behaviour.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
from deerflow.config.runtime_paths import existing_project_file
logger = logging.getLogger(__name__)
_FILENAME = "skill_evolution.json"
def resolve_path() -> Path:
"""Resolve the on-disk location of ``skill_evolution.json``.
Priority:
1. ``DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH`` environment variable
2. ``skill_evolution.json`` in the project root (current and parents)
3. Legacy ``backend/`` or repo-root sibling locations
4. Falls back to ``backend/skill_evolution.json`` (does not need to exist)
"""
override = os.getenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH")
if override:
return Path(override)
project_file = existing_project_file((_FILENAME,))
if project_file is not None:
return project_file
backend_dir = Path(__file__).resolve().parents[4]
repo_root = backend_dir.parent
for candidate in (backend_dir / _FILENAME, repo_root / _FILENAME):
if candidate.exists():
return candidate
return backend_dir / _FILENAME
def load_overrides() -> dict[str, Any] | None:
"""Read overrides from disk.
Returns ``None`` when the file does not exist, is empty, contains
non-dict JSON, or fails to parse. Errors are logged at WARNING level
so production stays silent in the common "no overrides" case.
"""
path = resolve_path()
if not path.exists():
return None
try:
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
except OSError as exc:
logger.warning("Failed to read %s: %s", path, exc)
return None
except json.JSONDecodeError as exc:
logger.warning("Skill evolution overrides at %s are not valid JSON: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("Skill evolution overrides at %s must be a JSON object, got %s", path, type(data).__name__)
return None
return data
def save_overrides(payload: dict[str, Any]) -> Path:
"""Atomically persist ``payload`` to ``skill_evolution.json``.
The caller is responsible for shape validation (e.g. via the
``SkillEvolutionConfig`` Pydantic model). Returns the path that was
written so callers can log it.
"""
if not isinstance(payload, dict):
raise TypeError("skill_evolution overrides payload must be a dict")
path = resolve_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info("Wrote skill_evolution overrides to %s", path)
return path
def get_overrides_mtime() -> float | None:
"""Return the mtime of ``skill_evolution.json`` if it exists, else None.
Used by the AppConfig cache to decide whether to reload — when the
file appears, disappears, or is modified, the mtime tuple changes
and ``get_app_config()`` rebuilds.
"""
path = resolve_path()
try:
return path.stat().st_mtime
except OSError:
return None
# ---------------------------------------------------------------------------
# Per-user overrides
# ---------------------------------------------------------------------------
def resolve_user_path(user_id: str) -> Path:
"""Return ``{base_dir}/users/{user_id}/skill_evolution.json``."""
from deerflow.config.paths import get_paths
return get_paths().user_dir(user_id) / _FILENAME
def load_user_overrides(user_id: str) -> dict[str, Any] | None:
"""Read the per-user overrides file for ``user_id`` (None if missing/invalid)."""
path = resolve_user_path(user_id)
if not path.exists():
return None
try:
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
except OSError as exc:
logger.warning("Failed to read %s: %s", path, exc)
return None
except json.JSONDecodeError as exc:
logger.warning("User skill_evolution overrides at %s are not valid JSON: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("User skill_evolution overrides at %s must be a JSON object", path)
return None
return data
def save_user_overrides(user_id: str, payload: dict[str, Any]) -> Path:
"""Atomically persist ``payload`` to the per-user overrides file."""
if not isinstance(payload, dict):
raise TypeError("skill_evolution overrides payload must be a dict")
path = resolve_user_path(user_id)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path_str = tempfile.mkstemp(prefix=".skill-evolution-", suffix=".tmp", dir=str(path.parent))
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info("Wrote per-user skill_evolution overrides to %s", path)
return path
def get_user_overrides_mtime(user_id: str) -> float | None:
path = resolve_user_path(user_id)
try:
return path.stat().st_mtime
except OSError:
return None
def load_skill_evolution_config_for_user(user_id: str | None):
"""Return a ``SkillEvolutionConfig`` instance with per-user overrides merged
on top of the global AppConfig defaults.
Used by the curator + the per-user ``GET/PUT /api/curator/config`` route.
When ``user_id`` is None or the per-user file is absent, returns the global
config unchanged.
"""
from deerflow.config import get_app_config
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
base = get_app_config().skill_evolution
if not user_id:
return base
overrides = load_user_overrides(user_id)
if not overrides:
return base
merged = base.model_dump()
for key, value in overrides.items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
merged[key] = {**existing, **value}
else:
merged[key] = value
try:
return SkillEvolutionConfig.model_validate(merged)
except Exception:
logger.warning("Per-user skill_evolution overrides for %s failed validation, ignoring", user_id, exc_info=True)
return base
@@ -56,3 +56,8 @@ class AgentStore(ABC):
any user identity and returns ``False`` if the target is not a built-in.
"""
raise NotImplementedError
@abstractmethod
async def count_owned(self, user_id: str) -> int:
"""Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
raise NotImplementedError
@@ -114,3 +114,6 @@ class MemoryAgentStore(AgentStore):
return False
self._agents.pop(agent_id, None)
return True
async def count_owned(self, user_id: str) -> int:
return sum(1 for a in self._agents.values() if a.get("user_id") == user_id)
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore
@@ -137,3 +137,8 @@ class AgentRepository(AgentStore):
await session.delete(row)
await session.commit()
return True
async def count_owned(self, user_id: str) -> int:
stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id)
async with self._sf() as session:
return await session.scalar(stmt) or 0
@@ -19,6 +19,7 @@ from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import (
@@ -28,6 +29,7 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
from deerflow.persistence.skills.model import SkillRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow
@@ -35,6 +37,7 @@ __all__ = [
"FeedbackRow",
"AgentRow",
"LlmCallMetricRow",
"NotificationRow",
"RecommendedQuestionRow",
"RunEventRow",
"RunRow",
@@ -43,6 +46,7 @@ __all__ = [
"ScheduledTaskRunRow",
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
"ThreadMetaRow",
"UserRow",
]
@@ -0,0 +1,26 @@
"""Persistence layer for per-user in-app notifications."""
from deerflow.persistence.notifications.base import NotificationStore
from deerflow.persistence.notifications.memory import MemoryNotificationStore
from deerflow.persistence.notifications.sender import (
get_notification_store,
notify,
set_notification_store,
)
from deerflow.persistence.notifications.sql import NotificationRepository
__all__ = [
"NotificationStore",
"MemoryNotificationStore",
"NotificationRepository",
"notify",
"set_notification_store",
"get_notification_store",
"make_notification_store",
]
def make_notification_store(session_factory):
if session_factory is None:
return MemoryNotificationStore()
return NotificationRepository(session_factory)
@@ -0,0 +1,42 @@
"""Abstract persistence API for in-app notifications."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class NotificationStore(ABC):
@abstractmethod
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
"""Insert a notification record. ``data`` must include ``id``, ``user_id``, ``type``."""
raise NotImplementedError
@abstractmethod
async def list_for_user(
self,
user_id: str,
*,
unread_only: bool = False,
limit: int = 50,
) -> list[dict[str, Any]]:
"""Return newest-first records for ``user_id``."""
raise NotImplementedError
@abstractmethod
async def unread_count(self, user_id: str) -> int:
raise NotImplementedError
@abstractmethod
async def mark_read(self, notification_id: str, user_id: str) -> bool:
"""Mark a single notification as read; returns False if not owned by user."""
raise NotImplementedError
@abstractmethod
async def mark_all_read(self, user_id: str) -> int:
"""Mark every unread notification of ``user_id`` as read; returns count updated."""
raise NotImplementedError
@abstractmethod
async def delete(self, notification_id: str, user_id: str) -> bool:
raise NotImplementedError
@@ -0,0 +1,70 @@
"""In-memory ``NotificationStore`` implementation for tests."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.notifications.base import NotificationStore
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
class MemoryNotificationStore(NotificationStore):
def __init__(self) -> None:
self._items: dict[str, dict[str, Any]] = {}
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
record = {
"id": data["id"],
"user_id": data["user_id"],
"type": data["type"],
"title": data.get("title") or "",
"body": data.get("body") or "",
"payload": data.get("payload") or {},
"read": False,
"created_at": _now_iso(),
}
self._items[record["id"]] = record
return copy.deepcopy(record)
async def list_for_user(
self,
user_id: str,
*,
unread_only: bool = False,
limit: int = 50,
) -> list[dict[str, Any]]:
filtered = [r for r in self._items.values() if r["user_id"] == user_id]
if unread_only:
filtered = [r for r in filtered if not r["read"]]
filtered.sort(key=lambda r: r["created_at"], reverse=True)
return [copy.deepcopy(r) for r in filtered[:limit]]
async def unread_count(self, user_id: str) -> int:
return sum(1 for r in self._items.values() if r["user_id"] == user_id and not r["read"])
async def mark_read(self, notification_id: str, user_id: str) -> bool:
record = self._items.get(notification_id)
if record is None or record["user_id"] != user_id:
return False
record["read"] = True
return True
async def mark_all_read(self, user_id: str) -> int:
updated = 0
for record in self._items.values():
if record["user_id"] == user_id and not record["read"]:
record["read"] = True
updated += 1
return updated
async def delete(self, notification_id: str, user_id: str) -> bool:
record = self._items.get(notification_id)
if record is None or record["user_id"] != user_id:
return False
del self._items[notification_id]
return True
@@ -0,0 +1,24 @@
"""ORM model for in-app notifications."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import JSON, Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class NotificationRow(Base):
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str] = mapped_column(String(256), nullable=False, default="")
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), index=True)
@@ -0,0 +1,72 @@
"""Helper for writing notifications without callers having to thread a store.
The harness can NOT import ``app.*`` (firewall), so the Gateway app injects
its ``NotificationStore`` instance via ``set_notification_store(...)`` at
startup. Until that happens, every call is a no-op (silently logged) so
non-Gateway entry points (tests, embedded client) don't crash on missing
infrastructure.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from deerflow.persistence.notifications.base import NotificationStore
logger = logging.getLogger(__name__)
_store: NotificationStore | None = None
def set_notification_store(store: NotificationStore | None) -> None:
"""Inject the active ``NotificationStore`` instance for the process.
Pass ``None`` to clear (e.g. on shutdown or in tests).
"""
global _store
_store = store
def get_notification_store() -> NotificationStore | None:
return _store
async def notify(
user_id: str,
*,
type: str,
title: str = "",
body: str = "",
payload: dict[str, Any] | None = None,
) -> None:
"""Write a notification for ``user_id``. Never raises — failures are logged.
Args:
user_id: Recipient.
type: Stable identifier (e.g. ``agent_skill_removed``, ``skill_taken_down``).
title: One-line title shown in the inbox.
body: Longer description shown when the user expands the entry.
payload: Structured context (skill_name, agent_id, actor_user_id, ...).
"""
try:
if _store is None:
logger.warning(
"notify(user_id=%s, type=%s) dropped — NotificationStore not configured",
user_id,
type,
)
return
await _store.create(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"type": type,
"title": title,
"body": body,
"payload": payload or {},
}
)
except Exception:
logger.exception("Failed to write notification (user_id=%s, type=%s)", user_id, type)
@@ -0,0 +1,97 @@
"""SQLAlchemy-backed notification repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.notifications.base import NotificationStore
from deerflow.persistence.notifications.model import NotificationRow
def _row_to_dict(row: NotificationRow) -> dict[str, Any]:
data = row.to_dict()
value = data.get("created_at")
if isinstance(value, datetime):
data["created_at"] = value.isoformat()
return data
class NotificationRepository(NotificationStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
row = NotificationRow(
id=data["id"],
user_id=data["user_id"],
type=data["type"],
title=data.get("title") or "",
body=data.get("body") or "",
payload=data.get("payload") or {},
read=False,
created_at=datetime.now(UTC),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def list_for_user(
self,
user_id: str,
*,
unread_only: bool = False,
limit: int = 50,
) -> list[dict[str, Any]]:
stmt = select(NotificationRow).where(NotificationRow.user_id == user_id)
if unread_only:
stmt = stmt.where(NotificationRow.read.is_(False))
stmt = stmt.order_by(NotificationRow.created_at.desc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def unread_count(self, user_id: str) -> int:
stmt = (
select(func.count())
.select_from(NotificationRow)
.where(NotificationRow.user_id == user_id, NotificationRow.read.is_(False))
)
async with self._sf() as session:
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def mark_read(self, notification_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(NotificationRow, notification_id)
if row is None or row.user_id != user_id:
return False
if not row.read:
row.read = True
await session.commit()
return True
async def mark_all_read(self, user_id: str) -> int:
stmt = (
update(NotificationRow)
.where(NotificationRow.user_id == user_id, NotificationRow.read.is_(False))
.values(read=True)
)
async with self._sf() as session:
result = await session.execute(stmt)
await session.commit()
return int(result.rowcount or 0)
async def delete(self, notification_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(NotificationRow, notification_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,13 @@
"""Persistence layer for custom skills (per-user ownership + publish state)."""
from deerflow.persistence.skills.base import SkillStore
from deerflow.persistence.skills.memory import MemorySkillStore
from deerflow.persistence.skills.sql import SkillRepository
__all__ = ["SkillStore", "MemorySkillStore", "SkillRepository", "make_skill_store"]
def make_skill_store(session_factory):
if session_factory is None:
return MemorySkillStore()
return SkillRepository(session_factory)
@@ -0,0 +1,69 @@
"""Abstract persistence API for custom skill ownership + publish state."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class SkillStore(ABC):
@abstractmethod
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
"""List all skill records visible to ``user_id``.
Visible = owner, OR published by anyone, OR built-in (owner_user_id IS NULL).
"""
raise NotImplementedError
@abstractmethod
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def get_any(self, name: str) -> dict[str, Any] | None:
"""Lookup by name regardless of ownership/visibility (admin / cascade use)."""
raise NotImplementedError
@abstractmethod
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create a skill record. Caller is responsible for global-name uniqueness check."""
raise NotImplementedError
@abstractmethod
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
"""Idempotently insert a legacy on-disk skill into the DB. Updates the row if it
already exists with ``owner_user_id IS NULL`` (so a re-run can migrate ownership).
"""
raise NotImplementedError
@abstractmethod
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Owner-only update. Returns None if the caller doesn't own the skill."""
raise NotImplementedError
@abstractmethod
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Admin override — caller is responsible for auth check.
Unlike ``update_builtin`` for agents, this accepts ANY skill including
user-owned ones, since the spec lets admin take down others' skills.
"""
raise NotImplementedError
@abstractmethod
async def delete(self, name: str, user_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def delete_admin(self, name: str) -> bool:
"""Admin-side delete; bypasses ownership check (caller authorizes)."""
raise NotImplementedError
@abstractmethod
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
@@ -0,0 +1,129 @@
"""In-memory ``SkillStore`` implementation for tests."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.skills.base import SkillStore
def _now() -> str:
return datetime.now(UTC).isoformat()
def _copy(record: dict[str, Any] | None) -> dict[str, Any] | None:
return copy.deepcopy(record) if record is not None else None
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
out = dict(record)
out["builtin"] = out.get("owner_user_id") is None
return out
class MemorySkillStore(SkillStore):
def __init__(self) -> None:
self._skills: dict[str, dict[str, Any]] = {}
@staticmethod
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
owner = record.get("owner_user_id")
if owner is None:
return True
if owner == user_id:
return True
return bool(record.get("published"))
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
return [
_with_derived(_copy(record)) # type: ignore[arg-type]
for record in self._skills.values()
if self._is_visible(record, user_id)
]
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None or not self._is_visible(record, user_id):
return None
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def get_any(self, name: str) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None:
return None
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None or record.get("owner_user_id") != user_id:
return None
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
name = data["name"]
if name in self._skills:
raise ValueError(f"Skill '{name}' already exists")
record = {
"name": name,
"owner_user_id": data.get("owner_user_id"),
"published": bool(data.get("published", False)),
"created_at": _now(),
"updated_at": _now(),
}
self._skills[name] = record
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
name = data["name"]
record = self._skills.get(name)
if record is None:
record = {
"name": name,
"owner_user_id": data.get("owner_user_id"),
"published": bool(data.get("published", True)),
"created_at": _now(),
"updated_at": _now(),
}
self._skills[name] = record
elif record.get("owner_user_id") is None and data.get("owner_user_id") is not None:
record["owner_user_id"] = data["owner_user_id"]
record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None or record.get("owner_user_id") != user_id:
return None
if "published" in data:
record["published"] = bool(data["published"])
record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
record = self._skills.get(name)
if record is None:
return None
if "published" in data:
record["published"] = bool(data["published"])
if "owner_user_id" in data:
record["owner_user_id"] = data["owner_user_id"]
record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type]
async def delete(self, name: str, user_id: str) -> bool:
record = self._skills.get(name)
if record is None or record.get("owner_user_id") != user_id:
return False
del self._skills[name]
return True
async def delete_admin(self, name: str) -> bool:
if name not in self._skills:
return False
del self._skills[name]
return True
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)
@@ -0,0 +1,28 @@
"""ORM model for custom skill ownership + publish state.
Note: skill content lives on disk under ``skills/custom/{name}/``; this table
only stores the metadata (owner, published flag, timestamps). The skill
``name`` doubles as the primary key because the filesystem path is already
keyed by name, and we require global name uniqueness across all owners.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class SkillRow(Base):
__tablename__ = "skills"
# Global unique name — must match the on-disk folder name under skills/custom/.
# Hyphen-case or snake_case allowed; see deerflow.skills.validation.
name: Mapped[str] = mapped_column(String(64), primary_key=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,144 @@
"""SQLAlchemy-backed custom skill repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.skills.base import SkillStore
from deerflow.persistence.skills.model import SkillRow
def _row_to_dict(row: SkillRow) -> dict[str, Any]:
data = row.to_dict()
data["builtin"] = data.get("owner_user_id") is None
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class SkillRepository(SkillStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _visible_filter(user_id: str):
return or_(
SkillRow.owner_user_id.is_(None),
SkillRow.owner_user_id == user_id,
SkillRow.published.is_(True),
)
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
stmt = select(SkillRow).where(self._visible_filter(user_id)).order_by(SkillRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def get_visible(self, name: str, user_id: str) -> dict[str, Any] | None:
stmt = select(SkillRow).where(SkillRow.name == name, self._visible_filter(user_id))
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _row_to_dict(row) if row is not None else None
async def get_any(self, name: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(SkillRow, name)
return _row_to_dict(row) if row is not None else None
async def get_owned(self, name: str, user_id: str) -> dict[str, Any] | None:
stmt = select(SkillRow).where(SkillRow.name == name, SkillRow.owner_user_id == user_id)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _row_to_dict(row) if row is not None else None
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = SkillRow(
name=data["name"],
owner_user_id=data.get("owner_user_id"),
published=bool(data.get("published", False)),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def ensure_legacy(self, data: dict[str, Any]) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(SkillRow, data["name"])
if row is None:
now = datetime.now(UTC)
row = SkillRow(
name=data["name"],
owner_user_id=data.get("owner_user_id"),
published=bool(data.get("published", True)),
created_at=now,
updated_at=now,
)
session.add(row)
elif row.owner_user_id is None and data.get("owner_user_id") is not None:
# Only patch ownership if it was previously null — never overwrite
# an existing owner record on a re-migration.
row.owner_user_id = data["owner_user_id"]
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update(self, name: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None or row.owner_user_id != user_id:
return None
if "published" in data:
row.published = bool(data["published"])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update_admin(self, name: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None:
return None
if "published" in data:
row.published = bool(data["published"])
if "owner_user_id" in data:
row.owner_user_id = data["owner_user_id"]
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, name: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None or row.owner_user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
async def delete_admin(self, name: str) -> bool:
async with self._sf() as session:
row = await session.get(SkillRow, name)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def count_owned(self, user_id: str) -> int:
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
@@ -0,0 +1,151 @@
"""Cascade handling when a published skill is removed from circulation.
Called from the Gateway skills router whenever a published skill is
unpublished, deleted, or admin-taken-down. Walks every agent on disk that
referenced the skill, strips the skill from its ``config.yaml`` ``skills``
list, and emits an in-app notification to that agent's owner.
The harness/app firewall means we cannot import the FastAPI app side; the
``AgentStore`` instance must be passed in by the caller (the router gets
it from ``app.gateway.deps.get_agent_store``).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Literal
import yaml
from deerflow.config.agents_config import load_agent_config
from deerflow.config.paths import get_paths
from deerflow.persistence.notifications import notify
logger = logging.getLogger(__name__)
CascadeOperation = Literal["unpublish", "delete", "admin_takedown"]
def _list_agent_dirs() -> list[Path]:
agents_dir = get_paths().agents_dir
if not agents_dir.exists():
return []
return [entry for entry in sorted(agents_dir.iterdir()) if entry.is_dir()]
def _remove_skill_from_yaml(config_file: Path, skill_name: str) -> bool:
"""Rewrite ``config.yaml`` removing ``skill_name`` from its ``skills`` list.
Returns True if the file was modified, False otherwise.
"""
if not config_file.exists():
return False
try:
with config_file.open("r", encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except yaml.YAMLError:
logger.warning("Cascade: failed to parse %s, skipping", config_file)
return False
skills = data.get("skills")
if not isinstance(skills, list) or skill_name not in skills:
return False
data["skills"] = [s for s in skills if s != skill_name]
with config_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
return True
_OP_LABELS: dict[CascadeOperation, str] = {
"unpublish": "取消发布",
"delete": "删除",
"admin_takedown": "管理员强制下架",
}
async def cascade_skill_unpublished_or_deleted(
skill_name: str,
*,
actor_user_id: str,
operation: CascadeOperation,
skill_owner_user_id: str | None,
agent_store: Any,
) -> dict[str, Any]:
"""Detach ``skill_name`` from every referencing agent and notify owners.
Self-references (agent.user_id == actor_user_id) are still detached but
the owner gets no in-app notification — the user just did the action,
they know.
On ``admin_takedown`` we additionally notify ``skill_owner_user_id``.
Returns counters for testability: ``{checked, modified, notified}``.
"""
counters = {"checked": 0, "modified": 0, "notified": 0}
label = _OP_LABELS.get(operation, operation)
for agent_dir in _list_agent_dirs():
config_file = agent_dir / "config.yaml"
if not config_file.exists():
continue
counters["checked"] += 1
# Quick read to decide whether we need to mutate at all.
try:
agent_cfg = load_agent_config(agent_dir.name)
except Exception:
logger.debug("Cascade: failed to load %s, skipping", agent_dir.name, exc_info=True)
continue
if agent_cfg is None or not agent_cfg.skills or skill_name not in agent_cfg.skills:
continue
modified = _remove_skill_from_yaml(config_file, skill_name)
if not modified:
continue
counters["modified"] += 1
logger.info(
"Cascade: removed skill '%s' from agent '%s/config.yaml' (op=%s)",
skill_name,
agent_dir.name,
operation,
)
# Look up agent owner from DB to decide where to send the notification.
record = await agent_store.get_any(agent_dir.name)
agent_owner = record.get("user_id") if record else None
if agent_owner and agent_owner != actor_user_id:
agent_display = (record.get("name") if record else None) or agent_dir.name
await notify(
agent_owner,
type="agent_skill_removed",
title=f"专家 “{agent_display}” 的一个技能已失效",
body=(
f"技能 “{skill_name}” 被 {label},已从该专家的技能列表中自动移除。"
),
payload={
"skill_name": skill_name,
"agent_id": agent_dir.name,
"agent_name": agent_display,
"operation": operation,
"actor_user_id": actor_user_id,
},
)
counters["notified"] += 1
if operation == "admin_takedown" and skill_owner_user_id and skill_owner_user_id != actor_user_id:
await notify(
skill_owner_user_id,
type="skill_taken_down",
title=f"你的技能 “{skill_name}” 已被管理员下架",
body="管理员对该技能执行了强制下架。如有疑问请联系管理员。",
payload={
"skill_name": skill_name,
"operation": operation,
"actor_user_id": actor_user_id,
},
)
counters["notified"] += 1
return counters
@@ -0,0 +1,835 @@
"""Skill curator — automatic lifecycle transitions and LLM consolidation pass.
Per-user since the multitenancy upgrade: every public function takes a
``user_id`` argument and the on-disk state lives under
``{base_dir}/users/{user_id}/.curator_state`` (alongside the per-user
memory.json and threads/ dirs).
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import logging
import os
import re
import tempfile
import threading
from collections.abc import Coroutine
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, TypeVar
logger = logging.getLogger(__name__)
_CURATOR_STATE_FILENAME = ".curator_state"
_T = TypeVar("_T")
def _run_async_blocking(coro: Coroutine[Any, Any, _T]) -> _T:
"""Run an async coroutine to completion from sync curator code.
Curator is invoked from a background thread, but ``agent.invoke()`` inside
``_run_llm_review`` (LangChain 1.x ``create_agent``) spins up its own
asyncio loop which can leave the calling thread in an "already has a
running loop" state. In that case raw ``asyncio.run(coro)`` raises
``RuntimeError`` *after* the coroutine object has been materialised but
before anyone awaits it — producing the ``coroutine was never awaited``
RuntimeWarning that pollutes our logs.
This helper detects a running loop in the caller's thread and offloads
to a dedicated worker thread; otherwise it falls back to ``asyncio.run``.
Either way it guarantees the coroutine is closed on the failure path,
so no RuntimeWarning ever escapes.
"""
try:
asyncio.get_running_loop()
in_loop = True
except RuntimeError:
in_loop = False
if in_loop:
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
except BaseException:
# ``asyncio.run`` ran in the worker (it consumes the coroutine on
# success or surfaces the inner exception); but if the executor
# itself failed to submit, close to be safe.
try:
coro.close()
except Exception:
pass
raise
try:
return asyncio.run(coro)
except BaseException:
try:
coro.close()
except Exception:
pass
raise
def _get_skills_root() -> Path:
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path())
def _curator_state_path(user_id: str | None) -> Path:
"""Return the state file path. Per-user when user_id is known; falls back
to the global ``skills/.curator_state`` for legacy callers."""
if not user_id:
return _get_skills_root() / _CURATOR_STATE_FILENAME
from deerflow.config.paths import get_paths
return get_paths().user_dir(user_id) / _CURATOR_STATE_FILENAME
def _read_curator_state(user_id: str | None) -> dict:
path = _curator_state_path(user_id)
if not path.exists():
return {"run_count": 0, "paused": False}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"run_count": 0, "paused": False}
def _write_curator_state(state: dict, user_id: str | None) -> None:
path = _curator_state_path(user_id)
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, default=str)
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except Exception:
pass
raise
except Exception:
logger.debug("Failed to write curator state", exc_info=True)
def _get_curator_config(user_id: str | None = None):
"""Return the curator config object for ``user_id``.
Reads ``config.yaml.skill_evolution.curator`` as the baseline and merges
the per-user override file (``users/{user_id}/skill_evolution.json``) on
top. With ``user_id=None`` falls back to the global default.
"""
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 getattr(skill_evo, "curator", None)
except Exception:
return None
def _list_user_owned_skill_names(user_id: str) -> set[str] | None:
"""Return skill names owned by ``user_id`` (used to filter transitions).
Returns None if the SkillStore is unreachable — curator then degrades to
"process every on-disk agent-created skill" (legacy behaviour).
"""
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)
async def _run() -> list[dict]:
return await store.list_visible(user_id)
records = _run_async_blocking(_run())
return {r["name"] for r in records if r.get("owner_user_id") == user_id}
except Exception:
logger.debug("_list_user_owned_skill_names failed", exc_info=True)
return None
def _collect_referenced_skill_names() -> set[str]:
"""Scan every agent's config.yaml and return the union of their ``skills``
fields.
Used as the "do not touch" set during curator transitions and LLM
candidate selection — any skill currently bound to *any* agent
(including the owner's own) is exempt from automatic stale/archive
and from being suggested for merging.
Returns an empty set on filesystem errors (best-effort, non-fatal).
"""
import yaml
from deerflow.config.paths import get_paths
referenced: set[str] = set()
try:
agents_dir = get_paths().agents_dir
except Exception:
return referenced
if not agents_dir.exists():
return referenced
for entry in agents_dir.iterdir():
if not entry.is_dir():
continue
config_file = entry / "config.yaml"
if not config_file.exists():
continue
try:
with config_file.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except Exception:
logger.debug("_collect_referenced_skill_names: failed to parse %s", config_file, exc_info=True)
continue
skills = data.get("skills")
if isinstance(skills, list):
for s in skills:
if isinstance(s, str):
referenced.add(s)
return referenced
def _is_skill_published(skill_name: str) -> bool:
"""Return True if ``skill_name`` is marked ``published=True`` in SkillStore.
Best-effort — returns False on any error so a transient DB hiccup never
causes a published skill to be wrongly archived (the caller treats
"published=True" as exempt, so False = "fail open to processing", which
only puts a skill *back* into curator scope when DB is unavailable; in
that degraded mode curator legacy behaviour applies).
"""
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 False
store = make_skill_store(factory)
async def _run() -> dict | None:
return await store.get_any(skill_name)
record = _run_async_blocking(_run())
return bool(record and record.get("published", False))
except Exception:
logger.debug("_is_skill_published(%s) failed", skill_name, exc_info=True)
return False
def _eligible_for_curation(
name: str,
entry: dict,
*,
owned_set: set[str] | None,
referenced_set: set[str],
) -> bool:
"""Three-condition gate for whether curator may touch a skill.
Curator only acts on skills that ALL of:
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)
``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).
"""
if entry.get("source") != "agent":
return False
if owned_set is not None and name not in owned_set:
return False
if name in referenced_set:
return False
if _is_skill_published(name):
return False
return True
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):
return False
state = _read_curator_state(user_id)
if state.get("paused"):
return False
last_run = state.get("last_run_at")
if not last_run:
return True
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:
return True
def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict:
"""Pure age-based state transitions — no LLM involved.
Only processes agent-created skills that are ALL of:
- owned by ``user_id``
- not referenced by any agent's ``skills`` config
- not currently published
Skips pinned skills. Returns counters: {marked_stale, archived, checked,
skipped_referenced, skipped_published}.
"""
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries, latest_activity_at, set_state
if now is None:
now = datetime.now(UTC)
curator_cfg = _get_curator_config()
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()
raw_entries = agent_created_entries()
skipped_referenced = 0
skipped_published = 0
eligible: dict[str, dict] = {}
for name, entry in raw_entries.items():
if owned is not None and name not in owned:
continue
if name in referenced:
skipped_referenced += 1
continue
if _is_skill_published(name):
skipped_published += 1
continue
eligible[name] = entry
counters = {
"marked_stale": 0,
"archived": 0,
"checked": len(eligible),
"skipped_referenced": skipped_referenced,
"skipped_published": skipped_published,
}
from deerflow.skills import curator_log
for name, entry in eligible.items():
if entry.get("pinned"):
continue
last_activity_str = latest_activity_at(name)
if not last_activity_str:
continue
try:
last_dt = datetime.fromisoformat(last_activity_str)
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=UTC)
except Exception:
continue
days_idle = (now - last_dt).days
current_state = entry.get("state", STATE_ACTIVE)
if days_idle >= archive_days and current_state != STATE_ARCHIVED:
set_state(name, STATE_ARCHIVED)
counters["archived"] += 1
curator_log.record(
"info",
f" 自动归档 [{name}]:已闲置 {days_idle} 天(阈值 {archive_days} 天)",
user_id=user_id,
)
elif days_idle >= stale_days and current_state == STATE_ACTIVE:
set_state(name, STATE_STALE)
counters["marked_stale"] += 1
curator_log.record(
"info",
f" 标记过时 [{name}]:已闲置 {days_idle} 天(阈值 {stale_days} 天)",
user_id=user_id,
)
return counters
def _render_candidate_list(user_id: str) -> str:
"""List of skill candidates handed to the LLM for merge/archive review.
Same three-condition gate as ``apply_automatic_transitions``: only
agent-created, not-referenced-by-any-agent, not-published skills owned
by ``user_id`` show up here. The LLM literally cannot see (and thus
cannot suggest action on) anything outside that set.
"""
from deerflow.skills.usage import STATE_ARCHIVED, all_entries
owned = _list_user_owned_skill_names(user_id)
referenced = _collect_referenced_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):
continue
if v.get("state") == STATE_ARCHIVED:
continue
entries[k] = v
if not entries:
return "No agent-created active skills found."
lines = []
for name, entry in sorted(entries.items()):
state = entry.get("state", "active")
use = entry.get("use_count", 0)
view = entry.get("view_count", 0)
patch = entry.get("patch_count", 0)
last = entry.get("last_used_at") or entry.get("created_at") or "unknown"
lines.append(f"- {name} [state={state}, use={use}, view={view}, patches={patch}, last_active={last}]")
return "\n".join(lines)
_CURATOR_REVIEW_PROMPT = (
"You are running as DeerFlow's background skill CURATOR. This is an "
"UMBRELLA-BUILDING consolidation pass, not a passive audit and not a "
"duplicate-finder.\n\n"
"The goal of the skill collection is a LIBRARY OF CLASS-LEVEL "
"INSTRUCTIONS AND EXPERIENTIAL KNOWLEDGE. A collection of hundreds of "
"narrow skills where each one captures one session's specific bug is "
"a FAILURE of the library — not a feature. An agent searching skills "
"matches on descriptions, not on exact names; one broad umbrella "
"skill with labeled subsections beats five narrow siblings for "
"discoverability, not the other way around.\n\n"
"The right target shape is CLASS-LEVEL skills with rich SKILL.md "
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
"Hard rules — do not violate:\n"
"1. DO NOT touch public (built-in) skills. The candidate list below "
"is already filtered to agent-created custom skills only.\n"
"2. DO NOT delete any skill. Archiving (a soft state flip via the "
"skill_archive tool — the file stays in custom/<name>/ and the user "
"can restore it from the Web UI) is the maximum action you may take. "
"Archives are recoverable; deletion is not. "
"When you call skill_archive(name, reason) you MUST pass a `reason` "
"string written **in Chinese**, in plain language a human operator "
"would read — NOT English jargon or terse debug notes. Examples of "
"acceptable reasons: '已合并到 code-formatter,该技能同时覆盖 Python 与 JS', "
"'与 foo-bar 内容重复,保留 foo-bar', '重构后过时,被 api-v2-search 替代', "
"'一次性 POC 实验,无复用价值'. Bad reasons (do NOT use): 'merged', "
"'duplicate', 'obsolete', 'consolidated'. The reason is shown in the "
"Web UI run-history for user audit, so write it as you would explain "
"it to the user themselves.\n"
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
"4. DO NOT use usage counters as a reason to skip consolidation. The "
"counters are new and often mostly zero. Judge overlap on CONTENT, "
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
"absence of evidence either way.\n"
"5. DO NOT reject consolidation on the grounds that 'each skill has "
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
"right bar is: 'would a human maintainer write this as N separate "
"skills, or as one skill with N labeled subsections?' When the "
"answer is the latter, merge.\n\n"
"How to work — not optional:\n"
"1. Scan the full candidate list. Identify PREFIX CLUSTERS (skills "
"sharing a first word or domain keyword). Examples you are likely "
"to find: search-*, code-*, data-*, api-*, file-*, analysis-*, "
"workflow-*, report-*, monitor-*, debug-*, python-*, security-*, etc. "
"Expect 10-25 clusters.\n"
"2. For each cluster with 2+ members, do NOT ask 'are these pairs "
"overlapping?' — ask 'what is the UMBRELLA CLASS these skills all "
"serve? Would a maintainer name that class and write one skill for "
"it?' If yes, pick (or create) the umbrella and absorb the siblings "
"into it.\n"
"3. Three ways to consolidate — use the right one per cluster:\n"
" a. MERGE INTO EXISTING UMBRELLA — one skill in the cluster is "
"already broad enough to be the umbrella. Patch it to add a labeled "
"section for each sibling's unique insight, then archive the "
"siblings.\n"
" b. CREATE A NEW UMBRELLA SKILL.md — no existing member is broad "
"enough. Create a new class-level skill whose SKILL.md covers the "
"shared workflow and has short labeled subsections. Archive the "
"now-absorbed narrow siblings.\n"
" c. DEMOTE TO REFERENCES/TEMPLATES/SCRIPTS — a sibling has "
"narrow-but-valuable session-specific content. Move it into the "
"umbrella's appropriate support directory:\n"
" • `references/<topic>.md` for session-specific detail OR "
"condensed knowledge banks (quoted research, API docs excerpts, "
"domain notes, provider quirks, reproduction recipes)\n"
" • `templates/<name>.<ext>` for starter files meant to be "
"copied and modified\n"
" • `scripts/<name>.<ext>` for statically re-runnable actions "
"(verification scripts, fixture generators, probes)\n"
" Then archive the old sibling.\n"
"4. Also flag skills whose NAME is too narrow (contains a specific "
"issue number, a feature codename, a specific error string, an "
"'audit' / 'diagnosis' / 'salvage' session artifact). These almost "
"always belong as a subsection or support file under a class-level "
"umbrella.\n"
"5. Iterate. After one consolidation round, scan the remaining set "
"and look for the NEXT umbrella opportunity. Don't stop after 3 "
"merges.\n\n"
"'keep' is a legitimate decision ONLY when the skill is already a "
"class-level umbrella and none of the proposed merges would improve "
"discoverability. 'This is narrow but distinct from its siblings' "
"is NOT a reason to keep — it's a reason to move it under an "
"umbrella as a subsection or support file.\n\n"
"Expected output: real umbrella-ification. Process every obvious "
"cluster. If you end the pass with fewer than 10 archives, you "
"stopped too early — go back and look at the clusters you left "
"alone.\n\n"
"When done, write a human summary AND a structured machine-readable "
"block so downstream tooling can distinguish consolidation from "
"pruning. Format EXACTLY:\n\n"
"## Structured summary (required)\n"
"```yaml\n"
"consolidations:\n"
" - from: <old-skill-name>\n"
" into: <umbrella-skill-name>\n"
" reason: <one short sentence — why merged, not just 'similar'>\n"
"prunings:\n"
" - name: <skill-name>\n"
" reason: <one short sentence — why archived with no merge target>\n"
"```\n\n"
"Every skill you archived via skill_archive MUST appear in exactly one "
"of the two lists. If you consolidated X into umbrella Y (patched Y, wrote "
"a references file to Y, or created Y with X's content absorbed), X "
"goes under `consolidations` with `into: Y`. If you archived X with "
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
"`prunings`. Leave a list empty (`consolidations: []`) if none. Do "
"not omit the block. The block comes AFTER your human-readable "
"summary of clusters processed, patches made, and decisions left alone."
)
def _make_skill_archive_tool(user_id: str):
"""Curator-only tool: soft-archive a skill (sets state, files stay put)."""
from langchain.tools import tool as lc_tool
from deerflow.skills import curator_log
from deerflow.skills.storage import get_or_new_skill_storage
@lc_tool("skill_archive")
def _skill_archive(name: str, reason: str) -> str:
"""Soft-archive an agent-created skill (sets state=archived; files stay in custom/).
The user can recover the skill from the Web UI's archive drawer.
This is intentionally non-destructive — the on-disk SKILL.md and any
supporting files are preserved so nothing is lost.
Args:
name: The skill name to archive.
reason: 必填。用**中文**写一句话,清晰说明为什么归档这个技能 —
直接给读它的运维人员看,不要写英文术语 / 调试信息。例如:
- "已合并到 code-formatter,该技能同时覆盖 Python 与 JS"
- "重构后过时,被 api-v2-search 替代"
- "与现有 foo-bar 技能重复,内容完全一致"
- "一次性 POC 实验,无复用价值"
- "命名过窄(含具体 issue 号),已并入通用伞技能 X"
此原因会展示在策展日志和 Web UI 运行历史中,供用户审计。
"""
from deerflow.skills.usage import STATE_ARCHIVED, get_entry, set_state
# Defensive: tolerate empty/None reason but log a clear placeholder
# rather than crashing the curator run.
reason_text = (reason or "").strip() or "未提供原因"
storage = get_or_new_skill_storage()
if not storage.custom_skill_exists(name):
curator_log.record("warning", f"归档:未找到技能 [{name}],或它已被归档", user_id=user_id)
return f"Skill '{name}' not found or already archived."
entry = get_entry(name)
if entry.get("pinned"):
curator_log.record("info", f"归档:技能 [{name}] 已被钉选,跳过", user_id=user_id)
return f"Skill '{name}' is pinned — skipping."
if entry.get("state") == STATE_ARCHIVED:
curator_log.record("info", f"归档:技能 [{name}] 已是归档状态,跳过", user_id=user_id)
return f"Skill '{name}' is already archived."
# Safety net: even if the LLM hallucinated a name not in the
# candidate list, refuse to archive anything that is published or
# currently referenced by an agent.
if _is_skill_published(name):
curator_log.record(
"warning",
f"归档:已拒绝处理 [{name}],该技能已发布(策展不允许操作已发布的技能)",
user_id=user_id,
)
return f"Refused: skill '{name}' is published; manual unpublish is required first."
referenced = _collect_referenced_skill_names()
if name in referenced:
curator_log.record(
"warning",
f"归档:已拒绝处理 [{name}],该技能正被某个 Agent 引用",
user_id=user_id,
)
return f"Refused: skill '{name}' is still referenced by one or more agents."
# Soft archive: flip the lifecycle state. The skill directory stays in
# ``custom/<name>/`` untouched so the user can recover it from the
# Web UI's archive drawer (which lists skills by state, not by location).
try:
set_state(name, STATE_ARCHIVED)
except Exception as e:
curator_log.record("error", f"归档失败:技能 [{name}] — {e}", user_id=user_id)
return f"Archive failed: {e}"
curator_log.record(
"info",
f"已归档(软):[{name}] — 原因:{reason_text} — 文件保留在 custom/{name}/",
user_id=user_id,
)
return f"Archived '{name}' (reason: {reason_text}; files preserved in custom/{name}/)."
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."""
from deerflow.skills import curator_log
try:
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
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)]
# 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
# retry, instead of aborting the entire review pass.
# NOTE: ``create_react_agent`` (langgraph.prebuilt) does NOT accept a
# 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)
prompt = _CURATOR_REVIEW_PROMPT + f"\n\nSkill candidates for this pass:\n{candidate_list}"
result = agent.invoke({"messages": [HumanMessage(content=prompt)]})
messages = result.get("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
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
curator_log.record("warning", "LLM 评审:本次未产出最终回复", user_id=user_id)
return ""
except Exception as e:
logger.warning("Curator LLM review failed: %s", e)
curator_log.record("warning", f"LLM 评审失败:{e}", user_id=user_id)
return ""
def _parse_structured_summary(text: str) -> dict:
pattern = re.compile(r"```yaml\s*(.*?)```", re.DOTALL)
match = pattern.search(text)
if not match:
return {"consolidations": [], "prunings": []}
try:
import yaml
data = yaml.safe_load(match.group(1)) or {}
return {"consolidations": data.get("consolidations") or [], "prunings": data.get("prunings") or []}
except Exception:
return {"consolidations": [], "prunings": []}
def run_curator_review(user_id: str) -> dict:
"""Main curator run for ``user_id``: transitions → LLM consolidation → state.
Only skills owned by ``user_id`` are touched.
"""
from deerflow.skills import curator_log
curator_log.start_new_run(user_id=user_id)
now = datetime.now(UTC)
logger.info("Curator[%s]: starting run at %s", user_id, now.isoformat())
curator_log.record("info", f"本次策展开始,启动时间 {now.isoformat()}", user_id=user_id)
transition_counters = apply_automatic_transitions(user_id, now)
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
curator_log.record(
"info",
(
f"按年龄自动流转完成:已检查 {transition_counters['checked']} 个,"
f"标记陈旧 {transition_counters['marked_stale']} 个,"
f"自动归档 {transition_counters['archived']} 个"
),
user_id=user_id,
)
candidate_list = _render_candidate_list(user_id)
candidate_line_count = 0 if not candidate_list.strip() else len([line for line in candidate_list.splitlines() if line.startswith("- ")])
curator_log.record(
"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,
)
# Surface per-item reasoning so operators can audit *why* each action
# was taken, not just the count.
for item in suggestions.get("consolidations", []) or []:
if not isinstance(item, dict):
continue
name = item.get("from", "?")
into = item.get("into", "?")
reason = (item.get("reason") or "无说明").strip()
curator_log.record(
"info",
f" 合并建议 [{name}] → [{into}]:{reason}",
user_id=user_id,
)
for item in suggestions.get("prunings", []) or []:
if not isinstance(item, dict):
continue
name = item.get("name", "?")
reason = (item.get("reason") or "无说明").strip()
curator_log.record(
"info",
f" 删减建议 [{name}]:{reason}",
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'])} 项"
)
state["last_consolidation_suggestions"] = suggestions
_write_curator_state(state, user_id)
logger.info("Curator[%s]: run complete — %s", user_id, state["last_run_summary"])
curator_log.record("info", f"本次策展已完成 — {state['last_run_summary']}", user_id=user_id)
return {"transitions": transition_counters, "suggestions": suggestions}
class _CuratorUser:
"""Minimal ``CurrentUser`` protocol implementation for background runs.
Curator threads are spawned via ``threading.Thread`` (not an asyncio task),
so the ``deerflow.runtime.user_context`` ContextVar starts unset. Without
a stand-in, any tool the LLM review invokes (e.g. ``skill_manage(create)``)
would call ``get_effective_user_id()`` and silently fall back to the
``DEFAULT_USER_ID`` sentinel — causing newly-created skills to be owned
by ``"default"`` instead of the user the curator is running for.
"""
__slots__ = ("id",)
def __init__(self, user_id: str) -> None:
self.id = user_id
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
# 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))
try:
run_curator_review(user_id)
except Exception as exc:
logger.exception("Curator[%s] background run failed", user_id)
curator_log.record("error", f"策展后台任务失败:{exc}", user_id=user_id)
finally:
reset_current_user(token)
async def maybe_run_curator() -> None:
"""Async startup entry — kicks off a thread per user whose interval elapsed.
Skips users with no DB-tracked entries (a fresh deployment) and never
blocks lifespan startup on failure.
"""
try:
from sqlalchemy import select
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.user.model import UserRow
factory = get_session_factory()
if factory is None:
return
async with factory() as session:
result = await session.execute(select(UserRow.id))
user_ids = [str(row[0]) for row in result.all()]
except Exception:
logger.exception("Curator: failed to enumerate users at startup")
return
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()
except Exception:
logger.exception("Curator: failed to spawn for user %s", user_id)
def get_curator_status(user_id: str) -> dict:
"""Return curator state for ``user_id``."""
from deerflow.skills.usage import STATE_ACTIVE, STATE_ARCHIVED, STATE_STALE, agent_created_entries
state = _read_curator_state(user_id)
owned = _list_user_owned_skill_names(user_id)
entries = agent_created_entries()
if owned is not None:
entries = {name: entry for name, entry in entries.items() if name in owned}
counts: dict[str, int] = {STATE_ACTIVE: 0, STATE_STALE: 0, STATE_ARCHIVED: 0}
for entry in entries.values():
s = entry.get("state", STATE_ACTIVE)
counts[s] = counts.get(s, 0) + 1
cfg = _get_curator_config()
return {
"enabled": cfg is not None and getattr(cfg, "enabled", True),
"run_count": state.get("run_count", 0),
"last_run_at": state.get("last_run_at"),
"last_run_summary": state.get("last_run_summary"),
"paused": state.get("paused", False),
"agent_created_skills": {
"total": len(entries),
"active": counts[STATE_ACTIVE],
"stale": counts[STATE_STALE],
"archived": counts[STATE_ARCHIVED],
},
}
@@ -0,0 +1,111 @@
"""In-memory ring buffer holding the most recent curator run's execution log.
Now sharded per-user — each ``user_id`` gets its own deque so users only see
their own curator activity from the Web UI.
The buffer is intentionally process-local and non-persistent. The Gateway
exposes it over ``GET /api/curator/logs`` with the active user id resolved
from the request context.
All public functions swallow internal exceptions so a logging failure can
never break the curator run itself.
"""
from __future__ import annotations
import logging
import threading
from collections import deque
from datetime import UTC, datetime
from typing import Any
logger = logging.getLogger(__name__)
_MAX_RECORDS = 200
# Reserved bucket key for "no user context" callers (legacy code paths or
# tests). Real curator runs always supply a concrete user_id.
_DEFAULT_BUCKET = "__default__"
_lock = threading.Lock()
_buffers: dict[str, deque[dict[str, Any]]] = {}
def _bucket_key(user_id: str | None) -> str:
return user_id or _DEFAULT_BUCKET
def _get_or_create(user_id: str | None) -> deque[dict[str, Any]]:
key = _bucket_key(user_id)
buffer = _buffers.get(key)
if buffer is None:
buffer = deque(maxlen=_MAX_RECORDS)
_buffers[key] = buffer
return buffer
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def start_new_run(user_id: str | None = None) -> None:
"""Clear the user's buffer and emit a ``run_started`` record."""
try:
with _lock:
buffer = _get_or_create(user_id)
buffer.clear()
buffer.append({"ts": _now_iso(), "level": "info", "message": "run_started"})
except Exception:
logger.debug("curator_log.start_new_run failed", exc_info=True)
def record(level: str, message: str, *, user_id: str | None = None, **fields: Any) -> None:
"""Append a single log record to the user's buffer. Never raises."""
try:
entry: dict[str, Any] = {
"ts": _now_iso(),
"level": str(level or "info"),
"message": str(message),
}
if fields:
entry.update({k: v for k, v in fields.items() if k not in entry})
with _lock:
buffer = _get_or_create(user_id)
buffer.append(entry)
except Exception:
logger.debug("curator_log.record failed", exc_info=True)
def get_recent(user_id: str | None = None) -> list[dict[str, Any]]:
"""Return a snapshot of records for ``user_id``. Never raises."""
try:
with _lock:
buffer = _buffers.get(_bucket_key(user_id))
if buffer is None:
return []
return list(buffer)
except Exception:
logger.debug("curator_log.get_recent failed", exc_info=True)
return []
def clear(user_id: str | None = None) -> None:
"""Drop a user's records. With ``user_id=None`` drops the default bucket.
Use ``clear_all()`` for cross-user cleanup (tests).
"""
try:
with _lock:
buffer = _buffers.get(_bucket_key(user_id))
if buffer is not None:
buffer.clear()
except Exception:
logger.debug("curator_log.clear failed", exc_info=True)
def clear_all() -> None:
"""Drop every user's records. Used by tests."""
try:
with _lock:
_buffers.clear()
except Exception:
logger.debug("curator_log.clear_all failed", exc_info=True)
@@ -189,6 +189,7 @@ class LocalSkillStorage(SkillStorage):
*,
overwrite: bool = False,
require_skill_suffix: bool = True,
target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict:
from deerflow.skills.installer import (
SkillAlreadyExistsError,
@@ -199,10 +200,10 @@ class LocalSkillStorage(SkillStorage):
)
from deerflow.skills.validation import _validate_skill_frontmatter
logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite)
logger.info("Installing skill from %s (overwrite=%s, target_category=%s)", archive_path, overwrite, target_category)
custom_dir = self._host_root / "custom"
custom_dir.mkdir(parents=True, exist_ok=True)
category_dir = self._host_root / target_category.value
category_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
@@ -218,10 +219,12 @@ class LocalSkillStorage(SkillStorage):
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
raise ValueError(f"Invalid skill name: {skill_name}")
if self.public_skill_exists(skill_name):
# Custom installs cannot overwrite built-in public skills.
# Public installs (admin-only) are allowed to replace existing public skills when overwrite=True.
if target_category == SkillCategory.CUSTOM and self.public_skill_exists(skill_name):
raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten")
target = custom_dir / skill_name
target = category_dir / skill_name
replaced = False
if target.exists():
if not overwrite:
@@ -231,7 +234,7 @@ class LocalSkillStorage(SkillStorage):
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root:
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=category_dir) as staging_root:
staging_target = Path(staging_root) / skill_name
shutil.copytree(skill_dir, staging_target)
_move_staged_skill_into_reserved_target(staging_target, target)
@@ -242,6 +245,7 @@ class LocalSkillStorage(SkillStorage):
"skill_name": skill_name,
"message": f"Skill '{skill_name}' installed successfully",
"overwritten": replaced,
"category": target_category.value,
}
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
@@ -132,17 +132,28 @@ class SkillStorage(ABC):
"""
@abstractmethod
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
async def ainstall_skill_from_archive(
self,
archive_path: str | Path,
*,
target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict:
"""Async install of a skill from a ``.skill`` ZIP archive.
``target_category`` controls the destination directory (public or custom).
Origin: ``deerflow.skills.installer.ainstall_skill_from_archive``.
"""
def install_skill_from_archive(self, archive_path: str | Path) -> dict:
def install_skill_from_archive(
self,
archive_path: str | Path,
*,
target_category: SkillCategory = SkillCategory.CUSTOM,
) -> dict:
"""Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`."""
from deerflow.skills.installer import _run_async_install
return _run_async_install(self.ainstall_skill_from_archive(archive_path))
return _run_async_install(self.ainstall_skill_from_archive(archive_path, target_category=target_category))
@abstractmethod
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
@@ -0,0 +1,159 @@
"""Skill usage sidecar — per-skill telemetry stored in .usage.json."""
from __future__ import annotations
import json
import logging
import os
import tempfile
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
_USAGE_FILENAME = ".usage.json"
STATE_ACTIVE = "active"
STATE_STALE = "stale"
STATE_ARCHIVED = "archived"
SOURCE_AGENT = "agent"
SOURCE_UPLOAD = "upload"
_DEFAULT_ENTRY: dict = {
"use_count": 0,
"view_count": 0,
"patch_count": 0,
"last_used_at": None,
"last_viewed_at": None,
"last_patched_at": None,
"created_at": None,
"state": STATE_ACTIVE,
"pinned": False,
"archived_at": None,
"source": SOURCE_UPLOAD,
}
def _usage_path() -> Path:
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path()) / _USAGE_FILENAME
def _read_all() -> dict:
path = _usage_path()
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
logger.debug("Failed to read %s", path, exc_info=True)
return {}
def _write_all(data: dict) -> None:
path = _usage_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, default=str)
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except Exception:
pass
raise
except Exception:
logger.debug("Failed to write %s", path, exc_info=True)
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _update_entry(name: str, updates: dict) -> None:
try:
data = _read_all()
entry = {**_DEFAULT_ENTRY, **data.get(name, {})}
entry.update(updates)
data[name] = entry
_write_all(data)
except Exception:
logger.debug("Failed to update usage entry for %r", name, exc_info=True)
def get_entry(name: str) -> dict:
"""Return the usage entry for *name*, filling missing fields with defaults."""
data = _read_all()
return {**_DEFAULT_ENTRY, **data.get(name, {})}
def all_entries() -> dict:
"""Return all usage entries keyed by skill name."""
return _read_all()
def agent_created_entries() -> dict:
"""Return only entries where source == 'agent' (eligible for curation)."""
return {k: v for k, v in _read_all().items() if v.get("source") == SOURCE_AGENT}
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()})
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()})
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()})
def set_state(name: str, state: str) -> None:
updates: dict = {"state": state}
if state == STATE_ARCHIVED:
updates["archived_at"] = _now_iso()
elif state == STATE_ACTIVE:
updates["archived_at"] = None
_update_entry(name, updates)
def set_pinned(name: str, pinned: bool) -> None:
_update_entry(name, {"pinned": pinned})
def forget(name: str) -> None:
try:
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)
def mark_agent_created(name: str) -> None:
"""Record a skill as agent-created; sets source='agent' and initialises created_at."""
_update_entry(name, {"source": SOURCE_AGENT, "created_at": _now_iso(), "state": STATE_ACTIVE})
def mark_uploaded(name: str) -> None:
"""Record a skill as user-uploaded; sets source='upload'."""
_update_entry(name, {"source": SOURCE_UPLOAD, "created_at": _now_iso()})
def latest_activity_at(name: str) -> str | None:
"""Return the most recent activity timestamp, falling back to created_at."""
entry = get_entry(name)
candidates = [entry.get("last_used_at"), entry.get("last_viewed_at"), entry.get("last_patched_at")]
valid = [ts for ts in candidates if ts]
if valid:
return max(valid)
return entry.get("created_at")
@@ -0,0 +1,147 @@
"""skill_view and skill_list — read-only tools for agent skill introspection."""
from __future__ import annotations
import json
import logging
from langchain.tools import ToolRuntime, tool
logger = logging.getLogger(__name__)
def _resolve_active_agent_id(runtime: ToolRuntime | None) -> str | None:
"""Extract the agent_id under which the current tool call is running.
Custom agents pass their id via either the ``context`` dict or the
``configurable`` section of the LangGraph runtime config. The default
lead agent runs without an explicit agent_id — callers should treat
a None return as "no per-agent skills filter applies".
"""
if runtime is None:
return None
try:
if getattr(runtime, "context", None):
ctx_id = runtime.context.get("agent_id")
if ctx_id:
return str(ctx_id)
cfg = getattr(runtime, "config", None)
if isinstance(cfg, dict):
cid = cfg.get("configurable", {}).get("agent_id")
if cid:
return str(cid)
except Exception:
return None
return None
def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
"""Return the explicit skill name set declared in an agent's config.yaml.
Semantics:
- ``None`` returned → no agent_id, or config missing — caller should
fall back to "all skills visible".
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
is intentionally empty.
- Non-empty set → only these names are visible to this agent.
"""
if not agent_id:
return None
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
if cfg is None or cfg.skills is None:
return None
return set(cfg.skills)
except Exception:
logger.debug("Failed to load agent config 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.
Args:
name: The skill name (hyphen-case, e.g. 'my-skill').
"""
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.usage import bump_view
storage = get_or_new_skill_storage()
try:
if storage.custom_skill_exists(name):
content = storage.read_custom_skill(name)
elif storage.public_skill_exists(name):
skill_file = storage.get_skills_root_path()
from pathlib import Path
from deerflow.skills.types import SKILL_MD_FILE
path = Path(skill_file) / "public" / name / SKILL_MD_FILE
if not path.exists():
return f"Skill '{name}' not found."
content = path.read_text(encoding="utf-8")
else:
return f"Skill '{name}' not found."
except Exception as e:
logger.debug("skill_view failed for %r: %s", name, e)
return f"Error reading skill '{name}': {e}"
try:
bump_view(name)
except Exception:
pass
return content
@tool("skill_list")
def skill_list_tool(runtime: ToolRuntime) -> str:
"""List skills available to this agent, with metadata and usage stats.
Does NOT read SKILL.md content.
For a **custom agent**, the result is filtered to skills explicitly
declared in the agent's ``.deer-flow/agents/{agent_id}/config.yaml``
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.
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
agent_id = _resolve_active_agent_id(runtime)
allowed = _load_agent_skill_whitelist(agent_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, {})
result.append({
"name": skill.name,
"description": skill.description,
"category": skill.category.value if hasattr(skill.category, "value") else str(skill.category),
"enabled": skill.enabled,
"state": entry.get("state", "active"),
"use_count": entry.get("use_count", 0),
"view_count": entry.get("view_count", 0),
"patch_count": entry.get("patch_count", 0),
"last_activity_at": latest_activity_at(skill.name),
"pinned": entry.get("pinned", False),
"source": entry.get("source", "upload"),
})
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
logger.debug("skill_list failed: %s", e)
return f"Error listing skills: {e}"
@@ -13,6 +13,8 @@ from langgraph.typing import ContextT
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.agents.thread_state import ThreadState
from deerflow.mcp.tools import _make_sync_tool_wrapper
from deerflow.persistence.skills import SkillStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.security_scanner import scan_skill_content
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage
@@ -64,6 +66,85 @@ async def _to_thread(func, /, *args, **kwargs):
return await asyncio.to_thread(func, *args, **kwargs)
async def _ensure_skill_db_registered(store: SkillStore, name: str, user_id: str) -> None:
"""Idempotently record an agent-created skill's ownership in the DB.
Mirrors the ``/api/skills/install-upload`` path so agent-created skills
show up in the Web UI listing (which filters custom skills by DB
ownership). ``ensure_legacy`` is idempotent — it never overwrites an
existing owner — so re-running the create flow on an already-owned skill
is a no-op. Failures are swallowed so DB outages can never break the
agent's create flow itself.
"""
try:
await store.ensure_legacy(
{"name": name, "owner_user_id": user_id, "published": False}
)
except Exception:
logger.debug("Failed to register skill ownership for %r", name, exc_info=True)
def _default_skill_store() -> SkillStore | None:
"""Build a ``SkillStore`` backed by the global session factory.
Returns ``None`` if no DB session factory is configured (e.g. ephemeral
test environments), letting the caller skip the registration step
silently.
"""
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
return make_skill_store(factory)
except Exception:
logger.debug("Failed to build default skill store", exc_info=True)
return None
def _default_agent_store() -> Any | None:
"""Build an ``AgentStore`` backed by the global session factory."""
try:
from deerflow.persistence.agents import make_agent_store
from deerflow.persistence.engine import get_session_factory
factory = get_session_factory()
if factory is None:
return None
return make_agent_store(factory)
except Exception:
logger.debug("Failed to build default agent store", exc_info=True)
return None
async def _assert_agent_may_delete(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
"""Pre-flight ownership check before agent-delete touches the on-disk file.
LocalSkillStorage doesn't know about users — it deletes anything that
matches by name. Without this guard, a malicious or hallucinating agent
could destroy another user's skill just by knowing the name. We refuse
unless the DB confirms ``user_id`` owns the skill outright.
Returns the DB record for use in the subsequent cascade step. Raises
``ValueError`` (mapped to a tool error visible to the LLM) on refusal.
"""
record = await store.get_any(name)
if record is None:
# No DB row — could be a legacy/orphan skill. Refuse rather than
# delete blindly; admin can clean up via the Web UI.
raise ValueError(
f"Refused: skill '{name}' has no ownership record. Ask a human to delete it via the Web UI."
)
owner = record.get("owner_user_id")
if owner is None:
raise ValueError(f"Refused: skill '{name}' is built-in and cannot be deleted by an agent.")
if owner != user_id:
raise ValueError(f"Refused: skill '{name}' is owned by another user.")
return record
async def _skill_manage_impl(
runtime: ToolRuntime[ContextT, ThreadState],
action: str,
@@ -78,6 +159,10 @@ async def _skill_manage_impl(
Args:
action: One of create, patch, edit, delete, write_file, remove_file.
For listing or viewing skills, prefer the dedicated
``skill_list`` / ``skill_view`` tools — but ``action='list'``
and ``action='view'`` are accepted here as convenience
aliases that delegate to those tools.
name: Skill name in hyphen-case.
content: New file content for create, edit, or write_file.
path: Supporting file path for write_file or remove_file.
@@ -85,6 +170,17 @@ async def _skill_manage_impl(
replace: Replacement text for patch.
expected_count: Optional expected number of replacements for patch.
"""
# Read-only aliases — delegate to the dedicated tools so a model that
# over-uses skill_manage doesn't trip on Unsupported action errors.
if action == "list":
from deerflow.tools.builtins.skill_tools import skill_list_tool
return await _to_thread(skill_list_tool.invoke, {})
if action == "view":
from deerflow.tools.builtins.skill_tools import skill_view_tool
return await _to_thread(skill_view_tool.invoke, {"name": name})
name = SkillStorage.validate_skill_name(name)
lock = _get_lock(name)
thread_id = _get_thread_id(runtime)
@@ -104,6 +200,18 @@ async def _skill_manage_impl(
name,
_history_record(action="create", file_path=SKILL_MD_FILE, prev_content=None, new_content=content, thread_id=thread_id, scanner=scan),
)
try:
from deerflow.skills.usage import mark_agent_created
await _to_thread(mark_agent_created, name)
except Exception:
pass
# Register the new skill in the DB so the web UI listing actually
# surfaces it (custom skills without a DB row are filtered out by
# ``GET /api/skills``). Resolve the user_id *here* on the main
# async stack — contextvars don't propagate into ``to_thread``.
store = _default_skill_store()
if store is not None:
await _ensure_skill_db_registered(store, name, get_effective_user_id())
await refresh_skills_system_prompt_cache_async()
return f"Created custom skill '{name}'."
@@ -149,6 +257,15 @@ async def _skill_manage_impl(
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."
if action == "delete":
# Ownership pre-check: LocalSkillStorage deletes purely by name,
# so without this an agent could blow away another user's skill
# just by knowing the name. Raise BEFORE touching the filesystem.
user_id = get_effective_user_id()
store = _default_skill_store()
record: dict[str, Any] | None = None
if store is not None:
record = await _assert_agent_may_delete(store, name, user_id)
await _to_thread(
skill_storage.delete_custom_skill,
name,
@@ -161,6 +278,36 @@ async def _skill_manage_impl(
scanner={"decision": "allow", "reason": "Deletion requested."},
),
)
# Mirror what ``DELETE /api/skills/custom/{name}`` does: drop the
# ``.usage.json`` entry and the DB ownership row, then cascade
# the removal to every agent that referenced this skill so
# ``config.yaml`` doesn't end up with a dangling reference.
# Failures are best-effort — the on-disk delete already happened
# and is authoritative.
try:
from deerflow.skills.usage import forget
await _to_thread(forget, name)
except Exception:
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
if store is not None:
try:
await store.delete(name, user_id)
except Exception:
logger.debug("Failed to delete DB row for %r", name, exc_info=True)
agent_store = _default_agent_store()
if agent_store is not None:
try:
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
await cascade_skill_unpublished_or_deleted(
name,
actor_user_id=user_id,
operation="delete",
skill_owner_user_id=(record.get("owner_user_id") if record else user_id),
agent_store=agent_store,
)
except Exception:
logger.debug("Cascade failed for deleted skill %r", name, exc_info=True)
await refresh_skills_system_prompt_cache_async()
return f"Deleted custom skill '{name}'."
@@ -86,9 +86,10 @@ def get_available_tools(
builtin_tools.append(scheduled_task_tool)
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.append(skill_manage_tool)
builtin_tools.extend([skill_list_tool, skill_view_tool, skill_manage_tool])
# Add subagent tools only if enabled via runtime parameter
if subagent_enabled:
@@ -0,0 +1,118 @@
from __future__ import annotations
import json
import threading
import pytest
from deerflow.config import skill_evolution_runtime
from deerflow.skills import curator_log
@pytest.fixture
def overrides_path(tmp_path, monkeypatch):
"""Redirect skill_evolution_runtime.resolve_path at a fresh temp file."""
target = tmp_path / "skill_evolution.json"
monkeypatch.setenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH", str(target))
yield target
def test_load_overrides_returns_none_when_missing(overrides_path):
assert not overrides_path.exists()
assert skill_evolution_runtime.load_overrides() is None
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,
},
}
written = skill_evolution_runtime.save_overrides(payload)
assert written == overrides_path
assert overrides_path.exists()
assert skill_evolution_runtime.load_overrides() == payload
def test_load_overrides_handles_invalid_json(overrides_path):
overrides_path.write_text("{not valid json", encoding="utf-8")
assert skill_evolution_runtime.load_overrides() is None
def test_load_overrides_rejects_non_object_root(overrides_path):
overrides_path.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
assert skill_evolution_runtime.load_overrides() is None
def test_app_config_merges_overrides_on_from_file(overrides_path, monkeypatch, tmp_path):
yaml_cfg = tmp_path / "config.yaml"
yaml_cfg.write_text(
"""
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
skill_evolution:
enabled: false
curator:
interval_hours: 168
stale_after_days: 30
""".strip(),
encoding="utf-8",
)
# Overrides flip enabled and change interval_hours.
skill_evolution_runtime.save_overrides(
{
"enabled": True,
"curator": {"interval_hours": 24},
}
)
from deerflow.config.app_config import AppConfig
cfg = AppConfig.from_file(str(yaml_cfg))
assert cfg.skill_evolution.enabled is True
assert cfg.skill_evolution.curator.interval_hours == 24
# Untouched yaml values remain.
assert cfg.skill_evolution.curator.stale_after_days == 30
def test_curator_log_start_new_run_resets_and_seeds_record():
curator_log.clear()
curator_log.record("info", "old record")
assert len(curator_log.get_recent()) == 1
curator_log.start_new_run()
records = curator_log.get_recent()
assert len(records) == 1
assert records[0]["message"] == "run_started"
assert records[0]["level"] == "info"
def test_curator_log_record_is_thread_safe():
curator_log.clear()
def writer(idx: int) -> None:
for i in range(20):
curator_log.record("info", f"thread-{idx}-{i}")
threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
records = curator_log.get_recent()
# 5 threads * 20 records each = 100, well under the 200 ring-buffer cap.
assert len(records) == 100
assert all("message" in r and "ts" in r for r in records)
def test_curator_log_record_ignores_internal_errors(monkeypatch):
# Even if the timestamp helper blows up, record() must not raise.
monkeypatch.setattr(curator_log, "_now_iso", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
curator_log.record("info", "should not raise")
@@ -0,0 +1,600 @@
"""Multitenancy tests for the Stage 1-3 skills/notifications work.
Covers:
- ``SkillStore`` per-user visibility, global-name uniqueness, admin override
- ``_get_editable_skill_or_raise`` route helper (owner / admin / 403 / 404 paths)
- ``cascade_skill_unpublished_or_deleted`` removes skills from referencing
agents' ``config.yaml`` and writes notifications via the sender shim
- ``NotificationStore`` CRUD + ``unread_count``
- Curator per-user isolation — user A's transitions never touch user B's skills
"""
from __future__ import annotations
import pytest
import yaml
from fastapi import HTTPException
from app.gateway.routers.skills import _get_editable_skill_or_raise, _get_owned_skill_or_raise
from deerflow.config import paths as paths_module
from deerflow.persistence.agents import MemoryAgentStore
from deerflow.persistence.notifications import MemoryNotificationStore, set_notification_store
from deerflow.persistence.skills import MemorySkillStore
from deerflow.skills import curator_log
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
@pytest.fixture
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
yield tmp_path
monkeypatch.setattr(paths_module, "_paths", None)
@pytest.fixture
def notification_store():
"""Install a fresh ``MemoryNotificationStore`` as the sender backend.
Cleared on both setup and teardown so a stale store from a previous test
can never leak in through the global ``_store`` singleton in
``deerflow.persistence.notifications.sender``.
"""
store = MemoryNotificationStore()
set_notification_store(store)
yield store
set_notification_store(None)
# ---------------------------------------------------------------------------
# SkillStore: per-user visibility
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_skill_store_visibility_owner_published_and_builtin() -> None:
store = MemorySkillStore()
# Builtin (legacy migrated, no human owner) — visible to everyone.
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
# User A's private skill (not published).
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
# User B's published skill — visible across users.
await store.create({"name": "beta-tool", "owner_user_id": "user-b", "published": True})
# User B's private skill — should be invisible to A and C.
await store.create({"name": "gamma-tool", "owner_user_id": "user-b", "published": False})
names_for_a = {s["name"] for s in await store.list_visible("user-a")}
assert names_for_a == {"builtin-skill", "alpha-tool", "beta-tool"}
names_for_c = {s["name"] for s in await store.list_visible("user-c")}
assert names_for_c == {"builtin-skill", "beta-tool"}
# get_visible respects the same filter.
assert (await store.get_visible("alpha-tool", "user-a")) is not None
assert (await store.get_visible("alpha-tool", "user-c")) is None
assert (await store.get_visible("gamma-tool", "user-a")) is None
# builtin flag is derived from owner_user_id IS NULL.
builtin = await store.get_any("builtin-skill")
assert builtin is not None and builtin["builtin"] is True
alpha = await store.get_any("alpha-tool")
assert alpha is not None and alpha["builtin"] is False
@pytest.mark.asyncio
async def test_skill_store_create_rejects_duplicate_name() -> None:
"""Global uniqueness — even an admin can't register a name a normal user owns."""
store = MemorySkillStore()
await store.create({"name": "shared", "owner_user_id": "user-a"})
with pytest.raises(ValueError, match="already exists"):
await store.create({"name": "shared", "owner_user_id": "user-b"})
@pytest.mark.asyncio
async def test_skill_store_only_owner_can_update_or_delete() -> None:
store = MemorySkillStore()
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
# Non-owner update returns None and does not mutate.
assert await store.update("alpha-tool", "user-b", {"published": True}) is None
record = await store.get_any("alpha-tool")
assert record is not None and record["published"] is False
# Owner can update.
updated = await store.update("alpha-tool", "user-a", {"published": True})
assert updated is not None and updated["published"] is True
# Non-owner delete fails.
assert await store.delete("alpha-tool", "user-b") is False
assert await store.get_any("alpha-tool") is not None
# Owner delete succeeds.
assert await store.delete("alpha-tool", "user-a") is True
assert await store.get_any("alpha-tool") is None
@pytest.mark.asyncio
async def test_skill_store_admin_override_can_unpublish_and_delete_any_skill() -> None:
"""``update_admin`` / ``delete_admin`` bypass ownership — including others' owned skills."""
store = MemorySkillStore()
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": True})
# update_admin can flip published on a skill the admin doesn't own.
updated = await store.update_admin("alpha-tool", {"published": False})
assert updated is not None and updated["published"] is False
# delete_admin removes it.
assert await store.delete_admin("alpha-tool") is True
assert await store.get_any("alpha-tool") is None
# And no-ops on a missing name.
assert await store.update_admin("does-not-exist", {"published": False}) is None
assert await store.delete_admin("does-not-exist") is False
@pytest.mark.asyncio
async def test_skill_store_ensure_legacy_patches_null_owner_only() -> None:
store = MemorySkillStore()
# First migration: no owner → claim by admin.
first = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "admin-id", "published": True})
assert first["owner_user_id"] == "admin-id"
# Second migration: someone else tries to take ownership — refused, original wins.
second = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "someone-else"})
assert second["owner_user_id"] == "admin-id"
# ---------------------------------------------------------------------------
# Route helper authorization (mirrors test_agents_backend.py contract)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_editable_skill_or_raise_owner_admin_and_403() -> None:
store = MemorySkillStore()
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
await store.create({"name": "owned", "owner_user_id": "user-a", "published": False})
await store.create({"name": "others-published", "owner_user_id": "user-b", "published": True})
# Owner can edit own skill.
record, mode = await _get_editable_skill_or_raise(store, "owned", "user-a", is_admin=False)
assert mode == "owner" and record["name"] == "owned"
# Admin can edit a built-in (NULL owner).
record, mode = await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=True)
assert mode == "admin" and record["name"] == "builtin-skill"
# Unlike agents, the spec lets admin take down ANY skill, including
# another user's published one.
record, mode = await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=True)
assert mode == "admin" and record["owner_user_id"] == "user-b"
# Non-admin trying to edit a built-in → 403.
with pytest.raises(HTTPException) as exc:
await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Non-admin trying to edit someone else's published skill → 403.
with pytest.raises(HTTPException) as exc:
await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Missing skill → 404 regardless of admin.
with pytest.raises(HTTPException) as exc:
await _get_editable_skill_or_raise(store, "missing", "user-a", is_admin=True)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_get_owned_skill_or_raise_distinguishes_404_from_403() -> None:
"""Visible-but-not-owned must surface as 403 so the user sees a clear reason."""
store = MemorySkillStore()
await store.create({"name": "published-from-b", "owner_user_id": "user-b", "published": True})
# Owner check on a published-but-not-owned skill returns 403, not 404.
with pytest.raises(HTTPException) as exc:
await _get_owned_skill_or_raise(store, "published-from-b", "user-a")
assert exc.value.status_code == 403
# Truly invisible / nonexistent → 404.
with pytest.raises(HTTPException) as exc:
await _get_owned_skill_or_raise(store, "nope", "user-a")
assert exc.value.status_code == 404
# ---------------------------------------------------------------------------
# Cascade: detaches references + writes notifications
# ---------------------------------------------------------------------------
def _write_agent_dir(base_dir, *, agent_id: str, skills: list[str]) -> None:
agent_dir = base_dir / "agents" / agent_id
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "config.yaml").write_text(
yaml.safe_dump(
{"id": agent_id, "name": agent_id, "description": "", "skills": skills},
default_flow_style=False,
allow_unicode=True,
),
encoding="utf-8",
)
@pytest.mark.asyncio
async def test_cascade_unpublish_strips_skill_and_notifies_other_owners(
isolated_paths, notification_store
) -> None:
"""User A unpublishes ``alpha-tool``. User B's agent loses the skill from its
config and gets an in-app notification. User A's own agent loses the skill
too but no notification (self action)."""
agent_store = MemoryAgentStore()
# User A is the actor; she also owns one of the referencing agents.
await agent_store.create({"id": "agent-of-a", "name": "Agent A", "user_id": "user-a"})
await agent_store.create({"id": "agent-of-b", "name": "Agent B", "user_id": "user-b"})
# An agent referencing a different skill — should be untouched.
await agent_store.create({"id": "agent-untouched", "name": "Untouched", "user_id": "user-b"})
_write_agent_dir(isolated_paths, agent_id="agent-of-a", skills=["alpha-tool", "other"])
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
_write_agent_dir(isolated_paths, agent_id="agent-untouched", skills=["unrelated"])
counters = await cascade_skill_unpublished_or_deleted(
"alpha-tool",
actor_user_id="user-a",
operation="unpublish",
skill_owner_user_id="user-a",
agent_store=agent_store,
)
assert counters["modified"] == 2
assert counters["notified"] == 1 # only user-b — not the actor
# A's agent: skill stripped, no notification for the actor.
a_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-a" / "config.yaml").read_text("utf-8"))
assert "alpha-tool" not in a_cfg["skills"]
assert "other" in a_cfg["skills"]
# B's agent: skill stripped, notification delivered to B.
b_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-b" / "config.yaml").read_text("utf-8"))
assert b_cfg["skills"] == []
user_b_inbox = await notification_store.list_for_user("user-b")
assert len(user_b_inbox) == 1
note = user_b_inbox[0]
assert note["type"] == "agent_skill_removed"
assert note["payload"]["skill_name"] == "alpha-tool"
assert note["payload"]["agent_id"] == "agent-of-b"
assert note["payload"]["operation"] == "unpublish"
# Untouched agent retains its config + no notification fired for it.
untouched_cfg = yaml.safe_load(
(isolated_paths / "agents" / "agent-untouched" / "config.yaml").read_text("utf-8")
)
assert untouched_cfg["skills"] == ["unrelated"]
assert await notification_store.unread_count("user-a") == 0
@pytest.mark.asyncio
async def test_cascade_admin_takedown_also_notifies_skill_owner(
isolated_paths, notification_store
) -> None:
"""Admin force-takedown emits an extra ``skill_taken_down`` notification to
the original skill owner, on top of any per-agent notifications."""
agent_store = MemoryAgentStore()
await agent_store.create({"id": "agent-of-b", "name": "B", "user_id": "user-b"})
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
await cascade_skill_unpublished_or_deleted(
"alpha-tool",
actor_user_id="admin-id",
operation="admin_takedown",
skill_owner_user_id="user-a",
agent_store=agent_store,
)
# B (agent owner) gets the agent-skill-removed notification.
assert (await notification_store.unread_count("user-b")) == 1
# A (skill owner, not the actor) gets the takedown notification.
a_inbox = await notification_store.list_for_user("user-a")
assert len(a_inbox) == 1
assert a_inbox[0]["type"] == "skill_taken_down"
assert a_inbox[0]["payload"]["skill_name"] == "alpha-tool"
# Admin (the actor) gets nothing.
assert (await notification_store.unread_count("admin-id")) == 0
# ---------------------------------------------------------------------------
# Notification inbox CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_notification_store_crud_and_unread_count() -> None:
store = MemoryNotificationStore()
await store.create({"id": "n1", "user_id": "user-a", "type": "x", "title": "t1"})
await store.create({"id": "n2", "user_id": "user-a", "type": "x", "title": "t2"})
await store.create({"id": "n3", "user_id": "user-b", "type": "x", "title": "t3"})
assert (await store.unread_count("user-a")) == 2
assert (await store.unread_count("user-b")) == 1
# list_for_user filters by recipient and respects unread_only.
a_items = await store.list_for_user("user-a")
assert {item["id"] for item in a_items} == {"n1", "n2"}
# Mark one read → unread count drops; second listing reflects state.
assert await store.mark_read("n1", "user-a") is True
assert (await store.unread_count("user-a")) == 1
unread = await store.list_for_user("user-a", unread_only=True)
assert [item["id"] for item in unread] == ["n2"]
# Cross-user mark_read is rejected.
assert await store.mark_read("n3", "user-a") is False
assert (await store.unread_count("user-b")) == 1
# mark_all_read returns the count of newly-flipped rows.
flipped = await store.mark_all_read("user-a")
assert flipped == 1
assert (await store.unread_count("user-a")) == 0
# Delete behaves the same way — owner-only.
assert await store.delete("n3", "user-a") is False
assert await store.delete("n3", "user-b") is True
assert (await store.list_for_user("user-b")) == []
# ---------------------------------------------------------------------------
# Curator per-user isolation
# ---------------------------------------------------------------------------
def test_curator_apply_transitions_only_touches_owned_skills(
isolated_paths, monkeypatch
) -> None:
"""User A running curator must not mark / archive user B's skills.
Reproduces a minimal end-of-life scenario: both users own an idle skill,
but only A's should transition to STALE.
"""
from datetime import UTC, datetime, timedelta
from deerflow.skills import curator, usage
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
# The curator's filter helper normally queries the SkillStore via
# ``get_session_factory``; here we substitute a static mapping so the test
# stays SQL-free. ``apply_automatic_transitions`` is synchronous so we
# cannot wait on the async store from inside it without nested-loop pain.
owned_by = {
"user-a": {"a-skill"},
"user-b": {"b-skill"},
}
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: owned_by.get(uid, set()))
# Point the on-disk skills root at the temp dir so the usage helpers write
# to ``{tmp}/skills/.usage.json`` and not into the global singleton state.
reset_skill_storage()
monkeypatch.setattr(
"deerflow.skills.storage._default_skill_storage",
get_or_new_skill_storage(skills_path=str(isolated_paths / "skills")),
raising=False,
)
# Stamp both skills as agent-created and old enough to mark stale.
old_iso = (datetime.now(UTC) - timedelta(days=90)).isoformat()
usage._update_entry(
"a-skill",
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
)
usage._update_entry(
"b-skill",
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
)
# Run curator transitions as user-a.
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
# Only a-skill counted as checked, only it got transitioned.
assert counters["checked"] == 1 # owned-filter narrows the entry set
assert counters["archived"] == 1 # 90 days idle == archive_after_days
# The on-disk state reflects the filter.
assert usage.get_entry("a-skill")["state"] == usage.STATE_ARCHIVED
assert usage.get_entry("b-skill")["state"] == usage.STATE_ACTIVE
reset_skill_storage()
def test_curator_log_buckets_records_per_user() -> None:
"""Curator log records for one user must not appear in another's view —
guards the dict-of-deques refactor in curator_log.py."""
curator_log.clear_all()
curator_log.start_new_run(user_id="user-a")
curator_log.record("info", "alpha event", user_id="user-a")
curator_log.start_new_run(user_id="user-b")
curator_log.record("info", "beta event", user_id="user-b")
a_records = curator_log.get_recent(user_id="user-a")
b_records = curator_log.get_recent(user_id="user-b")
assert [r["message"] for r in a_records] == ["run_started", "alpha event"]
assert [r["message"] for r in b_records] == ["run_started", "beta event"]
# An unknown user gets an empty buffer rather than a cross-user view.
assert curator_log.get_recent(user_id="user-c") == []
curator_log.clear_all()
# ---------------------------------------------------------------------------
# Increment #1: Curator three-condition eligibility filter
# ---------------------------------------------------------------------------
def _seed_old_agent_skill(name: str) -> None:
"""Helper: stamp an agent-created skill as 90 days idle (archive-eligible)."""
from datetime import UTC, datetime, timedelta
from deerflow.skills import usage
old_iso = (datetime.now(UTC) - timedelta(days=90)).isoformat()
usage._update_entry(
name,
{
"source": usage.SOURCE_AGENT,
"created_at": old_iso,
"last_used_at": old_iso,
"state": usage.STATE_ACTIVE,
},
)
def _isolated_skills_root(isolated_paths, monkeypatch) -> None:
"""Helper: point the on-disk skills root at the temp directory."""
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
reset_skill_storage()
monkeypatch.setattr(
"deerflow.skills.storage._default_skill_storage",
get_or_new_skill_storage(skills_path=str(isolated_paths / "skills")),
raising=False,
)
def test_curator_skips_skill_referenced_by_any_agent(
isolated_paths, monkeypatch
) -> None:
"""A skill listed in any agent's config.yaml ``skills`` field must be
exempt from automatic archive, even if it has been idle for ages."""
from datetime import UTC, datetime
from deerflow.skills import curator, usage
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"linked-skill"})
monkeypatch.setattr(curator, "_is_skill_published", lambda name: False)
# Pretend one agent's config references linked-skill.
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: {"linked-skill"})
_isolated_skills_root(isolated_paths, monkeypatch)
_seed_old_agent_skill("linked-skill")
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
# Should be excluded from the eligible set entirely.
assert counters["checked"] == 0
assert counters["archived"] == 0
assert counters["skipped_referenced"] == 1
assert usage.get_entry("linked-skill")["state"] == usage.STATE_ACTIVE
def test_curator_skips_published_skill(isolated_paths, monkeypatch) -> None:
"""A skill marked ``published`` in SkillStore must never be auto-archived
even when nothing references it."""
from datetime import UTC, datetime
from deerflow.skills import curator, usage
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")
_isolated_skills_root(isolated_paths, monkeypatch)
_seed_old_agent_skill("public-tool")
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
assert counters["checked"] == 0
assert counters["archived"] == 0
assert counters["skipped_published"] == 1
assert usage.get_entry("public-tool")["state"] == usage.STATE_ACTIVE
def test_curator_archives_orphan_unpublished_skill(isolated_paths, monkeypatch) -> None:
"""The "default happy path": owner's own agent-created skill, unpublished,
not referenced anywhere — IS archived when idle past the threshold."""
from datetime import UTC, datetime
from deerflow.skills import curator, usage
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: {"orphan-skill"})
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
monkeypatch.setattr(curator, "_is_skill_published", lambda name: False)
_isolated_skills_root(isolated_paths, monkeypatch)
_seed_old_agent_skill("orphan-skill")
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
assert counters["checked"] == 1
assert counters["archived"] == 1
assert counters["skipped_referenced"] == 0
assert counters["skipped_published"] == 0
assert usage.get_entry("orphan-skill")["state"] == usage.STATE_ARCHIVED
def test_skill_archive_tool_refuses_referenced_or_published(
isolated_paths, monkeypatch
) -> None:
"""Safety net: even if the LLM bypasses the candidate filter and invokes
skill_archive with a protected name, the tool itself refuses."""
from deerflow.skills import curator
# Make the on-disk lookup believe the skill exists and is not pinned.
class _FakeStorage:
def custom_skill_exists(self, _name: str) -> bool:
return True
class _FakeEntry:
def get(self, key, default=None):
return False # pinned=False
monkeypatch.setattr("deerflow.skills.storage.get_or_new_skill_storage", lambda *_a, **_kw: _FakeStorage())
monkeypatch.setattr("deerflow.skills.usage.get_entry", lambda _n: _FakeEntry())
tool = curator._make_skill_archive_tool("user-a")
# Case A: target is referenced — must refuse.
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: {"linked"})
monkeypatch.setattr(curator, "_is_skill_published", lambda name: False)
out_a = tool.invoke({"name": "linked", "reason": "test"})
assert "Refused" in out_a
assert "referenced" in out_a
# Case B: target is published — must refuse.
monkeypatch.setattr(curator, "_collect_referenced_skill_names", lambda: set())
monkeypatch.setattr(curator, "_is_skill_published", lambda name: name == "pub")
out_b = tool.invoke({"name": "pub", "reason": "test"})
assert "Refused" in out_b
assert "published" in out_b
def test_collect_referenced_skill_names_aggregates_from_disk(
isolated_paths, monkeypatch
) -> None:
"""End-to-end of the scanner helper: it walks ``.deer-flow/agents/*/config.yaml``
and returns the union of every ``skills`` list."""
from deerflow.skills import curator
# Build two agents on disk, each referencing one skill.
agents_root = isolated_paths / "agents"
(agents_root / "agent-1").mkdir(parents=True, exist_ok=True)
(agents_root / "agent-2").mkdir(parents=True, exist_ok=True)
(agents_root / "agent-1" / "config.yaml").write_text(
yaml.safe_dump({"id": "agent-1", "name": "A", "skills": ["alpha", "beta"]}),
encoding="utf-8",
)
(agents_root / "agent-2" / "config.yaml").write_text(
yaml.safe_dump({"id": "agent-2", "name": "B", "skills": ["beta", "gamma"]}),
encoding="utf-8",
)
# Point get_paths().agents_dir at the temp tree.
class _FakePaths:
agents_dir = agents_root
monkeypatch.setattr("deerflow.config.paths.get_paths", lambda: _FakePaths())
assert curator._collect_referenced_skill_names() == {"alpha", "beta", "gamma"}
+2692 -2692
View File
File diff suppressed because it is too large. Load diff