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

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+950
View File
@@ -0,0 +1,950 @@
# Open Canvas 模式需求拆解文档
## 1. 文档目标
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
- 前端:基于 `React 19 + Vite + react-router-dom`
- 后端:基于 `Python + FastAPI + LangGraph`
- 模型层:支持云模型与本地模型自由配置
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
---
## 2. 最终目标
### 2.1 用户视角目标
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
- 左侧是聊天区,右侧是 Canvas 编辑区
- 可以从空白文本或代码模板快速开始
- 可以通过聊天让 AI 生成一份结构化 artifact
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
- artifact 支持版本历史切换
- artifact 支持文本和代码两种主要类型
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
- 支持启用 Web 搜索增强
- 支持切换模型
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
### 2.2 工程视角目标
新增的 Canvas 模式需要满足以下工程原则:
- 不破坏现有 DeerFlow 工作区
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
- 可逐阶段上线
- 前后端状态协议清晰
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
---
## 3. 范围定义
### 3.1 本次需求包含
- 前端新增 `open-canvas` 专用目录
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
- 前端新增 Canvas 页面、上下文、组件、状态管理
- 后端新增 Canvas 专用线程状态字段
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
- 模型配置统一为 DeerFlow 后端配置驱动
- 支持本地大模型配置与模型列表动态下发
- 支持结构化 artifact 的保存、读取、更新、版本切换
### 3.2 本次需求不包含
- 一次性复刻 open-canvas 全部 UI 细节
- 完整照搬 Supabase 鉴权体系
- 直接复用 open-canvas 的 Next.js API Route
- 直接运行 open-canvas 的 LangGraph.js 后端
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
### 3.3 迁移原则
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
- 前端尽量复用其交互设计和数据结构
- 后端以 Python 实现同等能力
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
---
## 4. 总体设计思路
### 4.1 前端设计思路
在现有 `frontend-web/src` 下新增一个专用目录,例如:
```text
src/
open-canvas/
pages/
routes/
components/
contexts/
hooks/
types/
api/
utils/
constants/
```
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
建议路由形式:
- `/canvas`
- `/canvas/:threadId`
也可以挂在现有页面布局下,例如:
- `/workspace/canvas`
- `/workspace/canvas/:threadId`
推荐优先采用独立一级功能路由:
- 逻辑更清晰
- 状态隔离更容易
- 不容易污染现有 `workspace` 功能
### 4.2 后端设计思路
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
#### 方案 A:新增独立 Canvas Assistant
- 新增 `canvas_agent`
- 使用单独的 LangGraph 状态定义
- 前端调用时通过 `assistant_id=canvas_agent`
优点:
- 与现有 `lead_agent` 隔离
- 风险更低
- 更容易对齐 open-canvas 的图结构
缺点:
- 会产生一套新的 Agent 图维护成本
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
- Agent 根据模式进入 Canvas 路径
优点:
- 统一入口
缺点:
- 会显著增加现有 Agent 复杂度
- 容易影响现有聊天和文件产物模式
推荐先做方案 A。
### 4.3 模型配置设计思路
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
- `name`
- `display_name`
- `provider`
- `supports_thinking`
- `supports_reasoning_effort`
- `supports_vision`
- `supports_tool_calling`
- `supports_artifact_editing`
- `supports_code_editing`
- `temperature_range`
- `max_tokens_range`
- `base_url_source` 或 provider 标识
- `is_local`
---
## 5. 与当前项目的关键差异
### 5.1 当前 DeerFlow 现状
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
- `artifacts: string[]`
这更接近“生成文件产物”的工作模式。
前端当前 artifact 面板也主要是:
- 文件列表
- 文件详情
- 文件下载/预览
### 5.2 open-canvas 的关键能力
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
- 一个 conversation 对应一个主 artifact
- artifact 具有类型、标题、内容、版本
- 用户可以选中部分内容让 AI 改写
- 每次改写都会生成新版本
### 5.3 需要新增的核心能力
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
- 当前 artifact
- 当前版本索引
- 历史版本列表
- 选区信息
- 当前编辑模式
- 快速动作状态
- Web 搜索结果状态
---
## 6. 分阶段实施方案
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
---
## 7. 阶段一:前端路由与目录骨架
### 7.1 目标
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
### 7.2 需求
- 新增 `src/open-canvas/` 目录
- 新增 Canvas 页面路由
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
- 提供 Canvas 首页和 Canvas Thread 页
- 支持空状态展示
### 7.3 建议目录
```text
src/open-canvas/
api/
threads.ts
runs.ts
models.ts
components/
layout/
chat/
canvas/
artifacts/
quick-actions/
selectors/
contexts/
CanvasThreadContext.tsx
CanvasGraphContext.tsx
CanvasUIContext.tsx
hooks/
useCanvasThread.ts
useCanvasStream.ts
useCanvasArtifact.ts
pages/
CanvasHomePage.tsx
CanvasThreadPage.tsx
routes/
OpenCanvasRoutes.tsx
types/
artifact.ts
thread.ts
model.ts
utils/
artifact.ts
selection.ts
stream.ts
constants/
index.ts
```
### 7.4 路由建议
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
建议形式:
```text
/canvas
/canvas/:threadId
```
### 7.5 验收标准
- 能访问 Canvas 首页
- 能访问指定 thread 的 Canvas 页面
- 不影响现有 `workspace/*` 与 `strategy/*`
- 页面有独立布局骨架
---
## 8. 阶段二:前端 Canvas 布局与交互容器
### 8.1 目标
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
### 8.2 需求
- 左侧聊天区
- 右侧画布区
- 支持面板拖拽或折叠
- 初始空状态
- 支持“快速开始”:
- 新建空白文本 artifact
- 新建代码 artifact
### 8.3 组件拆分建议
- `CanvasShell`
- `CanvasSidebar`
- `CanvasChatPanel`
- `CanvasArtifactPanel`
- `CanvasEmptyState`
- `QuickStartActions`
### 8.4 快速开始能力
用户进入空线程时可以选择:
- 开始写文章
- 开始写代码
- 从模板开始
首版模板可只做最小集:
- 空白文档
- HTML 模板
- React 组件模板
- Python 脚本模板
### 8.5 验收标准
- 页面可稳定展示双栏布局
- 快速开始能创建前端本地 artifact 状态
- 代码和文本可分别进入不同渲染器
---
## 9. 阶段三:结构化 Artifact 数据模型
### 9.1 目标
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
### 9.2 核心要求
需要支持以下能力:
- 文本 artifact
- 代码 artifact
- 标题
- 当前版本索引
- 版本历史
- 当前选中版本切换
- 后续支持局部高亮编辑
### 9.3 建议数据结构
```ts
type CanvasArtifact = {
currentIndex: number;
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
};
type CanvasArtifactText = {
index: number;
type: "text";
title: string;
fullMarkdown: string;
};
type CanvasArtifactCode = {
index: number;
type: "code";
title: string;
language: string;
code: string;
};
```
### 9.4 线程状态建议新增字段
前端与后端需共同支持:
- `artifact`
- `highlighted_text`
- `highlighted_code`
- `canvas_mode`
- `web_search_enabled`
- `web_search_results`
- `quick_action_id`
### 9.5 与现有 `artifacts: string[]` 的关系
建议保留现有 `artifacts: string[]` 不动。
新增字段:
- `artifact` 用于 Canvas 主文档
- `artifacts` 继续用于文件产物
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
### 9.6 验收标准
- 前端类型定义完成
- 后端状态字段设计完成
- 确认与现有线程状态兼容
---
## 10. 阶段四:后端线程状态与 API 扩展
### 10.1 目标
让后端线程状态可以安全存储和返回 Canvas Artifact。
### 10.2 需求
- 扩展 Python `ThreadState`
- 扩展状态序列化逻辑
- 确保 `/api/threads/*` 返回结构化 `artifact`
- 确保 LangGraph 流中可带上 `artifact` 更新
### 10.3 后端改造点
建议关注以下模块:
- `packages/harness/deerflow/agents/thread_state.py`
- 线程 state serializer
- `app/gateway/routers/threads.py`
- 流事件适配层
### 10.4 注意事项
- 旧线程没有 `artifact` 字段时必须兼容
- 状态更新不能破坏现有消息结构
- 必须确保 JSON 可序列化
### 10.5 验收标准
- 新建线程可写入 `artifact`
- 读取线程时可返回 `artifact`
- 不影响现有线程读取
- 前端能从线程 state 中恢复 Canvas 内容
---
## 11. 阶段五:Canvas 专用 Agent 图
### 11.1 目标
实现与 open-canvas 类似的 AI 路由能力。
### 11.2 推荐方案
新增 `canvas_agent`,单独维护图逻辑。
### 11.3 节点拆分建议
首版建议至少拆成这些节点:
- `route_user_intent`
- `reply_general_input`
- `generate_artifact`
- `rewrite_artifact`
- `update_highlighted_text`
- `update_highlighted_code`
- `generate_followup`
二期可加:
- `rewrite_artifact_theme`
- `rewrite_code_theme`
- `change_length`
- `change_reading_level`
- `translate_artifact`
- `add_comments`
- `add_logs`
- `fix_bugs`
- `port_language`
- `web_search_enrich`
### 11.4 路由逻辑要求
系统需要能判断:
- 用户是在闲聊
- 用户要新生成 artifact
- 用户要整体重写当前 artifact
- 用户要局部修改当前 artifact
- 用户要执行某个快捷动作
### 11.5 状态输入要求
节点需要读取:
- 当前消息历史
- 当前 artifact
- 当前选区
- 当前模型配置
- Web 搜索开关
### 11.6 输出要求
节点输出必须能更新:
- `messages`
- `artifact`
- `web_search_results`
- `followup`
### 11.7 验收标准
- 能根据输入创建新 artifact
- 能根据输入整体改写 artifact
- 能保留版本历史
- 能在改写后返回自然语言 followup
---
## 12. 阶段六:前端流式同步与版本管理
### 12.1 目标
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
### 12.2 需求
- 复用现有 LangGraph stream client
- 在 Canvas 模式中解析 `artifact` 更新
- 支持流式文本更新
- 支持版本切换
- 支持断线后恢复线程状态
### 12.3 前端状态层建议
- `CanvasThreadContext`:线程基础信息
- `CanvasGraphContext`:消息、artifact、streaming 状态
- `CanvasUIContext`:面板、编辑态、选中态
### 12.4 关键能力
- 当前版本切换
- 新版本创建后自动定位到最新版本
- 手动编辑内容后保存
- 流结束后线程状态持久化
### 12.5 验收标准
- 发送消息时聊天和画布可同步更新
- 版本数会增加
- 切换历史版本稳定
- 刷新页面后线程状态能恢复
---
## 13. 阶段七:局部选区改写
### 13.1 目标
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
### 13.2 文本选区需求
- 支持在 Markdown 文本中选择一段文本
- 记录选区位置与原始文本
- 用户发出修改请求后,仅重写该选区
- 将选区结果合并为新版本 artifact
### 13.3 代码选区需求
- 支持在代码编辑器中选择一段代码
- 记录 start/end index 或行列信息
- AI 只返回替换片段
- 前端或后端负责将新片段拼接回完整代码
### 13.4 实现建议
首版建议:
- 文本:前端记录纯文本字符位置
- 代码:使用 CodeMirror selection 信息
不建议首版就做复杂块级富文本局部编辑。
### 13.5 风险
- Markdown 渲染态与源码态位置映射复杂
- 富文本块级位置难维护
- 流式更新中选区同步容易错位
### 13.6 首版折中方案
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
### 13.7 验收标准
- 文本局部改写成功
- 代码局部改写成功
- 原内容未改动部分保持不变
- 新版本正确入历史
---
## 14. 阶段八:快捷动作系统
### 14.1 目标
实现 open-canvas 风格的快捷动作体系。
### 14.2 首批推荐动作
文本类:
- 扩写
- 精简
- 改正式语气
- 改口语语气
- 改阅读难度
- 翻译
代码类:
- 加注释
- 加日志
- 修复 bug
- 优化代码
- 转换语言
### 14.3 设计方式
快捷动作不要直接写死在 UI,建议配置化:
- `id`
- `label`
- `type`
- `applicable_to`
- `prompt_template`
### 14.4 后端设计
快捷动作可通过:
- 前端传 `quick_action_id`
- 后端路由节点根据动作 id 选择 prompt
### 14.5 验收标准
- 文本动作可执行
- 代码动作可执行
- 结果进入新版本
- 不同动作提示词可扩展
---
## 15. 阶段九:模型配置统一与本地模型支持
### 15.1 目标
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
### 15.2 原则
- 后端 `config.yaml` 为真源
- 前端只展示后端暴露的模型信息
- 不维护一份脱离后端的前端常量模型表
### 15.3 本地模型支持范围
建议优先支持两类:
- `Ollama`
- OpenAI-compatible 本地服务
- LM Studio
- vLLM
- LocalAI
- one-api 风格代理
### 15.4 后端配置建议
后端模型配置应至少支持:
- `name`
- `display_name`
- `use`
- `model`
- `api_key`
- `base_url`
- `supports_vision`
- `supports_thinking`
- `supports_tool_calling`
- `is_local`
- `canvas_capabilities`
### 15.5 Canvas 模式额外要求
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
- `supports_canvas_generation`
- `supports_canvas_rewrite`
- `supports_canvas_code_edit`
- `supports_streaming_text`
- `supports_streaming_tool_calls`
### 15.6 与 open-canvas 的关系
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
### 15.7 验收标准
- 前端模型选择器可显示后端返回模型
- 可切换本地模型
- Canvas 模式能用本地模型生成和改写
---
## 16. 阶段十:Web 搜索增强
### 16.1 目标
实现类似 open-canvas 的可选 Web 搜索增强。
### 16.2 需求
- UI 提供 Web 搜索开关
- 后端在需要时走搜索节点
- 搜索结果写入线程状态
- 结果可在画布旁侧栏展示
### 16.3 首版建议
只做:
- 开关
- 搜索结果列表
- 搜索结果用于生成 artifact
暂不做复杂网页抓取编辑链路。
### 16.4 验收标准
- 打开搜索后可返回搜索结果
- 搜索结果可参与 artifact 生成
- 结果展示不阻塞主画布
---
## 17. 阶段十一:持久化、导出与文件产物联动
### 17.1 目标
让 Canvas 文档与 DeerFlow 原有文件能力协同。
### 17.2 需求
- artifact 持久化在线程 state 中
- 可手动导出为文件
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
- Canvas artifact 与 `artifacts: string[]` 可双向关联
### 17.3 推荐能力
- 导出 Markdown
- 导出代码文件
- 另存为 artifact file
- 从 artifact file 回填到 Canvas
### 17.4 验收标准
- 当前版本可导出
- 导出文件可在现有文件面板查看
- 文件型 artifact 和 Canvas artifact 能共存
---
## 18. 推荐开发顺序
建议严格按以下顺序推进:
1. 路由和目录骨架
2. Canvas 页面布局
3. 前端 artifact 数据模型
4. 后端线程状态扩展
5. 新建 Canvas Agent 图
6. 前后端流式同步
7. 整体生成与整体改写
8. 局部选区改写
9. 快捷动作
10. 本地模型支持完善
11. Web 搜索增强
12. 导出与文件联动
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
---
## 19. 里程碑定义
### M1:页面可进入
- 路由接通
- 双栏布局完成
- 本地 mock artifact 可渲染
### M2:后端状态打通
- 线程可保存结构化 artifact
- 页面刷新后能恢复
### M3:AI 生成可用
- 可新建文本/代码 artifact
- 可整体改写
- 可产生版本历史
### M4:核心体验可用
- 选区改写可用
- 快捷动作可用
- 模型切换可用
### M5:增强能力完整
- 本地模型支持完善
- Web 搜索增强
- 导出和文件联动
---
## 20. 风险与难点
### 20.1 最大难点
- 结构化 artifact 状态从无到有
- 局部选区改写的精确性
- 流式状态与版本管理的一致性
- 不破坏现有 DeerFlow 文件型 artifact 体系
### 20.2 技术风险
- 线程 state JSON 结构过大
- 大模型对局部改写指令遵循不稳定
- 本地模型 tool calling 与流式能力差异大
- 文本渲染态与源码态位置映射复杂
### 20.3 规避建议
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
- 首版只支持文本和代码两种 artifact 类型
- 首版先做整体生成和整体改写,再做局部改写
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
---
## 21. 验收总清单
### 21.1 前端
- 有独立 Canvas 路由
- 有专用目录和模块边界
- 双栏布局可用
- 文本和代码两类 artifact 可渲染
- 历史版本可切换
- 选区改写可交互
- 快捷动作可触发
- 模型可切换
### 21.2 后端
- 线程状态支持 `artifact`
- 新增 `canvas_agent` 或等价模式
- 生成、重写、局部改写可用
- 流式返回稳定
- 模型列表接口支持 Canvas 能力元数据
### 21.3 本地模型
- 可配置 Ollama
- 可配置 OpenAI-compatible 本地服务
- 前端可见并可切换
- 至少能完成文本生成和整体改写
---
## 22. 推荐首版最小可交付范围
如果要控制风险,建议首版只交付以下内容:
- 新增 `canvas/*` 路由
- 双栏布局
- 结构化 `artifact`
- `canvas_agent`
- 文本/代码 artifact 新建
- 整体生成
- 整体改写
- 历史版本切换
- 后端模型动态加载
- Ollama 或本地 OpenAI-compatible 至少一种可用
先不要把以下内容放进首版强制目标:
- 富文本块级精确编辑
- 全量快捷动作
- 复杂 Web 搜索增强
- 多 artifact 并行管理
---
## 23. 后续实施建议
建议下一步基于本文档继续输出两份落地文档:
### 23.1 前端实施清单
逐文件列出:
- 要新建哪些文件
- 哪些组件可复用
- 哪些页面要接入路由
- 哪些状态要从现有 hooks 抽离
### 23.2 后端实施清单
逐模块列出:
- 要改哪些 Python 文件
- 新增哪些状态字段
- 新增哪些 Agent 节点
- 哪些 API 需要扩展
---
## 24. 结论
这个需求很大,但完全可拆。
正确做法不是“先抄 UI”,而是按下面的主线推进:
- 先建前端 Canvas 子系统
- 再建结构化 artifact 状态
- 再建 Canvas Agent 图
- 再补局部改写和快捷动作
- 最后统一本地模型能力
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
+19 -17
View File
@@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
+27 -27
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
@@ -37,27 +37,27 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };
+36 -26
View File
@@ -1,6 +1,7 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
@@ -10,28 +11,37 @@ function Collapsible({
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
const CollapsibleTrigger = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
>(({ className, ...props }, ref) => {
return (
<CollapsiblePrimitive.CollapsibleTrigger
ref={ref}
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
);
});
CollapsibleTrigger.displayName =
CollapsiblePrimitive.CollapsibleTrigger.displayName;
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ ...props }, ref) => {
return (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
data-slot="collapsible-content"
{...props}
/>
);
});
CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+74 -58
View File
@@ -12,11 +12,20 @@ function Dialog({
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
const DialogTrigger = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DialogPrimitive.Trigger
ref={ref}
data-slot="dialog-trigger"
{...props}
/>
);
});
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
function DialogPortal({
...props
@@ -24,61 +33,68 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
const DialogClose = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
>(({ ...props }, ref) => {
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
});
DialogClose.displayName = DialogPrimitive.Close.displayName;
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
});
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
+102 -89
View File
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
@@ -198,45 +205,51 @@ function DropdownMenuSub({
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
+37 -29
View File
@@ -11,34 +11,42 @@ function HoverCard({
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
const HoverCardTrigger = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<HoverCardPrimitive.Trigger
ref={ref}
data-slot="hover-card-trigger"
{...props}
/>
);
});
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
ref={ref}
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
});
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+90 -87
View File
@@ -24,68 +24,69 @@ function SelectValue({
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
@@ -100,32 +101,34 @@ function SelectLabel({
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
+71 -61
View File
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
const SheetTrigger = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
>(({ ...props }, ref) => {
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
});
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
const SheetClose = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
>(({ ...props }, ref) => {
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
});
SheetClose.displayName = SheetPrimitive.Close.displayName;
function SheetPortal({
...props
@@ -28,58 +34,62 @@ function SheetPortal({
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
});
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}
>(({ className, children, side = "right", ...props }, ref) => {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
});
SheetContent.displayName = SheetPrimitive.Content.displayName;
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
+169 -153
View File
@@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
function SidebarGroupContent({
className,
@@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
function SidebarMenuBadge({
className,
@@ -670,37 +686,37 @@ function SidebarMenuSubItem({
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
+53 -45
View File
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>
>(({ className, variant = "default", ...props }, ref) => {
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Content
ref={ref}
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+38 -28
View File
@@ -28,33 +28,43 @@ function Tooltip({
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<TooltipPrimitive.Trigger
ref={ref}
data-slot="tooltip-trigger"
{...props}
/>
);
});
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset, children, ...props }, ref) => {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
});
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,79 @@
"use client";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
export function UserManagementPage() {
const { t } = useI18n();
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive text-sm py-4">
{String(error)}
</div>
)}
{!isLoading && !error && users && users.length === 0 && (
<div className="text-muted-foreground text-sm py-8 text-center">
{t.adminUsers.empty}
</div>
)}
{!isLoading && !error && users && users.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr
key={user.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">
{user.system_role === "admin" ? (
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
) : (
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
{user.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SettingsSection>
</div>
);
}
@@ -8,6 +8,8 @@ import {
EyeIcon,
LoaderIcon,
PackageIcon,
PencilIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
@@ -32,6 +34,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
@@ -39,6 +43,7 @@ import {
} from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
@@ -119,8 +124,27 @@ export function ArtifactFileDetail({
enabled: isCodeFile && !isWriteFile,
});
const displayContent = content ?? "";
const queryClient = useQueryClient();
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
const [editedContent, setEditedContent] = useState<string | null>(null);
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
// always reflects the original tool-call output, not the on-disk file after save).
const [savedContent, setSavedContent] = useState<string | null>(null);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
const isHtmlStreaming = language === "html" && thread.isLoading;
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
const activeContent = editedContent ?? displayContent;
const isDirty = editedContent !== null && editedContent !== displayContent;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (language === "html") return "HTML";
@@ -130,22 +154,12 @@ export function ArtifactFileDetail({
return "FILE";
}, [isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setEditedContent(null);
setSavedContent(null);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
@@ -308,6 +322,40 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return;
setIsSaving(true);
try {
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: activeContent }),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail ?? "保存失败");
}
if (isWriteFile) {
setSavedContent(activeContent);
} else {
await queryClient.invalidateQueries({
queryKey: ["artifact", filepathFromProps, threadId],
});
}
setEditedContent(null);
toast.success("已保存");
} catch (error) {
console.error("Save artifact failed:", error);
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
@@ -406,7 +454,27 @@ export function ArtifactFileDetail({
)}
</div>
<div className="flex items-center justify-end gap-2">
{canEdit && viewMode === "code" && !isDirty && (
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
<PencilIcon className="size-3" />
可编辑
</span>
)}
{canEdit && viewMode === "code" && isDirty && (
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
未保存
</span>
)}
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEdit && viewMode === "code" && (
<ArtifactAction
icon={isSaving ? LoaderIcon : SaveIcon}
label="保存"
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
disabled={isSaving || !isDirty}
onClick={handleSave}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
@@ -513,17 +581,26 @@ export function ArtifactFileDetail({
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
content={activeContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="size-full">
<div
className="size-full"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
}}
>
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly
value={activeContent}
readonly={!canEdit}
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
/>
</div>
)}
@@ -41,6 +41,7 @@ export function CodeEditor({
readonly,
disabled,
autoFocus,
onChange,
settings,
}: {
className?: string;
@@ -49,6 +50,7 @@ export function CodeEditor({
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
onChange?: (value: string) => void;
settings?: unknown;
}) {
const {
@@ -107,6 +109,7 @@ export function CodeEditor({
}}
autoFocus={autoFocus}
value={value}
onChange={onChange}
/>
)}
</div>
@@ -0,0 +1,212 @@
"use client";
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
SidebarMenuButton,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
export function NotificationBell() {
const { t } = useI18n();
const { open: isSidebarOpen } = useSidebar();
const [open, setOpen] = useState(false);
// Render badge from the lightweight count endpoint so the bell stays cheap
// when nobody opens it. Only when the user actually opens the dropdown do
// we pay for the full list query.
const { data: unreadCount = 0 } = useUnreadCount();
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={t.notifications.bellAria}
>
{isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<div className="relative">
<BellIcon className="size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
<span>{t.notifications.title}</span>
{hasUnread && (
<span className="text-muted-foreground ml-auto text-xs">
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
</span>
)}
</div>
) : (
<div className="flex size-full items-center justify-center">
<div className="relative">
<BellIcon className="text-sidebar-foreground/80 size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
)}
</div>
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
sideOffset={8}
className="w-96 p-0"
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<span className="text-sm font-semibold">
{t.notifications.title}
</span>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
</div>
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.notifications.empty}
</div>
) : (
<ul className="divide-y">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
!notification.read && "bg-blue-500/5",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-2">
{!notification.read && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"truncate text-sm",
!notification.read && "font-medium",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -173,91 +173,89 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -2,7 +2,6 @@
import {
BellIcon,
InfoIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
@@ -136,12 +135,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "skills" && (
<SkillSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -1,6 +1,21 @@
"use client";
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ClockIcon,
EyeIcon,
EyeOffIcon,
FileArchive,
Loader2,
PinIcon,
PinOffIcon,
SparklesIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -10,6 +25,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -29,13 +45,15 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useEnableSkill,
useDeleteCustomSkill,
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -44,6 +62,7 @@ import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
@@ -73,29 +92,110 @@ function SkillSettingsList({
}) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("public");
const [filter, setFilter] = useState<string>("all");
const [installOpen, setInstallOpen] = useState(false);
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// Visible-to-me skills, classified into the three buckets the UI cares about.
// "All" = built-in + anyone-published-custom + mine (active state only).
// "Mine" = strictly my own custom skills (active state only).
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
const stale: Skill[] = [];
const archived: Skill[] = [];
for (const skill of skills) {
const state = skill.state ?? "active";
const isCustom = skill.category === "custom";
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isPublishedByOthers =
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
if (state === "stale") {
stale.push(skill);
continue;
}
if (state === "archived") {
archived.push(skill);
continue;
}
// state === active
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
all.push(skill);
}
if (isOwnedByMe) {
mine.push(skill);
}
}
return {
activeAll: all,
activeMine: mine,
staleVisible: stale,
archivedVisible: archived,
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs defaultValue="public" onValueChange={setFilter}>
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="outline"
disabled={staleCount === 0}
onClick={() => setStaleSheetOpen(true)}
title={t.settings.skills.badgeStaleTitle}
>
<ClockIcon className="size-4" />
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
</Button>
<Button
size="sm"
variant="outline"
disabled={archivedCount === 0}
onClick={() => setArchiveSheetOpen(true)}
title={t.settings.skills.badgeArchivedTitle}
>
<ArchiveIcon className="size-4" />
{t.settings.skills.entryArchived.replace(
"{count}",
String(archivedCount),
)}
</Button>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
onClose?.();
router.push("/page/workspace/skills/evolution");
}}
>
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
@@ -116,34 +216,267 @@ function SkillSettingsList({
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<Item className="w-full" variant="outline" key={skill.name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">{skill.name}</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={skill.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onCheckedChange={(checked) =>
enableSkill({ skillName: skill.name, enabled: checked })
}
/>
</ItemActions>
</Item>
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
/>
<SkillStateSheet
kind="stale"
open={staleSheetOpen}
onOpenChange={setStaleSheetOpen}
skills={staleVisible}
/>
<SkillStateSheet
kind="archived"
open={archiveSheetOpen}
onOpenChange={setArchiveSheetOpen}
skills={archivedVisible}
/>
</div>
);
}
function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
// the multitenancy fields still classify public skills correctly.
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
next
? t.settings.skills.publishSuccess
: t.settings.skills.unpublishSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePinned() {
const next = !skill.pinned;
try {
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
toast.success(
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
</ItemContent>
<ItemActions>
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="size-3.5" />
) : (
<PinIcon className="size-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<EyeOffIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
)}
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</ItemActions>
</Item>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -167,9 +500,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
function InstallSkillDialog({
open,
onClose,
isAdmin,
}: {
open: boolean;
onClose: () => void;
isAdmin: boolean;
}) {
const { t } = useI18n();
const dialog = t.settings.skills.installDialog;
@@ -178,6 +513,7 @@ function InstallSkillDialog({
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
@@ -186,6 +522,7 @@ function InstallSkillDialog({
setValidation(null);
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
if (inputRef.current) inputRef.current.value = "";
}, []);
@@ -242,7 +579,7 @@ function InstallSkillDialog({
async (overwrite: boolean) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite });
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
@@ -255,13 +592,15 @@ function InstallSkillDialog({
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
const canOverwrite = !!validation && !validationError && isCustomConflict;
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -309,6 +648,27 @@ function InstallSkillDialog({
/>
</div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
<div className="flex gap-4">
{(["custom", "public"] as const).map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
</label>
))}
</div>
</div>
)}
{validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
@@ -366,10 +726,10 @@ function InstallSkillDialog({
</div>
)}
{isPublicConflict && (
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
</span>
</div>
)}
@@ -0,0 +1,360 @@
"use client";
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { cn } from "@/lib/utils";
type Kind = "stale" | "archived";
export function SkillStateSheet({
kind,
open,
onOpenChange,
skills,
}: {
kind: Kind;
open: boolean;
onOpenChange: (open: boolean) => void;
skills: Skill[];
}) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const isArchived = kind === "archived";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
{isArchived ? (
<ArchiveIcon className="size-4" />
) : (
<ClockIcon className="size-4" />
)}
{isArchived ? ts.archivedTitle : ts.staleTitle}
<span className="text-muted-foreground text-sm font-normal">
({skills.length})
</span>
</SheetTitle>
<SheetDescription>
{isArchived ? ts.archivedDescription : ts.staleDescription}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto p-4">
{skills.length === 0 ? (
<div className="text-muted-foreground py-12 text-center text-sm">
{ts.empty}
</div>
) : (
<div className="flex flex-col gap-3">
{skills.map((skill) => (
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
const { t } = useI18n();
const tt = t.settings.skills;
const ts = tt.stateSheet;
const setState = useSetSkillState();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const pending = setState.isPending || deleteSkill.isPending;
const isArchived = kind === "archived";
const timeLabel = useMemo(() => {
if (isArchived) {
const when = skill.last_activity_at;
return when
? ts.archivedAt.replace("{when}", formatRelative(when))
: ts.archivedAt.replace("{when}", "—");
}
const when = skill.last_activity_at;
return when
? ts.lastUsedAt.replace("{when}", formatRelative(when))
: ts.neverUsed;
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
async function handleRestore() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "active" });
toast.success(ts.restoreSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleArchive() {
try {
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
toast.success(ts.archiveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(tt.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{skill.name}</span>
{isArchived ? (
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
{tt.badgeArchived}
</Badge>
) : (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title={tt.badgeStaleTitle}
>
{tt.badgeStale}
</Badge>
)}
{skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={tt.badgeSourceAgentTitle}
>
{tt.badgeSourceAgent}
</Badge>
)}
{skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={tt.badgeSourceUploadTitle}
>
{tt.badgeSourceUpload}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{skill.description}
</p>
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>{timeLabel}</span>
<span>
{skill.use_count && skill.use_count > 0
? tt.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: tt.useCountInlineZero}
</span>
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => void handleRestore()}
disabled={pending}
>
<RotateCcwIcon className="size-3.5" />
{ts.actionRestore}
</Button>
{!isArchived && (
<Button
size="sm"
variant="outline"
onClick={() => void handleArchive()}
disabled={pending}
>
<ArchiveIcon className="size-3.5" />
{ts.actionArchiveNow}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={pending}
title={ts.moreMenu}
>
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
{ts.actionView}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setConfirmDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2Icon className="size-3.5" />
{ts.actionDelete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SkillViewDialog
skill={skill}
open={viewOpen}
onOpenChange={setViewOpen}
/>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{tt.deleteAction}</DialogTitle>
<DialogDescription>
{tt.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function SkillViewDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tv = t.settings.skills.viewDialog;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{tv.title}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 text-sm">
<Row label={tv.descriptionLabel}>
<span className="whitespace-pre-wrap">{skill.description}</span>
</Row>
<Row label={tv.stateLabel}>
<span className={cn("font-medium", stateColor(skill.state))}>
{skill.state ?? "active"}
</span>
</Row>
{skill.use_count !== undefined && (
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
)}
{skill.last_activity_at && (
<Row label={tv.lastUsedLabel}>
{formatRelative(skill.last_activity_at)}
</Row>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{tv.close}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}
function stateColor(state: string | undefined): string {
switch (state) {
case "archived":
return "text-muted-foreground";
case "stale":
return "text-amber-600 dark:text-amber-300";
default:
return "text-emerald-600 dark:text-emerald-300";
}
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return iso;
const sec = Math.max(0, Math.floor(ms / 1000));
const min = Math.floor(sec / 60);
const hour = Math.floor(min / 60);
const day = Math.floor(hour / 24);
if (day >= 1) return `${day}d ago`;
if (hour >= 1) return `${hour}h ago`;
if (min >= 1) return `${min}m ago`;
return "just now";
}
@@ -0,0 +1,456 @@
"use client";
import {
ArrowLeftIcon,
Loader2Icon,
PlayIcon,
RefreshCwIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
useCuratorLogs,
useCuratorStatus,
useRunCurator,
useUpdateCuratorConfig,
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
const { t } = useI18n();
const router = useRouter();
const tt = t.settings.skillEvolution;
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{tt.title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{tt.description}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/page/workspace/skills")}
>
<ArrowLeftIcon className="size-4" />
{tt.backToSkills}
</Button>
</header>
<StatusCard />
<ConfigCard />
<LogsCard />
</div>
);
}
function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
if (!status?.last_run_at) return tt.lastRunNever;
try {
return new Date(status.last_run_at).toLocaleString();
} catch {
return status.last_run_at;
}
}, [status?.last_run_at, tt.lastRunNever]);
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
{status && (
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={status.enabled ? "default" : "secondary"}>
{status.enabled ? tt.enabled : tt.disabled}
</Badge>
{status.paused && (
<Badge variant="outline">{tt.paused}</Badge>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
{tt.runNow}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : status ? (
<div className="grid gap-3 text-sm sm:grid-cols-2">
<StatRow label={tt.runCount}>
{status.run_count}
{tt.runCountUnit && (
<span className="text-muted-foreground ml-1">
{tt.runCountUnit}
</span>
)}
</StatRow>
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
<span className="text-muted-foreground">
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
</span>
</StatRow>
<StatRow label={tt.skillCounts} className="sm:col-span-2">
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
<span>
{tt.total}: {status.agent_created_skills.total}
</span>
<span>
{tt.active}: {status.agent_created_skills.active}
</span>
<span>
{tt.stale}: {status.agent_created_skills.stale}
</span>
<span>
{tt.archived}: {status.agent_created_skills.archived}
</span>
</span>
</StatRow>
</div>
) : null}
</CardContent>
</Card>
);
}
function StatRow({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
<span className="font-medium">{children}</span>
</div>
);
}
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
if (config && !draft) {
setDraft(config);
}
}, [config, draft]);
if (isLoading || !draft) {
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
</CardContent>
</Card>
);
}
const isDirty =
!!config &&
JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
try {
const updated = await updateConfig.mutateAsync(draft);
setDraft(updated);
toast.success(tt.saveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow
label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.interval_hours}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.staleAfterDaysLabel}
helper={tt.staleAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.stale_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.archiveAfterDaysLabel}
helper={tt.archiveAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.archive_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
})
}
/>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)}
>
{tt.reset}
</Button>
<Button
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={handleSave}
>
{updateConfig.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{tt.save}
</Button>
</div>
</CardContent>
</Card>
);
}
function FieldRow({
label,
helper,
children,
}: {
label: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
<div className="flex-1">
<div className="text-sm font-medium">{label}</div>
{helper && (
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
function LogsCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.logs;
const [autoRefresh, setAutoRefresh] = useState(true);
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
const records = logs?.records ?? [];
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<label className="text-muted-foreground flex items-center gap-2 text-xs">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
{tt.autoRefresh}
</label>
<Button size="sm" variant="outline" onClick={() => void refetch()}>
<RefreshCwIcon className="size-3.5" />
{tt.refresh}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading && records.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : records.length === 0 ? (
<div className="text-muted-foreground text-sm">{tt.empty}</div>
) : (
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
{records.map((record, idx) => (
<div
key={`${record.ts}-${idx}`}
className={cn(
"flex gap-3",
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
record.level === "error" && "text-red-600 dark:text-red-300",
)}
>
<span className="text-muted-foreground shrink-0">
{formatTime(record.ts)}
</span>
<span className="text-muted-foreground w-14 shrink-0 uppercase">
{record.level}
</span>
<span className="whitespace-pre-wrap break-all">
{record.message === "run_started"
? "——— 本次策展开始 ———"
: record.message}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return ts;
}
}
@@ -1,6 +1,7 @@
"use client";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -10,11 +11,14 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<SidebarGroup className="px-2 pt-2">
<SidebarMenu>
@@ -48,6 +52,17 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -59,6 +74,19 @@ export function WorkspaceNavChatList() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
<UsersIcon />
<span>{t.sidebar.users}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -27,6 +27,7 @@ import {
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { NotificationBell } from "./notification-bell";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -56,7 +57,7 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
"appearance" | "memory" | "tools" | "notification" | "about"
>("appearance");
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
@@ -74,6 +75,9 @@ export function WorkspaceNavMenu() {
defaultSection={settingsDefaultSection}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
<NotificationBell />
</SidebarMenuItem>
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
+14
View File
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
export interface UserListItem {
id: string;
email: string;
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
export async function loginByUsername(
username: string,
): Promise<LoginByUsernameResponse> {
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { listAllUsers } from "./api";
export function useAllUsers(enabled: boolean) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
enabled,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
CuratorConfig,
CuratorLogs,
CuratorStatus,
} from "./types";
export async function getCuratorStatus(): Promise<CuratorStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
return response.json() as Promise<CuratorStatus>;
}
export async function getCuratorConfig(): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
return response.json() as Promise<CuratorConfig>;
}
export async function updateCuratorConfig(
payload: CuratorConfig,
): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return response.json() as Promise<CuratorConfig>;
}
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
method: "POST",
});
return response.json();
}
export async function getCuratorLogs(): Promise<CuratorLogs> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
return response.json() as Promise<CuratorLogs>;
}
+90
View File
@@ -0,0 +1,90 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
getCuratorConfig,
getCuratorLogs,
getCuratorStatus,
runCurator,
updateCuratorConfig,
} from "./api";
import type { CuratorConfig } from "./types";
// All curator-related queries are per-user data (status / config / logs all
// live under .deer-flow/users/{user_id}/), so the cache key MUST be scoped by
// user_id. Otherwise switching accounts in the same tab would let user B
// briefly read user A's cached values — and clicking "save" on that stale
// frame would silently overwrite user B's real config with user A's values.
function useCuratorKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(
() => ({
status: ["curator", userId, "status"] as const,
config: ["curator", userId, "config"] as const,
logs: ["curator", userId, "logs"] as const,
}),
[userId],
);
}
export function useCuratorStatus() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.status,
queryFn: getCuratorStatus,
});
return { status: query.data, isLoading: query.isLoading, error: query.error };
}
export function useCuratorConfig() {
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.config,
queryFn: getCuratorConfig,
});
return { config: query.data, isLoading: query.isLoading, error: query.error };
}
export function useUpdateCuratorConfig() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: keys.config });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useRunCurator() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: () => runCurator(),
onSuccess: () => {
// Logs start from scratch on a new run — refresh so the UI clears
// immediately and then picks up the new run_started record.
void queryClient.invalidateQueries({ queryKey: keys.logs });
void queryClient.invalidateQueries({ queryKey: keys.status });
},
});
}
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
const autoRefresh = options?.autoRefresh ?? true;
const keys = useCuratorKeys();
const query = useQuery({
queryKey: keys.logs,
queryFn: getCuratorLogs,
refetchInterval: autoRefresh ? 2000 : false,
});
return {
logs: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+38
View File
@@ -0,0 +1,38 @@
export interface CuratorSubConfig {
enabled: boolean;
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
}
export interface CuratorConfig {
enabled: boolean;
moderation_model_name: string | null;
curator: CuratorSubConfig;
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
last_run_at: string | null;
last_run_summary: string | null;
paused: boolean;
agent_created_skills: {
total: number;
active: number;
stale: number;
archived: number;
};
}
export interface CuratorLogRecord {
ts: string;
level: string;
message: string;
}
export interface CuratorLogs {
records: CuratorLogRecord[];
run_count: number;
last_run_at: string | null;
}
+154
View File
@@ -172,6 +172,7 @@ export const enUS: Translations = {
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
users: "Users",
},
// Agents
@@ -434,10 +435,77 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
installSkill: "Install skill",
goToEvolution: "Skill evolution",
emptyTitle: "No agent skill yet",
emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
emptyButton: "Create Your First Skill",
badgeBuiltin: "Built-in",
badgeMine: "Mine",
badgeFromOthers: "From others",
badgeFromOthersTitle: "Owned by user {user_id}",
badgePublished: "Published",
badgePublishedTitle: "Visible to all users",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
badgePinned: "Pinned",
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
badgeSourceAgent: "Agent-created",
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
tabAll: "All",
tabMine: "Mine",
entryStale: "Stale {count}",
entryArchived: "Archived {count}",
useCountInline: "Used {count} times",
useCountInlineZero: "Not used yet",
publishAction: "Publish",
unpublishAction: "Unpublish",
publishSuccess: "Skill published",
unpublishSuccess: "Skill unpublished",
pinAction: "Pin (prevent auto-archive)",
unpinAction: "Unpin",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
deleteAction: "Delete skill",
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
stateSheet: {
archivedTitle: "Archived skills",
archivedDescription:
"These skills have been archived by you or the curator and can be restored at any time.",
staleTitle: "Stale skills",
staleDescription:
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
empty: "Nothing here",
actionRestore: "Restore",
actionArchiveNow: "Archive now",
actionView: "View",
actionPin: "Pin",
actionUnpin: "Unpin",
actionDelete: "Delete permanently",
moreMenu: "More",
archivedAt: "Archived {when}",
lastUsedAt: "Last used {when}",
neverUsed: "Never used",
restoreSuccess: "Restored",
archiveSuccess: "Archived",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
},
viewDialog: {
title: "Skill details",
descriptionLabel: "Description",
stateLabel: "State",
sourceLabel: "Source",
useCountLabel: "Use count",
lastUsedLabel: "Last used",
close: "Close",
},
installDialog: {
title: "Install skill",
dropHint: "Drop a .zip or .skill archive here, or",
@@ -456,9 +524,73 @@ export const enUS: Translations = {
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
targetCategoryLabel: "Install to",
categoryCustom: "Custom skill",
categoryPublic: "Built-in (admin)",
},
},
skillEvolution: {
title: "Skill Evolution",
description:
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
backToSkills: "Back to skills",
status: {
title: "Status",
enabled: "Enabled",
disabled: "Disabled",
paused: "Paused",
runCount: "Total runs",
runCountUnit: "",
lastRunAt: "Last run",
lastRunNever: "Never",
lastRunSummary: "Last summary",
lastRunSummaryEmpty: "—",
skillCounts: "Skills",
active: "Active",
stale: "Stale",
archived: "Archived",
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
},
config: {
title: "Configuration",
description:
"Saves to skill_evolution.json and hot-reloads — no restart required.",
enabledLabel: "Enable skill evolution",
enabledHelper:
"When off, the agent cannot create or modify skills under custom/.",
curatorEnabledLabel: "Curator runs automatically",
curatorEnabledHelper:
"When off, the curator only runs when triggered from this page.",
intervalHoursLabel: "Run interval (hours)",
intervalHoursHelper:
"Minimum hours between automatic curator runs.",
staleAfterDaysLabel: "Stale threshold (days)",
staleAfterDaysHelper:
"Skills inactive for this many days are flagged stale.",
archiveAfterDaysLabel: "Archive threshold (days)",
archiveAfterDaysHelper:
"Stale skills inactive for this many additional days are archived.",
moderationModelLabel: "Moderation model (optional)",
moderationModelHelper:
"Leave blank to use the default chat model.",
save: "Save changes",
saveSuccess: "Configuration updated",
reset: "Reset",
},
logs: {
title: "Recent run logs",
description:
"Latest curator run only; cleared on backend restart.",
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
autoRefresh: "Auto-refresh (2s)",
refresh: "Refresh now",
},
},
notification: {
@@ -479,4 +611,26 @@ export const enUS: Translations = {
emptyDescription: "Credits and acknowledgements will show here.",
},
},
adminUsers: {
title: "User Management",
description: "View all registered users and their associated skill and agent counts.",
email: "Email",
role: "Role",
userId: "User ID",
skillCount: "Skills",
agentCount: "Agents",
roleAdmin: "Admin",
roleUser: "User",
empty: "No users found",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
unreadShort: "{count} unread",
markAllRead: "Mark all read",
empty: "No notifications",
deleteAria: "Delete notification",
},
};
+145
View File
@@ -1,5 +1,14 @@
import type { LucideIcon } from "lucide-react";
export interface NotificationsTranslations {
title: string;
bellAria: string;
unreadShort: string;
markAllRead: string;
empty: string;
deleteAria: string;
}
export interface Translations {
// Locale meta
locale: {
@@ -116,6 +125,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
users: string;
};
// Agents
@@ -355,9 +365,73 @@ export interface Translations {
description: string;
createSkill: string;
installSkill: string;
goToEvolution: string;
emptyTitle: string;
emptyDescription: string;
emptyButton: string;
badgeBuiltin: string;
badgeMine: string;
badgeFromOthers: string;
badgeFromOthersTitle: string;
badgePublished: string;
badgePublishedTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
badgeArchivedTitle: string;
badgePinned: string;
badgePinnedTitle: string;
badgeSourceAgent: string;
badgeSourceAgentTitle: string;
badgeSourceUpload: string;
badgeSourceUploadTitle: string;
tabAll: string;
tabMine: string;
entryStale: string;
entryArchived: string;
useCountInline: string;
useCountInlineZero: string;
publishAction: string;
unpublishAction: string;
publishSuccess: string;
unpublishSuccess: string;
pinAction: string;
unpinAction: string;
pinSuccess: string;
unpinSuccess: string;
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
stateSheet: {
archivedTitle: string;
archivedDescription: string;
staleTitle: string;
staleDescription: string;
empty: string;
actionRestore: string;
actionArchiveNow: string;
actionView: string;
actionPin: string;
actionUnpin: string;
actionDelete: string;
moreMenu: string;
archivedAt: string;
lastUsedAt: string;
neverUsed: string;
restoreSuccess: string;
archiveSuccess: string;
pinSuccess: string;
unpinSuccess: string;
};
viewDialog: {
title: string;
descriptionLabel: string;
stateLabel: string;
sourceLabel: string;
useCountLabel: string;
lastUsedLabel: string;
close: string;
};
installDialog: {
title: string;
dropHint: string;
@@ -374,9 +448,63 @@ export interface Translations {
warningsLabel: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
installSuccessReplaced: string;
rejectInvalidExt: string;
targetCategoryLabel: string;
categoryCustom: string;
categoryPublic: string;
};
};
skillEvolution: {
title: string;
description: string;
backToSkills: string;
status: {
title: string;
enabled: string;
disabled: string;
paused: string;
runCount: string;
runCountUnit: string;
lastRunAt: string;
lastRunNever: string;
lastRunSummary: string;
lastRunSummaryEmpty: string;
skillCounts: string;
active: string;
stale: string;
archived: string;
total: string;
runNow: string;
runStarted: string;
};
config: {
title: string;
description: string;
enabledLabel: string;
enabledHelper: string;
curatorEnabledLabel: string;
curatorEnabledHelper: string;
intervalHoursLabel: string;
intervalHoursHelper: string;
staleAfterDaysLabel: string;
staleAfterDaysHelper: string;
archiveAfterDaysLabel: string;
archiveAfterDaysHelper: string;
moderationModelLabel: string;
moderationModelHelper: string;
save: string;
saveSuccess: string;
reset: string;
};
logs: {
title: string;
description: string;
empty: string;
autoRefresh: string;
refresh: string;
};
};
notification: {
@@ -395,4 +523,21 @@ export interface Translations {
emptyDescription: string;
};
};
// Admin user management page
adminUsers: {
title: string;
description: string;
email: string;
role: string;
userId: string;
skillCount: string;
agentCount: string;
roleAdmin: string;
roleUser: string;
empty: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+147 -4
View File
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
users: "用户管理",
},
// Agents
@@ -415,10 +416,74 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
installSkill: "安装技能",
goToEvolution: "自我进化",
emptyTitle: "还没有技能",
emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
emptyButton: "创建你的第一个技能",
badgeBuiltin: "内置",
badgeMine: "我的",
badgeFromOthers: "来自他人",
badgeFromOthersTitle: "来自用户 {user_id}",
badgePublished: "已发布",
badgePublishedTitle: "该技能已对所有用户可见",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
badgePinned: "钉选",
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
badgeSourceAgent: "Agent 创建",
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
badgeSourceUploadTitle: "由用户手动上传或编辑",
tabAll: "全部",
tabMine: "我的",
entryStale: "过时 {count}",
entryArchived: "归档 {count}",
useCountInline: "调用 {count} 次",
useCountInlineZero: "尚未调用",
publishAction: "发布",
unpublishAction: "取消发布",
publishSuccess: "已发布",
unpublishSuccess: "已取消发布",
pinAction: "钉选(防止被自动归档)",
unpinAction: "取消钉选",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
staleTitle: "过时的技能",
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
empty: "暂无内容",
actionRestore: "恢复",
actionArchiveNow: "立即归档",
actionView: "查看详情",
actionPin: "钉选",
actionUnpin: "取消钉选",
actionDelete: "永久删除",
moreMenu: "更多操作",
archivedAt: "归档于 {when}",
lastUsedAt: "最近使用 {when}",
neverUsed: "尚未使用",
restoreSuccess: "已恢复",
archiveSuccess: "已归档",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
},
viewDialog: {
title: "技能详情",
descriptionLabel: "描述",
stateLabel: "状态",
sourceLabel: "来源",
useCountLabel: "调用次数",
lastUsedLabel: "最近使用",
close: "关闭",
},
installDialog: {
title: "安装技能",
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
@@ -435,10 +500,66 @@ export const zhCN: Translations = {
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
installSuccess: "技能 “{name}” 已安装。",
installSuccessReplaced: "技能 “{name}” 已替换。",
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
targetCategoryLabel: `安装到`,
categoryCustom: `自定义技能`,
categoryPublic: `内置技能(管理员)`,
},
},
skillEvolution: {
title: "自我进化",
description:
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
backToSkills: "返回技能",
status: {
title: "当前状态",
enabled: "已启用",
disabled: "已停用",
paused: "已暂停",
runCount: "累计运行",
runCountUnit: "次",
lastRunAt: "上次运行",
lastRunNever: "尚未运行",
lastRunSummary: "上次摘要",
lastRunSummaryEmpty: "—",
skillCounts: "技能",
active: "活跃",
stale: "陈旧",
archived: "已归档",
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
},
config: {
title: "配置",
description:
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
enabledLabel: "启用自我进化",
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
curatorEnabledLabel: "curator 自动运行",
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
intervalHoursLabel: "运行间隔(小时)",
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
staleAfterDaysLabel: "陈旧阈值(天)",
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
archiveAfterDaysLabel: "归档阈值(天)",
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
moderationModelLabel: "安全审核模型(可选)",
moderationModelHelper: "为空时使用默认主模型。",
save: "保存修改",
saveSuccess: "配置已更新",
reset: "重置",
},
logs: {
title: "最近运行日志",
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
autoRefresh: "自动刷新 (2s)",
refresh: "立即刷新",
},
},
notification: {
@@ -459,4 +580,26 @@ export const zhCN: Translations = {
emptyDescription: "相关的致谢信息会展示在这里。",
},
},
adminUsers: {
title: "用户管理",
description: "查看所有注册用户及其关联的技能和智能体数量。",
email: "邮箱",
role: "角色",
userId: "用户 ID",
skillCount: "技能数",
agentCount: "智能体数",
roleAdmin: "管理员",
roleUser: "普通用户",
empty: "暂无用户",
},
notifications: {
title: "通知",
bellAria: "打开通知",
unreadShort: "{count} 条未读",
markAllRead: "全部已读",
empty: "暂无通知",
deleteAria: "删除通知",
},
};
@@ -0,0 +1,60 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
NotificationItem,
NotificationListResponse,
NotificationUnreadCountResponse,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
export async function listNotifications(
options: { unreadOnly?: boolean; limit?: number } = {},
): Promise<NotificationItem[]> {
const params = new URLSearchParams();
if (options.unreadOnly) params.set("unread_only", "true");
if (options.limit) params.set("limit", String(options.limit));
const query = params.toString();
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
const res = await apiFetch(url);
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
const data = (await res.json()) as NotificationListResponse;
return data.items;
}
export async function getUnreadCount(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
const data = (await res.json()) as NotificationUnreadCountResponse;
return data.unread;
}
export async function markNotificationRead(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
{ method: "POST" },
);
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
}
export async function markAllNotificationsRead(): Promise<number> {
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
method: "POST",
});
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
const data = (await res.json()) as { updated: number };
return data.updated;
}
export async function deleteNotification(id: string): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
}
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { getStoredAuth } from "@/core/auth";
import {
deleteNotification,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "./api";
// Notifications are per-user data, so the cache key MUST be scoped by user_id.
// Otherwise switching accounts in the same tab would show the previous user's
// inbox until the next fetch lands.
function useNotificationKeys() {
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
return useMemo(() => {
const root = ["notifications", userId] as const;
return {
root,
list: (limit: number, unreadOnly: boolean) =>
[...root, "list", { limit, unreadOnly }] as const,
count: [...root, "unread-count"] as const,
};
}, [userId]);
}
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
const { limit = 20, unreadOnly = false } = options;
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.list(limit, unreadOnly),
queryFn: () => listNotifications({ limit, unreadOnly }),
});
}
export function useUnreadCount(refetchInterval = 30000) {
const keys = useNotificationKeys();
return useQuery({
queryKey: keys.count,
queryFn: () => getUnreadCount(),
refetchInterval,
// Keep the badge fresh when the user comes back to the tab without
// waiting for the next interval tick.
refetchOnWindowFocus: true,
});
}
function useInvalidateNotifications() {
const queryClient = useQueryClient();
const keys = useNotificationKeys();
return () => {
void queryClient.invalidateQueries({ queryKey: keys.root });
};
}
export function useMarkNotificationRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: invalidate,
});
}
export function useMarkAllNotificationsRead() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: invalidate,
});
}
export function useDeleteNotification() {
const invalidate = useInvalidateNotifications();
return useMutation({
mutationFn: (id: string) => deleteNotification(id),
onSuccess: invalidate,
});
}
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
@@ -0,0 +1,18 @@
export interface NotificationItem {
id: string;
user_id: string;
type: string;
title: string;
body: string;
payload: Record<string, unknown>;
read: boolean;
created_at: string | null;
}
export interface NotificationListResponse {
items: NotificationItem[];
}
export interface NotificationUnreadCountResponse {
unread: number;
}
+76 -3
View File
@@ -1,7 +1,7 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Skill } from "./type";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean } = {},
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const query = options.overwrite ? "?overwrite=true" : "";
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
@@ -110,3 +113,73 @@ export async function installSkillUpload(
}
return response.json() as Promise<InstallSkillResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
published: boolean;
}
export async function setSkillPublished(
skillName: string,
published: boolean,
): Promise<SetSkillPublishedResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ published }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update published state");
}
return response.json() as Promise<SetSkillPublishedResponse>;
}
export async function setSkillState(
skillName: string,
state: SkillState,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillPinned(
skillName: string,
pinned: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill pinned state");
}
return response.json() as Promise<Skill>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
+60 -3
View File
@@ -1,6 +1,15 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
import {
deleteCustomSkill,
enableSkill,
installSkillUpload,
setSkillPinned,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { SkillState } from "./type";
import { loadSkills } from ".";
@@ -39,8 +48,56 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
installSkillUpload(file, { overwrite }),
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
published,
}: {
skillName: string;
published: boolean;
}) => setSkillPublished(skillName, published),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillState() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
setSkillState(skillName, state),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPinned() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
setSkillPinned(skillName, pinned),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDeleteCustomSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
+26 -7
View File
@@ -1,7 +1,26 @@
export interface Skill {
name: string;
description: string;
category: string;
license: string;
enabled: boolean;
}
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
export interface Skill {
name: string;
description: string;
category: string;
license: string;
enabled: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
published?: boolean;
builtin?: boolean;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
pinned?: boolean;
// Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
patch_count?: number;
}
+31 -38
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import {
ArrowLeftIcon,
@@ -17,13 +17,6 @@ import {
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
@@ -120,7 +113,7 @@ export default function NewAgentPage() {
model_name: chatModelInput || undefined,
},
onToolEnd() {
// setup_agent never actually executes in this flow — it gets
// setup_agent never actually executes in this flow 鈥?it gets
// intercepted by SetupAgentApprovalMiddleware and the form is filled
// from the approval ToolMessage payload (handleApproveSetupAgent).
// No agent record is fetched/refreshed during chat.
@@ -156,7 +149,7 @@ export default function NewAgentPage() {
if (!name) {
// Names are display-only and may collide, so a timestamped placeholder
// is safe to send to the backend just to satisfy the required-field
// validation. Intentionally NOT written back into the visible input —
// validation. Intentionally NOT written back into the visible input 鈥?
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
@@ -245,7 +238,7 @@ export default function NewAgentPage() {
// Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler
// prefills the form locally — it does NOT persist anything.
// prefills the form locally 鈥?it does NOT persist anything.
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
@@ -255,7 +248,7 @@ export default function NewAgentPage() {
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist
// anything — it only mirrors the proposal into the form so the user
// anything 鈥?it only mirrors the proposal into the form so the user
// can review it. Creation/update happens later when the user clicks
// the top-right Save button.
const proposedName = payload.name?.trim() ?? "";
@@ -338,22 +331,23 @@ export default function NewAgentPage() {
<span className="text-muted-foreground text-xs">
当前对话模型
</span>
<Select
<select
value={chatModelInput}
onValueChange={setChatModelInput}
onChange={(event) =>
setChatModelInput(event.currentTarget.value)
}
disabled={isModelsLoading || thread.isLoading}
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none"
>
<SelectTrigger className="h-7 w-auto min-w-[10rem] gap-2 px-2 text-xs">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<option value="" disabled>
选择模型
</option>
{models.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.name}
</option>
))}
</select>
</div>
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
@@ -405,22 +399,21 @@ export default function NewAgentPage() {
<div className="space-y-2">
<label className="text-sm font-medium">模型</label>
<Select
<select
value={modelInput}
onValueChange={setModelInput}
onChange={(event) => setModelInput(event.currentTarget.value)}
disabled={isModelsLoading}
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<option value="" disabled>
选择模型
</option>
{models.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.name}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
该专家被使用时调用的模型。
</p>
@@ -0,0 +1,9 @@
import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel";
export default function SkillEvolutionPage() {
return (
<div className="flex h-full flex-col overflow-auto p-6">
<SkillEvolutionPanel />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
export default function SkillsPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<SkillSettingsPage />
</div>
);
}
+4 -14
View File
@@ -1,14 +1,12 @@
import { Navigate, Route, Routes } from "react-router-dom";
import type { ReactNode } from "react";
import { Toaster } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { SidebarProvider } from "@/components/ui/sidebar";
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
import { ChatRuntime } from "./WorkspaceRoutes";
import AgentsPage from "./AgentsPage";
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
@@ -18,19 +16,10 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
@@ -89,6 +78,7 @@ export default function StrategyRoutes() {
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider>
);
+5
View File
@@ -0,0 +1,5 @@
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
export default function UsersPage() {
return <UserManagementPage />;
}
+28 -1
View File
@@ -22,6 +22,9 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -63,7 +66,7 @@ export function WorkspaceLayout({
);
}
function ChatRuntime({ children }: { children: ReactNode }) {
export function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
@@ -136,6 +139,22 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="skills"
element={
<WorkspaceLayout {...layoutProps}>
<SkillsPage />
</WorkspaceLayout>
}
/>
<Route
path="skills/evolution"
element={
<WorkspaceLayout {...layoutProps}>
<SkillEvolutionPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -156,6 +175,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/users"
element={
<WorkspaceLayout {...layoutProps}>
<UsersPage />
</WorkspaceLayout>
}
/>
<Route
path="settings/recommended-questions"
element={