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/stop_backend.sh
|
||||||
offline-backend-20260512/check_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/
|
skills/
|
||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.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({
|
const BreadcrumbLink = React.forwardRef<
|
||||||
asChild,
|
HTMLAnchorElement,
|
||||||
className,
|
React.ComponentProps<"a"> & {
|
||||||
...props
|
asChild?: boolean;
|
||||||
}: React.ComponentProps<"a"> & {
|
}
|
||||||
asChild?: boolean;
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
}) {
|
const Comp = asChild ? Slot : "a";
|
||||||
const Comp = asChild ? Slot : "a";
|
|
||||||
|
return (
|
||||||
return (
|
<Comp
|
||||||
<Comp
|
ref={ref}
|
||||||
data-slot="breadcrumb-link"
|
data-slot="breadcrumb-link"
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -37,27 +37,27 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
const Button = React.forwardRef<
|
||||||
className,
|
HTMLButtonElement,
|
||||||
variant = "default",
|
React.ComponentProps<"button"> &
|
||||||
size = "default",
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild = false,
|
asChild?: boolean;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<"button"> &
|
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||||
VariantProps<typeof buttonVariants> & {
|
const Comp = asChild ? Slot : "button";
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
return (
|
||||||
const Comp = asChild ? Slot : "button";
|
<Comp
|
||||||
|
ref={ref}
|
||||||
return (
|
data-slot="button"
|
||||||
<Comp
|
{...(variant !== undefined && { "data-variant": variant })}
|
||||||
data-slot="button"
|
{...(size !== undefined && { "data-size": size })}
|
||||||
{...(variant !== undefined && { "data-variant": variant })}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...(size !== undefined && { "data-size": size })}
|
{...props}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
/>
|
||||||
{...props}
|
);
|
||||||
/>
|
});
|
||||||
);
|
|
||||||
}
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
import * as React from "react";
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -10,28 +11,37 @@ function Collapsible({
|
|||||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleTrigger({
|
const CollapsibleTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<CollapsiblePrimitive.CollapsibleTrigger
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
data-slot="collapsible-trigger"
|
ref={ref}
|
||||||
className={cn("cursor-pointer", className)}
|
data-slot="collapsible-trigger"
|
||||||
{...props}
|
className={cn("cursor-pointer", className)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
function CollapsibleContent({
|
|
||||||
...props
|
CollapsibleTrigger.displayName =
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
CollapsiblePrimitive.CollapsibleTrigger.displayName;
|
||||||
return (
|
|
||||||
<CollapsiblePrimitive.CollapsibleContent
|
const CollapsibleContent = React.forwardRef<
|
||||||
data-slot="collapsible-content"
|
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||||
{...props}
|
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 };
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
@@ -12,11 +12,20 @@ function Dialog({
|
|||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
const DialogTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return (
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
@@ -24,61 +33,68 @@ function DialogPortal({
|
|||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
const DialogClose = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||||
|
|
||||||
function DialogOverlay({
|
const DialogOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="dialog-overlay"
|
||||||
"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={cn(
|
||||||
className,
|
"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}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function DialogContent({
|
const DialogContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton = true,
|
showCloseButton?: boolean;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||||
showCloseButton?: boolean;
|
return (
|
||||||
}) {
|
<DialogPortal data-slot="dialog-portal">
|
||||||
return (
|
<DialogOverlay />
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPrimitive.Content
|
||||||
<DialogOverlay />
|
ref={ref}
|
||||||
<DialogPrimitive.Content
|
data-slot="dialog-content"
|
||||||
data-slot="dialog-content"
|
className={cn(
|
||||||
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",
|
||||||
"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,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
{showCloseButton && (
|
||||||
{showCloseButton && (
|
<DialogPrimitive.Close
|
||||||
<DialogPrimitive.Close
|
data-slot="dialog-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"
|
||||||
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 />
|
||||||
<XIcon />
|
<span className="sr-only">Close</span>
|
||||||
<span className="sr-only">Close</span>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Close>
|
)}
|
||||||
)}
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Content>
|
</DialogPortal>
|
||||||
</DialogPortal>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
const DropdownMenuTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||||
return (
|
>(({ ...props }, ref) => {
|
||||||
<DropdownMenuPrimitive.Trigger
|
return (
|
||||||
data-slot="dropdown-menu-trigger"
|
<DropdownMenuPrimitive.Trigger
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
data-slot="dropdown-menu-trigger"
|
||||||
);
|
{...props}
|
||||||
}
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function DropdownMenuContent({
|
const DropdownMenuContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
sideOffset = 4,
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
...props
|
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
return (
|
||||||
return (
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Content
|
||||||
<DropdownMenuPrimitive.Content
|
ref={ref}
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
...props
|
...props
|
||||||
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
const DropdownMenuItem = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
inset,
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
variant = "default",
|
inset?: boolean;
|
||||||
...props
|
variant?: "default" | "destructive";
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}
|
||||||
inset?: boolean;
|
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||||
variant?: "default" | "destructive";
|
return (
|
||||||
}) {
|
<DropdownMenuPrimitive.Item
|
||||||
return (
|
ref={ref}
|
||||||
<DropdownMenuPrimitive.Item
|
data-slot="dropdown-menu-item"
|
||||||
data-slot="dropdown-menu-item"
|
data-inset={inset}
|
||||||
data-inset={inset}
|
data-variant={variant}
|
||||||
data-variant={variant}
|
className={cn(
|
||||||
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",
|
||||||
"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,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
@@ -198,45 +205,51 @@ function DropdownMenuSub({
|
|||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
inset,
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
children,
|
inset?: boolean;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
>(({ className, inset, children, ...props }, ref) => {
|
||||||
inset?: boolean;
|
return (
|
||||||
}) {
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
return (
|
ref={ref}
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-inset={inset}
|
||||||
data-inset={inset}
|
className={cn(
|
||||||
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",
|
||||||
"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,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="dropdown-menu-sub-content"
|
||||||
"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={cn(
|
||||||
className,
|
"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}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -11,34 +11,42 @@ function HoverCard({
|
|||||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoverCardTrigger({
|
const HoverCardTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
|
||||||
return (
|
>(({ ...props }, ref) => {
|
||||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
return (
|
||||||
);
|
<HoverCardPrimitive.Trigger
|
||||||
}
|
ref={ref}
|
||||||
|
data-slot="hover-card-trigger"
|
||||||
function HoverCardContent({
|
{...props}
|
||||||
className,
|
/>
|
||||||
align = "center",
|
);
|
||||||
sideOffset = 4,
|
});
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
const HoverCardContent = React.forwardRef<
|
||||||
<HoverCardPrimitive.Content
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
data-slot="hover-card-content"
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
align={align}
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||||
sideOffset={sideOffset}
|
return (
|
||||||
className={cn(
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
"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",
|
<HoverCardPrimitive.Content
|
||||||
className,
|
ref={ref}
|
||||||
)}
|
data-slot="hover-card-content"
|
||||||
{...props}
|
align={align}
|
||||||
/>
|
sideOffset={sideOffset}
|
||||||
</HoverCardPrimitive.Portal>
|
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 };
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
@@ -24,68 +24,69 @@ function SelectValue({
|
|||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
const SelectTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
size = "default",
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||||
children,
|
size?: "sm" | "default";
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
>(({ className, size = "default", children, ...props }, ref) => {
|
||||||
size?: "sm" | "default";
|
return (
|
||||||
}) {
|
<SelectPrimitive.Trigger
|
||||||
return (
|
ref={ref}
|
||||||
<SelectPrimitive.Trigger
|
data-slot="select-trigger"
|
||||||
data-slot="select-trigger"
|
data-size={size}
|
||||||
data-size={size}
|
className={cn(
|
||||||
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",
|
||||||
"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,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
<SelectPrimitive.Icon asChild>
|
||||||
<SelectPrimitive.Icon asChild>
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Trigger>
|
||||||
</SelectPrimitive.Trigger>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function SelectContent({
|
const SelectContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
position = "item-aligned",
|
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||||
align = "center",
|
return (
|
||||||
...props
|
<SelectPrimitive.Portal>
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
<SelectPrimitive.Content
|
||||||
return (
|
ref={ref}
|
||||||
<SelectPrimitive.Portal>
|
data-slot="select-content"
|
||||||
<SelectPrimitive.Content
|
className={cn(
|
||||||
data-slot="select-content"
|
"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",
|
||||||
className={cn(
|
position === "popper" &&
|
||||||
"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",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
position === "popper" &&
|
className,
|
||||||
"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}
|
||||||
position={position}
|
{...props}
|
||||||
align={align}
|
>
|
||||||
{...props}
|
<SelectScrollUpButton />
|
||||||
>
|
<SelectPrimitive.Viewport
|
||||||
<SelectScrollUpButton />
|
className={cn(
|
||||||
<SelectPrimitive.Viewport
|
"p-1",
|
||||||
className={cn(
|
position === "popper" &&
|
||||||
"p-1",
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
position === "popper" &&
|
)}
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
>
|
||||||
)}
|
{children}
|
||||||
>
|
</SelectPrimitive.Viewport>
|
||||||
{children}
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Content>
|
||||||
<SelectScrollDownButton />
|
</SelectPrimitive.Portal>
|
||||||
</SelectPrimitive.Content>
|
);
|
||||||
</SelectPrimitive.Portal>
|
});
|
||||||
);
|
|
||||||
}
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
className,
|
className,
|
||||||
@@ -100,32 +101,34 @@ function SelectLabel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
const SelectItem = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
...props
|
>(({ className, children, ...props }, ref) => {
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
return (
|
||||||
return (
|
<SelectPrimitive.Item
|
||||||
<SelectPrimitive.Item
|
ref={ref}
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-slot="select-item-indicator"
|
data-slot="select-item-indicator"
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
>
|
>
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
const SheetTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof SheetPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function SheetClose({
|
const SheetClose = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof SheetPrimitive.Close>,
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
SheetClose.displayName = SheetPrimitive.Close.displayName;
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
@@ -28,58 +34,62 @@ function SheetPortal({
|
|||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
const SheetOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
data-slot="sheet-overlay"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="sheet-overlay"
|
||||||
"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={cn(
|
||||||
className,
|
"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}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function SheetContent({
|
const SheetContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||||
side = "right",
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
>(({ className, children, side = "right", ...props }, ref) => {
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
return (
|
||||||
}) {
|
<SheetPortal>
|
||||||
return (
|
<SheetOverlay />
|
||||||
<SheetPortal>
|
<SheetPrimitive.Content
|
||||||
<SheetOverlay />
|
ref={ref}
|
||||||
<SheetPrimitive.Content
|
data-slot="sheet-content"
|
||||||
data-slot="sheet-content"
|
className={cn(
|
||||||
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",
|
||||||
"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" &&
|
||||||
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",
|
||||||
"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" &&
|
||||||
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",
|
||||||
"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" &&
|
||||||
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",
|
||||||
"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" &&
|
||||||
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",
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{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">
|
||||||
<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" />
|
||||||
<XIcon className="size-4" />
|
<span className="sr-only">Close</span>
|
||||||
<span className="sr-only">Close</span>
|
</SheetPrimitive.Close>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Content>
|
||||||
</SheetPrimitive.Content>
|
</SheetPortal>
|
||||||
</SheetPortal>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupLabel({
|
const SidebarGroupLabel = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
asChild = false,
|
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||||
...props
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
const Comp = asChild ? Slot : "div";
|
||||||
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 (
|
const SidebarGroupAction = React.forwardRef<
|
||||||
<Comp
|
HTMLButtonElement,
|
||||||
data-slot="sidebar-group-label"
|
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||||
data-sidebar="group-label"
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
className={cn(
|
const Comp = asChild ? Slot : "button";
|
||||||
"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",
|
return (
|
||||||
className,
|
<Comp
|
||||||
)}
|
ref={ref}
|
||||||
{...props}
|
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.
|
||||||
function SidebarGroupAction({
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
className,
|
"group-data-[collapsible=icon]:hidden",
|
||||||
asChild = false,
|
className,
|
||||||
...props
|
)}
|
||||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
{...props}
|
||||||
const Comp = asChild ? Slot : "button";
|
/>
|
||||||
|
);
|
||||||
return (
|
});
|
||||||
<Comp
|
|
||||||
data-slot="sidebar-group-action"
|
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarGroupContent({
|
function SidebarGroupContent({
|
||||||
className,
|
className,
|
||||||
@@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function SidebarMenuButton({
|
const SidebarMenuButton = React.forwardRef<
|
||||||
asChild = false,
|
HTMLButtonElement,
|
||||||
isActive = false,
|
React.ComponentProps<"button"> & {
|
||||||
variant = "default",
|
asChild?: boolean;
|
||||||
size = "default",
|
isActive?: boolean;
|
||||||
tooltip,
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
className,
|
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
...props
|
>(
|
||||||
}: React.ComponentProps<"button"> & {
|
(
|
||||||
asChild?: boolean;
|
{
|
||||||
isActive?: boolean;
|
asChild = false,
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
isActive = false,
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
variant = "default",
|
||||||
const Comp = asChild ? Slot : "button";
|
size = "default",
|
||||||
const { isMobile, state } = useSidebar();
|
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 = (
|
const SidebarMenuAction = React.forwardRef<
|
||||||
<Comp
|
HTMLButtonElement,
|
||||||
data-slot="sidebar-menu-button"
|
React.ComponentProps<"button"> & {
|
||||||
data-sidebar="menu-button"
|
asChild?: boolean;
|
||||||
data-size={size}
|
showOnHover?: boolean;
|
||||||
data-active={isActive}
|
}
|
||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||||
{...props}
|
const Comp = asChild ? Slot : "button";
|
||||||
/>
|
|
||||||
);
|
return (
|
||||||
|
<Comp
|
||||||
if (!tooltip) {
|
ref={ref}
|
||||||
return button;
|
data-slot="sidebar-menu-action"
|
||||||
}
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
if (typeof tooltip === "string") {
|
"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",
|
||||||
tooltip = {
|
// Increases the hit area of the button on mobile.
|
||||||
children: tooltip,
|
"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",
|
||||||
return (
|
"group-data-[collapsible=icon]:hidden",
|
||||||
<Tooltip>
|
showOnHover &&
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
"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",
|
||||||
<TooltipContent
|
className,
|
||||||
side="right"
|
)}
|
||||||
align="center"
|
{...props}
|
||||||
hidden={state !== "collapsed" || isMobile}
|
/>
|
||||||
{...tooltip}
|
);
|
||||||
/>
|
});
|
||||||
</Tooltip>
|
|
||||||
);
|
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({
|
||||||
className,
|
className,
|
||||||
@@ -670,37 +686,37 @@ function SidebarMenuSubItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubButton({
|
const SidebarMenuSubButton = React.forwardRef<
|
||||||
asChild = false,
|
HTMLAnchorElement,
|
||||||
size = "md",
|
React.ComponentProps<"a"> & {
|
||||||
isActive = false,
|
asChild?: boolean;
|
||||||
className,
|
size?: "sm" | "md";
|
||||||
...props
|
isActive?: boolean;
|
||||||
}: React.ComponentProps<"a"> & {
|
}
|
||||||
asChild?: boolean;
|
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||||
size?: "sm" | "md";
|
const Comp = asChild ? Slot : "a";
|
||||||
isActive?: boolean;
|
|
||||||
}) {
|
return (
|
||||||
const Comp = asChild ? Slot : "a";
|
<Comp
|
||||||
|
ref={ref}
|
||||||
return (
|
data-slot="sidebar-menu-sub-button"
|
||||||
<Comp
|
data-sidebar="menu-sub-button"
|
||||||
data-slot="sidebar-menu-sub-button"
|
data-size={size}
|
||||||
data-sidebar="menu-sub-button"
|
data-active={isActive}
|
||||||
data-size={size}
|
className={cn(
|
||||||
data-active={isActive}
|
"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",
|
||||||
className={cn(
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
"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",
|
size === "sm" && "text-xs",
|
||||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
size === "md" && "text-sm",
|
||||||
size === "sm" && "text-xs",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
size === "md" && "text-sm",
|
className,
|
||||||
"group-data-[collapsible=icon]:hidden",
|
)}
|
||||||
className,
|
{...props}
|
||||||
)}
|
/>
|
||||||
{...props}
|
);
|
||||||
/>
|
});
|
||||||
);
|
|
||||||
}
|
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
|||||||
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function TabsList({
|
const TabsList = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
variant = "default",
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||||
...props
|
VariantProps<typeof tabsListVariants>
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
>(({ className, variant = "default", ...props }, ref) => {
|
||||||
VariantProps<typeof tabsListVariants>) {
|
return (
|
||||||
return (
|
<TabsPrimitive.List
|
||||||
<TabsPrimitive.List
|
ref={ref}
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(tabsListVariants({ variant }), className)}
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
function TabsTrigger({
|
const TabsTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="tabs-trigger"
|
||||||
"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",
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
"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",
|
"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",
|
||||||
"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",
|
"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",
|
||||||
className,
|
"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}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function TabsContent({
|
const TabsContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
data-slot="tabs-content"
|
ref={ref}
|
||||||
className={cn("flex-1 outline-none", className)}
|
data-slot="tabs-content"
|
||||||
{...props}
|
className={cn("flex-1 outline-none", className)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||||
@@ -28,33 +28,43 @@ function Tooltip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
const TooltipTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return (
|
||||||
|
<TooltipPrimitive.Trigger
|
||||||
function TooltipContent({
|
ref={ref}
|
||||||
className,
|
data-slot="tooltip-trigger"
|
||||||
sideOffset,
|
{...props}
|
||||||
children,
|
/>
|
||||||
...props
|
);
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
});
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Portal>
|
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
|
||||||
<TooltipPrimitive.Content
|
|
||||||
data-slot="tooltip-content"
|
const TooltipContent = React.forwardRef<
|
||||||
sideOffset={sideOffset ?? 4}
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
className={cn(
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
"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, sideOffset, children, ...props }, ref) => {
|
||||||
className,
|
return (
|
||||||
)}
|
<TooltipPrimitive.Portal>
|
||||||
{...props}
|
<TooltipPrimitive.Content
|
||||||
>
|
ref={ref}
|
||||||
{children}
|
data-slot="tooltip-content"
|
||||||
</TooltipPrimitive.Content>
|
sideOffset={sideOffset ?? 4}
|
||||||
</TooltipPrimitive.Portal>
|
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 };
|
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,
|
EyeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
|
PencilIcon,
|
||||||
|
SaveIcon,
|
||||||
SquareArrowOutUpRightIcon,
|
SquareArrowOutUpRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -32,6 +34,8 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import {
|
import {
|
||||||
downloadArtifactFile,
|
downloadArtifactFile,
|
||||||
@@ -39,6 +43,7 @@ import {
|
|||||||
} from "@/core/artifacts/download";
|
} from "@/core/artifacts/download";
|
||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
@@ -119,8 +124,27 @@ export function ArtifactFileDetail({
|
|||||||
enabled: isCodeFile && !isWriteFile,
|
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 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 hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||||
const fileTypeLabel = useMemo(() => {
|
const fileTypeLabel = useMemo(() => {
|
||||||
if (language === "html") return "HTML";
|
if (language === "html") return "HTML";
|
||||||
@@ -130,22 +154,12 @@ export function ArtifactFileDetail({
|
|||||||
return "FILE";
|
return "FILE";
|
||||||
}, [isSkillFile, language]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setAllowAutoViewSwitch(true);
|
setAllowAutoViewSwitch(true);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
setShowResumeAutoScroll(false);
|
setShowResumeAutoScroll(false);
|
||||||
|
setEditedContent(null);
|
||||||
|
setSavedContent(null);
|
||||||
|
|
||||||
if (language === "html") {
|
if (language === "html") {
|
||||||
setViewMode(thread.isLoading ? "code" : "preview");
|
setViewMode(thread.isLoading ? "code" : "preview");
|
||||||
@@ -308,6 +322,40 @@ export function ArtifactFileDetail({
|
|||||||
}, 1600);
|
}, 1600);
|
||||||
}, [scrollArtifactToBottom]);
|
}, [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 () => {
|
const handleInstallSkill = useCallback(async () => {
|
||||||
if (isInstalling) return;
|
if (isInstalling) return;
|
||||||
|
|
||||||
@@ -406,7 +454,27 @@ export function ArtifactFileDetail({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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">
|
<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") && (
|
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
@@ -513,17 +581,26 @@ export function ArtifactFileDetail({
|
|||||||
viewMode === "preview" &&
|
viewMode === "preview" &&
|
||||||
(language === "markdown" || language === "html") && (
|
(language === "markdown" || language === "html") && (
|
||||||
<ArtifactFilePreview
|
<ArtifactFilePreview
|
||||||
content={displayContent}
|
content={activeContent}
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
animated={showPreviewSwitchPulse}
|
animated={showPreviewSwitchPulse}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCodeFile && viewMode === "code" && (
|
{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
|
<CodeEditor
|
||||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||||
value={displayContent ?? ""}
|
value={activeContent}
|
||||||
readonly
|
readonly={!canEdit}
|
||||||
|
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function CodeEditor({
|
|||||||
readonly,
|
readonly,
|
||||||
disabled,
|
disabled,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
onChange,
|
||||||
settings,
|
settings,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -49,6 +50,7 @@ export function CodeEditor({
|
|||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -107,6 +109,7 @@ export function CodeEditor({
|
|||||||
}}
|
}}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
value={value}
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex w-full flex-col gap-1">
|
||||||
{visibleThreads.map((thread) => {
|
{visibleThreads.map((thread) => {
|
||||||
const isActive = pathOfThread(thread) === pathname;
|
const isActive = pathOfThread(thread) === pathname;
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
className="group/side-menu-item"
|
className="group/side-menu-item"
|
||||||
>
|
>
|
||||||
<SidebarMenuButton isActive={isActive} asChild>
|
<SidebarMenuButton isActive={isActive} asChild>
|
||||||
<div>
|
<Link
|
||||||
<Link
|
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
href={pathOfThread(thread)}
|
||||||
href={pathOfThread(thread)}
|
>
|
||||||
>
|
{titleOfThread(thread)}
|
||||||
{titleOfThread(thread)}
|
</Link>
|
||||||
</Link>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuAction
|
<SidebarMenuAction
|
||||||
showOnHover
|
showOnHover
|
||||||
className="bg-background/50 hover:bg-background"
|
className="bg-background/50 hover:bg-background"
|
||||||
>
|
>
|
||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
<span className="sr-only">{t.common.more}</span>
|
<span className="sr-only">{t.common.more}</span>
|
||||||
</SidebarMenuAction>
|
</SidebarMenuAction>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-48 rounded-lg"
|
className="w-48 rounded-lg"
|
||||||
side={"right"}
|
side={"right"}
|
||||||
align={"start"}
|
align={"start"}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleRenameClick(
|
handleRenameClick(
|
||||||
thread.thread_id,
|
thread.thread_id,
|
||||||
titleOfThread(thread),
|
titleOfThread(thread),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="text-muted-foreground" />
|
<Pencil className="text-muted-foreground" />
|
||||||
<span>{t.common.rename}</span>
|
<span>{t.common.rename}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleShare(thread)}
|
onSelect={() => handleShare(thread)}
|
||||||
>
|
>
|
||||||
<Share2 className="text-muted-foreground" />
|
<Share2 className="text-muted-foreground" />
|
||||||
<span>{t.common.share}</span>
|
<span>{t.common.share}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<Download className="text-muted-foreground" />
|
<Download className="text-muted-foreground" />
|
||||||
<span>{t.common.export}</span>
|
<span>{t.common.export}</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleExport(thread, "markdown")
|
handleExport(thread, "markdown")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileText className="text-muted-foreground" />
|
<FileText className="text-muted-foreground" />
|
||||||
<span>{t.common.exportAsMarkdown}</span>
|
<span>{t.common.exportAsMarkdown}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleExport(thread, "json")
|
handleExport(thread, "json")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileJson className="text-muted-foreground" />
|
<FileJson className="text-muted-foreground" />
|
||||||
<span>{t.common.exportAsJSON}</span>
|
<span>{t.common.exportAsJSON}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleDelete(thread.thread_id)}
|
onSelect={() => handleDelete(thread.thread_id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="text-muted-foreground" />
|
<Trash2 className="text-muted-foreground" />
|
||||||
<span>{t.common.delete}</span>
|
<span>{t.common.delete}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SidebarMenuItem>
|
||||||
</SidebarMenuButton>
|
);
|
||||||
</SidebarMenuItem>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
InfoIcon,
|
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
@@ -136,12 +135,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "skills" && (
|
|
||||||
<SkillSettingsPage
|
|
||||||
onClose={() => props.onOpenChange?.(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||||
|
{activeSection === "skills" && <SkillSettingsPage />}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -10,6 +25,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -29,13 +45,15 @@ import {
|
|||||||
ItemContent,
|
ItemContent,
|
||||||
ItemDescription,
|
ItemDescription,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
useEnableSkill,
|
useDeleteCustomSkill,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
|
useSetSkillPinned,
|
||||||
|
useSetSkillPublished,
|
||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
@@ -44,6 +62,7 @@ import { env } from "@/env";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
import { SkillStateSheet } from "./skill-state-sheet";
|
||||||
|
|
||||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -73,29 +92,110 @@ function SkillSettingsList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("public");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
const [installOpen, setInstallOpen] = useState(false);
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
const { mutate: enableSkill } = useEnableSkill();
|
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||||
const filteredSkills = useMemo(
|
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||||
() => skills.filter((skill) => skill.category === filter),
|
|
||||||
[skills, filter],
|
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 = () => {
|
const handleCreateSkill = () => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
router.push("/?mode=skill");
|
router.push("/?mode=skill");
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<header className="flex justify-between">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs defaultValue="public" onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||||
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</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>
|
||||||
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -116,34 +216,267 @@ function SkillSettingsList({
|
|||||||
)}
|
)}
|
||||||
{filteredSkills.length > 0 &&
|
{filteredSkills.length > 0 &&
|
||||||
filteredSkills.map((skill) => (
|
filteredSkills.map((skill) => (
|
||||||
<Item className="w-full" variant="outline" key={skill.name}>
|
<SkillRow
|
||||||
<ItemContent>
|
key={skill.name}
|
||||||
<ItemTitle>
|
skill={skill}
|
||||||
<div className="flex items-center gap-2">{skill.name}</div>
|
currentUserId={currentUserId}
|
||||||
</ItemTitle>
|
isAdmin={isAdmin}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
<InstallSkillDialog
|
<InstallSkillDialog
|
||||||
open={installOpen}
|
open={installOpen}
|
||||||
onClose={() => setInstallOpen(false)}
|
onClose={() => setInstallOpen(false)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
<SkillStateSheet
|
||||||
|
kind="stale"
|
||||||
|
open={staleSheetOpen}
|
||||||
|
onOpenChange={setStaleSheetOpen}
|
||||||
|
skills={staleVisible}
|
||||||
|
/>
|
||||||
|
<SkillStateSheet
|
||||||
|
kind="archived"
|
||||||
|
open={archiveSheetOpen}
|
||||||
|
onOpenChange={setArchiveSheetOpen}
|
||||||
|
skills={archivedVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 }) {
|
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
@@ -167,9 +500,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
|||||||
function InstallSkillDialog({
|
function InstallSkillDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
isAdmin,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const dialog = t.settings.skills.installDialog;
|
const dialog = t.settings.skills.installDialog;
|
||||||
@@ -178,6 +513,7 @@ function InstallSkillDialog({
|
|||||||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
||||||
const validateMutation = useValidateSkillUpload();
|
const validateMutation = useValidateSkillUpload();
|
||||||
const installMutation = useInstallSkillUpload();
|
const installMutation = useInstallSkillUpload();
|
||||||
|
|
||||||
@@ -186,6 +522,7 @@ function InstallSkillDialog({
|
|||||||
setValidation(null);
|
setValidation(null);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setTargetCategory("custom");
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -242,7 +579,7 @@ function InstallSkillDialog({
|
|||||||
async (overwrite: boolean) => {
|
async (overwrite: boolean) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const result = await installMutation.mutateAsync({ file, overwrite });
|
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
||||||
toast.success(
|
toast.success(
|
||||||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||||||
"{name}",
|
"{name}",
|
||||||
@@ -255,13 +592,15 @@ function InstallSkillDialog({
|
|||||||
toast.error(err instanceof Error ? err.message : String(err));
|
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 isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||||
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
|
// Admin targeting public directory can overwrite an existing public skill.
|
||||||
const canOverwrite = !!validation && !validationError && isCustomConflict;
|
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||||
|
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||||
|
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||||
@@ -309,6 +648,27 @@ function InstallSkillDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{validateMutation.isPending && (
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
@@ -366,10 +726,10 @@ function InstallSkillDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isPublicConflict && (
|
{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" />
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
|
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
@@ -10,11 +11,14 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||||
|
|
||||||
export function WorkspaceNavChatList() {
|
export function WorkspaceNavChatList() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="px-2 pt-2">
|
<SidebarGroup className="px-2 pt-2">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -48,6 +52,17 @@ export function WorkspaceNavChatList() {
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||||
@@ -59,6 +74,19 @@ export function WorkspaceNavChatList() {
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { SettingsDialog } from "./settings";
|
import { SettingsDialog } from "./settings";
|
||||||
@@ -56,7 +57,7 @@ export function WorkspaceNavMenu() {
|
|||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||||
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
|
"appearance" | "memory" | "tools" | "notification" | "about"
|
||||||
>("appearance");
|
>("appearance");
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
@@ -74,6 +75,9 @@ export function WorkspaceNavMenu() {
|
|||||||
defaultSection={settingsDefaultSection}
|
defaultSection={settingsDefaultSection}
|
||||||
/>
|
/>
|
||||||
<SidebarMenu className="w-full">
|
<SidebarMenu className="w-full">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<NotificationBell />
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
|
|
||||||
import type { LoginByUsernameResponse } from "./index";
|
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(
|
export async function loginByUsername(
|
||||||
username: string,
|
username: string,
|
||||||
): Promise<LoginByUsernameResponse> {
|
): 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",
|
recentChats: "Recent chats",
|
||||||
demoChats: "Demo chats",
|
demoChats: "Demo chats",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
users: "Users",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
@@ -434,10 +435,77 @@ export const enUS: Translations = {
|
|||||||
"Manage the configuration and enabled status of the agent skills.",
|
"Manage the configuration and enabled status of the agent skills.",
|
||||||
createSkill: "Create skill",
|
createSkill: "Create skill",
|
||||||
installSkill: "Install skill",
|
installSkill: "Install skill",
|
||||||
|
goToEvolution: "Skill evolution",
|
||||||
emptyTitle: "No agent skill yet",
|
emptyTitle: "No agent skill yet",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||||
emptyButton: "Create Your First Skill",
|
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: {
|
installDialog: {
|
||||||
title: "Install skill",
|
title: "Install skill",
|
||||||
dropHint: "Drop a .zip or .skill archive here, or",
|
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).",
|
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
|
||||||
conflictPublic:
|
conflictPublic:
|
||||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
"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.",
|
installSuccess: "Skill '{name}' installed.",
|
||||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
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: {
|
notification: {
|
||||||
@@ -479,4 +611,26 @@ export const enUS: Translations = {
|
|||||||
emptyDescription: "Credits and acknowledgements will show here.",
|
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";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface NotificationsTranslations {
|
||||||
|
title: string;
|
||||||
|
bellAria: string;
|
||||||
|
unreadShort: string;
|
||||||
|
markAllRead: string;
|
||||||
|
empty: string;
|
||||||
|
deleteAria: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
// Locale meta
|
// Locale meta
|
||||||
locale: {
|
locale: {
|
||||||
@@ -116,6 +125,7 @@ export interface Translations {
|
|||||||
chats: string;
|
chats: string;
|
||||||
demoChats: string;
|
demoChats: string;
|
||||||
agents: string;
|
agents: string;
|
||||||
|
users: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
@@ -355,9 +365,73 @@ export interface Translations {
|
|||||||
description: string;
|
description: string;
|
||||||
createSkill: string;
|
createSkill: string;
|
||||||
installSkill: string;
|
installSkill: string;
|
||||||
|
goToEvolution: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: 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: {
|
installDialog: {
|
||||||
title: string;
|
title: string;
|
||||||
dropHint: string;
|
dropHint: string;
|
||||||
@@ -374,9 +448,63 @@ export interface Translations {
|
|||||||
warningsLabel: string;
|
warningsLabel: string;
|
||||||
conflictCustom: string;
|
conflictCustom: string;
|
||||||
conflictPublic: string;
|
conflictPublic: string;
|
||||||
|
conflictPublicAdminHint: string;
|
||||||
installSuccess: string;
|
installSuccess: string;
|
||||||
installSuccessReplaced: string;
|
installSuccessReplaced: string;
|
||||||
rejectInvalidExt: 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: {
|
notification: {
|
||||||
@@ -395,4 +523,21 @@ export interface Translations {
|
|||||||
emptyDescription: string;
|
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: "最近的对话",
|
recentChats: "最近的对话",
|
||||||
demoChats: "演示对话",
|
demoChats: "演示对话",
|
||||||
agents: "智能体",
|
agents: "智能体",
|
||||||
|
users: "用户管理",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
@@ -415,10 +416,74 @@ export const zhCN: Translations = {
|
|||||||
description: "管理 Agent Skill 配置和启用状态。",
|
description: "管理 Agent Skill 配置和启用状态。",
|
||||||
createSkill: "新建技能",
|
createSkill: "新建技能",
|
||||||
installSkill: "安装技能",
|
installSkill: "安装技能",
|
||||||
|
goToEvolution: "自我进化",
|
||||||
emptyTitle: "还没有技能",
|
emptyTitle: "还没有技能",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
emptyButton: "创建你的第一个技能",
|
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: {
|
installDialog: {
|
||||||
title: "安装技能",
|
title: "安装技能",
|
||||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||||
@@ -435,10 +500,66 @@ export const zhCN: Translations = {
|
|||||||
warningsLabel: "警告",
|
warningsLabel: "警告",
|
||||||
conflictCustom:
|
conflictCustom:
|
||||||
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
||||||
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
|
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||||
installSuccess: "技能 “{name}” 已安装。",
|
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
|
||||||
installSuccessReplaced: "技能 “{name}” 已替换。",
|
installSuccess: `技能 '{name}' 已安装。`,
|
||||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
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: {
|
notification: {
|
||||||
@@ -459,4 +580,26 @@ export const zhCN: Translations = {
|
|||||||
emptyDescription: "相关的致谢信息会展示在这里。",
|
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 { getBackendBaseURL } from "@/core/config";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
|
||||||
import type { Skill } from "./type";
|
import type { Skill, SkillState } from "./type";
|
||||||
|
|
||||||
export async function loadSkills() {
|
export async function loadSkills() {
|
||||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
||||||
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
|
|||||||
|
|
||||||
export async function installSkillUpload(
|
export async function installSkillUpload(
|
||||||
file: File,
|
file: File,
|
||||||
options: { overwrite?: boolean } = {},
|
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
|
||||||
): Promise<InstallSkillResponse> {
|
): Promise<InstallSkillResponse> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, file.name);
|
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(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||||
{ method: "POST", body: form },
|
{ method: "POST", body: form },
|
||||||
@@ -110,3 +113,73 @@ export async function installSkillUpload(
|
|||||||
}
|
}
|
||||||
return response.json() as Promise<InstallSkillResponse>;
|
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 { 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 ".";
|
import { loadSkills } from ".";
|
||||||
|
|
||||||
@@ -39,8 +48,56 @@ export function useValidateSkillUpload() {
|
|||||||
export function useInstallSkillUpload() {
|
export function useInstallSkillUpload() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
|
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
|
||||||
installSkillUpload(file, { overwrite }),
|
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: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
export interface Skill {
|
export type SkillState = "active" | "stale" | "archived";
|
||||||
name: string;
|
export type SkillSource = "agent" | "upload";
|
||||||
description: string;
|
|
||||||
category: string;
|
export interface Skill {
|
||||||
license: string;
|
name: string;
|
||||||
enabled: boolean;
|
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 {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@@ -17,13 +17,6 @@ import {
|
|||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
@@ -120,7 +113,7 @@ export default function NewAgentPage() {
|
|||||||
model_name: chatModelInput || undefined,
|
model_name: chatModelInput || undefined,
|
||||||
},
|
},
|
||||||
onToolEnd() {
|
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
|
// intercepted by SetupAgentApprovalMiddleware and the form is filled
|
||||||
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
||||||
// No agent record is fetched/refreshed during chat.
|
// No agent record is fetched/refreshed during chat.
|
||||||
@@ -156,7 +149,7 @@ export default function NewAgentPage() {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
// Names are display-only and may collide, so a timestamped placeholder
|
// Names are display-only and may collide, so a timestamped placeholder
|
||||||
// is safe to send to the backend just to satisfy the required-field
|
// 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
|
// 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
|
// refill it later (with the model's proposed name) on setup_agent
|
||||||
// approval.
|
// approval.
|
||||||
@@ -245,7 +238,7 @@ export default function NewAgentPage() {
|
|||||||
// Chat is purely a discussion channel. The agent record is only
|
// Chat is purely a discussion channel. The agent record is only
|
||||||
// created when the user explicitly clicks Save (top-right). If the
|
// created when the user explicitly clicks Save (top-right). If the
|
||||||
// model proposes a config via setup_agent, our approval handler
|
// 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 ensureThreadExists();
|
||||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||||
},
|
},
|
||||||
@@ -255,7 +248,7 @@ export default function NewAgentPage() {
|
|||||||
const handleApproveSetupAgent = useCallback(
|
const handleApproveSetupAgent = useCallback(
|
||||||
async (payload: SetupAgentApprovalPayload) => {
|
async (payload: SetupAgentApprovalPayload) => {
|
||||||
// Approving the model's setup_agent proposal **does not** persist
|
// 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
|
// can review it. Creation/update happens later when the user clicks
|
||||||
// the top-right Save button.
|
// the top-right Save button.
|
||||||
const proposedName = payload.name?.trim() ?? "";
|
const proposedName = payload.name?.trim() ?? "";
|
||||||
@@ -338,22 +331,23 @@ export default function NewAgentPage() {
|
|||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
当前对话模型
|
当前对话模型
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<select
|
||||||
value={chatModelInput}
|
value={chatModelInput}
|
||||||
onValueChange={setChatModelInput}
|
onChange={(event) =>
|
||||||
|
setChatModelInput(event.currentTarget.value)
|
||||||
|
}
|
||||||
disabled={isModelsLoading || thread.isLoading}
|
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">
|
<option value="" disabled>
|
||||||
<SelectValue placeholder="选择模型" />
|
选择模型
|
||||||
</SelectTrigger>
|
</option>
|
||||||
<SelectContent>
|
{models.map((model) => (
|
||||||
{models.map((model) => (
|
<option key={model.name} value={model.name}>
|
||||||
<SelectItem key={model.name} value={model.name}>
|
{model.display_name || model.name}
|
||||||
{model.display_name || model.name}
|
</option>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||||
@@ -405,22 +399,21 @@ export default function NewAgentPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">模型</label>
|
<label className="text-sm font-medium">模型</label>
|
||||||
<Select
|
<select
|
||||||
value={modelInput}
|
value={modelInput}
|
||||||
onValueChange={setModelInput}
|
onChange={(event) => setModelInput(event.currentTarget.value)}
|
||||||
disabled={isModelsLoading}
|
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">
|
<option value="" disabled>
|
||||||
<SelectValue placeholder="选择模型" />
|
选择模型
|
||||||
</SelectTrigger>
|
</option>
|
||||||
<SelectContent>
|
{models.map((model) => (
|
||||||
{models.map((model) => (
|
<option key={model.name} value={model.name}>
|
||||||
<SelectItem key={model.name} value={model.name}>
|
{model.display_name || model.name}
|
||||||
{model.display_name || model.name}
|
</option>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
该专家被使用时调用的模型。
|
该专家被使用时调用的模型。
|
||||||
</p>
|
</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 { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { RouteBaseProvider } from "@/core/route-base";
|
import { RouteBaseProvider } from "@/core/route-base";
|
||||||
import { SubtasksProvider } from "@/core/tasks/context";
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
|
||||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
|
||||||
|
|
||||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||||
|
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
import ChatsPage from "./ChatsPage";
|
import ChatsPage from "./ChatsPage";
|
||||||
import ChatPage from "./ChatPage";
|
import ChatPage from "./ChatPage";
|
||||||
@@ -18,19 +16,10 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
|||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
|
||||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<SubtasksProvider>
|
|
||||||
<ArtifactsProvider>
|
|
||||||
<PromptInputProvider>{children}</PromptInputProvider>
|
|
||||||
</ArtifactsProvider>
|
|
||||||
</SubtasksProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StrategyRoutes() {
|
export default function StrategyRoutes() {
|
||||||
return (
|
return (
|
||||||
<RouteBaseProvider value="/page/strategy">
|
<RouteBaseProvider value="/page/strategy">
|
||||||
|
<SidebarProvider>
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="qa/general" replace />} />
|
<Route index element={<Navigate to="qa/general" replace />} />
|
||||||
@@ -89,6 +78,7 @@ export default function StrategyRoutes() {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</RouteBaseProvider>
|
</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 ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
|
import SkillsPage from "./SkillsPage";
|
||||||
|
import UsersPage from "./UsersPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
|
||||||
export function WorkspaceLayout({
|
export function WorkspaceLayout({
|
||||||
@@ -63,7 +66,7 @@ export function WorkspaceLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SubtasksProvider>
|
<SubtasksProvider>
|
||||||
<ArtifactsProvider>
|
<ArtifactsProvider>
|
||||||
@@ -136,6 +139,22 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="skills"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<SkillsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="skills/evolution"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<SkillEvolutionPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="scheduled"
|
path="scheduled"
|
||||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||||
@@ -156,6 +175,14 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/users"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<UsersPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/recommended-questions"
|
path="settings/recommended-questions"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.gateway.routers import (
|
|||||||
mcp,
|
mcp,
|
||||||
memory,
|
memory,
|
||||||
models,
|
models,
|
||||||
|
notifications,
|
||||||
recommended_questions,
|
recommended_questions,
|
||||||
runs,
|
runs,
|
||||||
scheduled_tasks,
|
scheduled_tasks,
|
||||||
@@ -155,6 +156,68 @@ async def _iter_store_items(store, namespace, *, page_size: int = 500):
|
|||||||
offset += page_size
|
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:
|
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||||
"""Migrate LangGraph store threads with no user_id to the given admin.
|
"""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
|
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||||||
await _ensure_admin_user(app)
|
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
|
# Start IM channel service if any channels are configured
|
||||||
try:
|
try:
|
||||||
from app.channels.service import start_channel_service
|
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
|
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||||||
app.include_router(feedback.router)
|
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)
|
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||||
app.include_router(thread_runs.router)
|
app.include_router(thread_runs.router)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ class LocalAuthProvider(AuthProvider):
|
|||||||
"""Update an existing user."""
|
"""Update an existing user."""
|
||||||
return await self._repo.update_user(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:
|
async def get_user_by_email(self, email: str) -> User | None:
|
||||||
"""Get user by email."""
|
"""Get user by email."""
|
||||||
return await self._repo.get_user_by_email(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'."""
|
"""Return number of users with system_role == 'admin'."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_users(self) -> list[User]:
|
||||||
|
"""Return all users ordered by creation date."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
||||||
"""Get user by OAuth provider and ID.
|
"""Get user by OAuth provider and ID.
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
return await session.scalar(stmt) or 0
|
return await session.scalar(stmt) or 0
|
||||||
|
|
||||||
|
async def list_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:
|
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)
|
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
|
||||||
async with self._sf() as session:
|
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.local_provider import LocalAuthProvider
|
||||||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
|
from deerflow.persistence.notifications import NotificationStore
|
||||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||||
|
from deerflow.persistence.skills import SkillStore
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
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)
|
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
|
from deerflow.persistence.recommended_questions import make_recommended_question_store
|
||||||
|
|
||||||
app.state.recommended_question_store = make_recommended_question_store(sf)
|
app.state.recommended_question_store = make_recommended_question_store(sf)
|
||||||
@@ -112,6 +127,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
from deerflow.persistence.notifications import set_notification_store
|
||||||
|
|
||||||
|
set_notification_store(None)
|
||||||
await close_engine()
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +193,22 @@ def get_agent_store(request: Request) -> AgentStore:
|
|||||||
return val
|
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:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""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 import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
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 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)})
|
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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
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 (
|
from app.gateway.auth import (
|
||||||
UserResponse,
|
UserResponse,
|
||||||
create_access_token,
|
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.config import get_auth_config
|
||||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||||
from app.gateway.csrf_middleware import is_secure_request
|
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__)
|
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)
|
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) ─────────────────────────────────
|
# ── 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 json
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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 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 app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
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 import Skill
|
||||||
|
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
from deerflow.skills.installer import SkillAlreadyExistsError
|
||||||
from deerflow.skills.security_scanner import scan_skill_content
|
from deerflow.skills.security_scanner import scan_skill_content
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
@@ -22,6 +27,56 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api", tags=["skills"])
|
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):
|
class SkillResponse(BaseModel):
|
||||||
"""Response model for skill information."""
|
"""Response model for skill information."""
|
||||||
|
|
||||||
@@ -30,6 +85,16 @@ class SkillResponse(BaseModel):
|
|||||||
license: str | None = Field(None, description="License information")
|
license: str | None = Field(None, description="License information")
|
||||||
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
|
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
|
||||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
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):
|
class SkillsListResponse(BaseModel):
|
||||||
@@ -49,6 +114,7 @@ class SkillInstallRequest(BaseModel):
|
|||||||
|
|
||||||
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
|
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)")
|
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):
|
class SkillInstallResponse(BaseModel):
|
||||||
@@ -58,6 +124,7 @@ class SkillInstallResponse(BaseModel):
|
|||||||
skill_name: str = Field(..., description="Name of the installed skill")
|
skill_name: str = Field(..., description="Name of the installed skill")
|
||||||
message: str = Field(..., description="Installation result message")
|
message: str = Field(..., description="Installation result message")
|
||||||
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
|
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):
|
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.")
|
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:
|
def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse:
|
||||||
"""Convert a Skill object to a 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(
|
return SkillResponse(
|
||||||
name=skill.name,
|
name=skill.name,
|
||||||
description=skill.description,
|
description=skill.description,
|
||||||
license=skill.license,
|
license=skill.license,
|
||||||
category=skill.category,
|
category=skill.category,
|
||||||
enabled=skill.enabled,
|
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(
|
@router.get(
|
||||||
"/skills",
|
"/skills",
|
||||||
response_model=SkillsListResponse,
|
response_model=SkillsListResponse,
|
||||||
summary="List All Skills",
|
summary="List All Skills",
|
||||||
description="Retrieve a list of all available skills from both public and custom directories.",
|
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:
|
try:
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
user_id = _current_user_id(request)
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
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.",
|
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(
|
async def install_skill_upload(
|
||||||
|
request: Request,
|
||||||
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
|
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||||
config: AppConfig = Depends(get_config),
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
) -> SkillInstallResponse:
|
) -> 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)
|
tmp_path = await _persist_uploaded_skill(file)
|
||||||
try:
|
try:
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -225,10 +392,33 @@ async def install_skill_upload(
|
|||||||
summary="Install Skill",
|
summary="Install Skill",
|
||||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
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:
|
try:
|
||||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
user_id = _current_user_id(request)
|
||||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
except FileNotFoundError as e:
|
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")
|
@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:
|
try:
|
||||||
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
user_id = _current_user_id(request)
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
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)}")
|
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},
|
"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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name, config)
|
return await get_custom_skill(skill_name, config)
|
||||||
except HTTPException:
|
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")
|
@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:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
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 = get_or_new_skill_storage(app_config=config)
|
||||||
storage.delete_custom_skill(
|
storage.delete_custom_skill(
|
||||||
skill_name,
|
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."},
|
"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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except ValueError as 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)}")
|
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")
|
@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:
|
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||||
try:
|
try:
|
||||||
@@ -469,3 +865,186 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
|
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)}")
|
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 ""
|
||||||
return """
|
return """
|
||||||
## Skill Self-Evolution
|
## Skill Self-Evolution
|
||||||
After completing a task, consider creating or updating a skill when:
|
|
||||||
- The task required 5+ tool calls to resolve
|
You have access to `skill_manage` (plus `skill_list` / `skill_view`).
|
||||||
- You overcame non-obvious errors or pitfalls
|
|
||||||
- The user corrected your approach and the corrected version worked
|
After completing a non-trivial task — 5+ tool calls to resolve, recovery from
|
||||||
- You discovered a non-trivial, recurring workflow
|
a non-obvious error, a corrected approach that worked, or a recurring
|
||||||
If you used a skill and encountered issues not covered by it, patch it immediately.
|
workflow — **offer to save the approach as a reusable skill** so you can
|
||||||
Prefer patch over edit. Before creating a new skill, confirm with the user first.
|
replay it next time. Briefly propose a hyphen-case name and a one-line
|
||||||
Skip simple one-off tasks.
|
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(
|
messages[-1] = HumanMessage(
|
||||||
content=last_message.content,
|
content=last_message.content,
|
||||||
id=last_message.id,
|
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()},
|
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.runtime_paths import existing_project_file
|
||||||
from deerflow.config.sandbox_config import SandboxConfig
|
from deerflow.config.sandbox_config import SandboxConfig
|
||||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
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.skills_config import SkillsConfig
|
||||||
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
|
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
|
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")
|
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:
|
def logging_level_from_config(name: str | None) -> int:
|
||||||
"""Map ``config.yaml`` ``log_level`` string to a :mod:`logging` level constant."""
|
"""Map ``config.yaml`` ``log_level`` string to a :mod:`logging` level constant."""
|
||||||
mapping = logging.getLevelNamesMapping()
|
mapping = logging.getLevelNamesMapping()
|
||||||
@@ -204,6 +222,15 @@ class AppConfig(BaseModel):
|
|||||||
extensions_config = ExtensionsConfig.from_file()
|
extensions_config = ExtensionsConfig.from_file()
|
||||||
config_data["extensions"] = extensions_config.model_dump()
|
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)
|
result = cls.model_validate(config_data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -329,6 +356,7 @@ class AppConfig(BaseModel):
|
|||||||
_app_config: AppConfig | None = None
|
_app_config: AppConfig | None = None
|
||||||
_app_config_path: Path | None = None
|
_app_config_path: Path | None = None
|
||||||
_app_config_mtime: float | None = None
|
_app_config_mtime: float | None = None
|
||||||
|
_app_config_skill_evolution_overrides_mtime: float | None = None
|
||||||
_app_config_is_custom = False
|
_app_config_is_custom = False
|
||||||
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
|
_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=())
|
_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:
|
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
||||||
"""Load config from disk and refresh cache metadata."""
|
"""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)
|
resolved_path = AppConfig.resolve_config_path(config_path)
|
||||||
_app_config = AppConfig.from_file(str(resolved_path))
|
_app_config = AppConfig.from_file(str(resolved_path))
|
||||||
_app_config_path = resolved_path
|
_app_config_path = resolved_path
|
||||||
_app_config_mtime = _get_config_mtime(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
|
_app_config_is_custom = False
|
||||||
return _app_config
|
return _app_config
|
||||||
|
|
||||||
@@ -373,8 +402,14 @@ def get_app_config() -> AppConfig:
|
|||||||
|
|
||||||
resolved_path = AppConfig.resolve_config_path()
|
resolved_path = AppConfig.resolve_config_path()
|
||||||
current_mtime = _get_config_mtime(resolved_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 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:
|
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(
|
logger.info(
|
||||||
@@ -382,6 +417,12 @@ def get_app_config() -> AppConfig:
|
|||||||
_app_config_mtime,
|
_app_config_mtime,
|
||||||
current_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))
|
_load_and_cache_app_config(str(resolved_path))
|
||||||
return _app_config
|
return _app_config
|
||||||
|
|
||||||
@@ -409,10 +450,11 @@ def reset_app_config() -> None:
|
|||||||
`get_app_config()` to reload from file. Useful for testing
|
`get_app_config()` to reload from file. Useful for testing
|
||||||
or when switching between different configurations.
|
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 = None
|
||||||
_app_config_path = None
|
_app_config_path = None
|
||||||
_app_config_mtime = None
|
_app_config_mtime = None
|
||||||
|
_app_config_skill_evolution_overrides_mtime = None
|
||||||
_app_config_is_custom = False
|
_app_config_is_custom = False
|
||||||
|
|
||||||
|
|
||||||
@@ -424,10 +466,11 @@ def set_app_config(config: AppConfig) -> None:
|
|||||||
Args:
|
Args:
|
||||||
config: The AppConfig instance to use.
|
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 = config
|
||||||
_app_config_path = None
|
_app_config_path = None
|
||||||
_app_config_mtime = None
|
_app_config_mtime = None
|
||||||
|
_app_config_skill_evolution_overrides_mtime = None
|
||||||
_app_config_is_custom = True
|
_app_config_is_custom = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
@@ -1,6 +1,15 @@
|
|||||||
from pydantic import BaseModel, Field
|
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):
|
class SkillEvolutionConfig(BaseModel):
|
||||||
"""Configuration for agent-managed skill evolution."""
|
"""Configuration for agent-managed skill evolution."""
|
||||||
|
|
||||||
@@ -12,3 +21,7 @@ class SkillEvolutionConfig(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Optional model name for skill security moderation. Defaults to the primary chat model.",
|
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.
|
any user identity and returns ``False`` if the target is not a built-in.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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
|
return False
|
||||||
self._agents.pop(agent_id, None)
|
self._agents.pop(agent_id, None)
|
||||||
return True
|
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 datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.agents.base import AgentStore
|
from deerflow.persistence.agents.base import AgentStore
|
||||||
@@ -137,3 +137,8 @@ class AgentRepository(AgentStore):
|
|||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
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.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
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.recommended_questions.model import RecommendedQuestionRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
from deerflow.persistence.run.model import RunRow
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import (
|
||||||
@@ -28,6 +29,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
ScheduledTaskSubscriptionRow,
|
ScheduledTaskSubscriptionRow,
|
||||||
SchedulerThreadRow,
|
SchedulerThreadRow,
|
||||||
)
|
)
|
||||||
|
from deerflow.persistence.skills.model import SkillRow
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ __all__ = [
|
|||||||
"FeedbackRow",
|
"FeedbackRow",
|
||||||
"AgentRow",
|
"AgentRow",
|
||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
|
"NotificationRow",
|
||||||
"RecommendedQuestionRow",
|
"RecommendedQuestionRow",
|
||||||
"RunEventRow",
|
"RunEventRow",
|
||||||
"RunRow",
|
"RunRow",
|
||||||
@@ -43,6 +46,7 @@ __all__ = [
|
|||||||
"ScheduledTaskRunRow",
|
"ScheduledTaskRunRow",
|
||||||
"ScheduledTaskSubscriptionRow",
|
"ScheduledTaskSubscriptionRow",
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
|
"SkillRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"UserRow",
|
"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,
|
overwrite: bool = False,
|
||||||
require_skill_suffix: bool = True,
|
require_skill_suffix: bool = True,
|
||||||
|
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
from deerflow.skills.installer import (
|
from deerflow.skills.installer import (
|
||||||
SkillAlreadyExistsError,
|
SkillAlreadyExistsError,
|
||||||
@@ -199,10 +200,10 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
)
|
)
|
||||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
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"
|
category_dir = self._host_root / target_category.value
|
||||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
category_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_path = Path(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:
|
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||||
raise ValueError(f"Invalid skill name: {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")
|
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
|
replaced = False
|
||||||
if target.exists():
|
if target.exists():
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
@@ -231,7 +234,7 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
|
|
||||||
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
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
|
staging_target = Path(staging_root) / skill_name
|
||||||
shutil.copytree(skill_dir, staging_target)
|
shutil.copytree(skill_dir, staging_target)
|
||||||
_move_staged_skill_into_reserved_target(staging_target, target)
|
_move_staged_skill_into_reserved_target(staging_target, target)
|
||||||
@@ -242,6 +245,7 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
"skill_name": skill_name,
|
"skill_name": skill_name,
|
||||||
"message": f"Skill '{skill_name}' installed successfully",
|
"message": f"Skill '{skill_name}' installed successfully",
|
||||||
"overwritten": replaced,
|
"overwritten": replaced,
|
||||||
|
"category": target_category.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||||
|
|||||||
+14
-3
@@ -132,17 +132,28 @@ class SkillStorage(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@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.
|
"""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``.
|
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`."""
|
"""Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`."""
|
||||||
from deerflow.skills.installer import _run_async_install
|
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
|
@abstractmethod
|
||||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
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.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.mcp.tools import _make_sync_tool_wrapper
|
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.security_scanner import scan_skill_content
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
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)
|
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(
|
async def _skill_manage_impl(
|
||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: ToolRuntime[ContextT, ThreadState],
|
||||||
action: str,
|
action: str,
|
||||||
@@ -78,6 +159,10 @@ async def _skill_manage_impl(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
action: One of create, patch, edit, delete, write_file, remove_file.
|
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.
|
name: Skill name in hyphen-case.
|
||||||
content: New file content for create, edit, or write_file.
|
content: New file content for create, edit, or write_file.
|
||||||
path: Supporting file path for write_file or remove_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.
|
replace: Replacement text for patch.
|
||||||
expected_count: Optional expected number of replacements 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)
|
name = SkillStorage.validate_skill_name(name)
|
||||||
lock = _get_lock(name)
|
lock = _get_lock(name)
|
||||||
thread_id = _get_thread_id(runtime)
|
thread_id = _get_thread_id(runtime)
|
||||||
@@ -104,6 +200,18 @@ async def _skill_manage_impl(
|
|||||||
name,
|
name,
|
||||||
_history_record(action="create", file_path=SKILL_MD_FILE, prev_content=None, new_content=content, thread_id=thread_id, scanner=scan),
|
_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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return f"Created custom skill '{name}'."
|
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)."
|
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."
|
||||||
|
|
||||||
if action == "delete":
|
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(
|
await _to_thread(
|
||||||
skill_storage.delete_custom_skill,
|
skill_storage.delete_custom_skill,
|
||||||
name,
|
name,
|
||||||
@@ -161,6 +278,36 @@ async def _skill_manage_impl(
|
|||||||
scanner={"decision": "allow", "reason": "Deletion requested."},
|
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()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return f"Deleted custom skill '{name}'."
|
return f"Deleted custom skill '{name}'."
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,10 @@ def get_available_tools(
|
|||||||
builtin_tools.append(scheduled_task_tool)
|
builtin_tools.append(scheduled_task_tool)
|
||||||
skill_evolution_config = getattr(config, "skill_evolution", None)
|
skill_evolution_config = getattr(config, "skill_evolution", None)
|
||||||
if getattr(skill_evolution_config, "enabled", False):
|
if getattr(skill_evolution_config, "enabled", False):
|
||||||
|
from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||||
|
|
||||||
builtin_tools.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
|
# Add subagent tools only if enabled via runtime parameter
|
||||||
if subagent_enabled:
|
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