Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/pages/WorkspaceRoutes.tsx # offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
This commit is contained in:
commit
aeba15eb16
88 files changed
+13824
-3572
No files matched your search
+10
@@ -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
@@ -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 架构保持长期兼容。
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
|
||||
|
||||
export default function UsersPage() {
|
||||
return <UserManagementPage />;
|
||||
}
|
||||
@@ -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)}")
|
||||
+17
-8
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
+13
@@ -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.",
|
||||
)
|
||||
+216
@@ -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
|
||||
+3
@@ -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)
|
||||
+6
-1
@@ -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
|
||||
+4
@@ -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",
|
||||
]
|
||||
+26
@@ -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)
|
||||
+42
@@ -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
|
||||
+70
@@ -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
|
||||
+24
@@ -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)
|
||||
+72
@@ -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)
|
||||
+97
@@ -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
|
||||
+13
@@ -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
|
||||
+129
@@ -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)
|
||||
+28
@@ -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)
|
||||
+10
-6
@@ -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:
|
||||
|
||||
+14
-3
@@ -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")
|
||||
+147
@@ -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}"
|
||||
+147
@@ -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"}
|
||||
Generated
+2692
-2692
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user