Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
1574d44cc3
80 files changed
+9366
-1299
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
@@ -0,0 +1,950 @@
|
||||
# Open Canvas 模式需求拆解文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
|
||||
|
||||
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
|
||||
|
||||
- 前端:基于 `React 19 + Vite + react-router-dom`
|
||||
- 后端:基于 `Python + FastAPI + LangGraph`
|
||||
- 模型层:支持云模型与本地模型自由配置
|
||||
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
|
||||
|
||||
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终目标
|
||||
|
||||
### 2.1 用户视角目标
|
||||
|
||||
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
|
||||
|
||||
- 左侧是聊天区,右侧是 Canvas 编辑区
|
||||
- 可以从空白文本或代码模板快速开始
|
||||
- 可以通过聊天让 AI 生成一份结构化 artifact
|
||||
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
|
||||
- artifact 支持版本历史切换
|
||||
- artifact 支持文本和代码两种主要类型
|
||||
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
|
||||
- 支持启用 Web 搜索增强
|
||||
- 支持切换模型
|
||||
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
|
||||
|
||||
### 2.2 工程视角目标
|
||||
|
||||
新增的 Canvas 模式需要满足以下工程原则:
|
||||
|
||||
- 不破坏现有 DeerFlow 工作区
|
||||
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
|
||||
- 可逐阶段上线
|
||||
- 前后端状态协议清晰
|
||||
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 本次需求包含
|
||||
|
||||
- 前端新增 `open-canvas` 专用目录
|
||||
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
|
||||
- 前端新增 Canvas 页面、上下文、组件、状态管理
|
||||
- 后端新增 Canvas 专用线程状态字段
|
||||
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
|
||||
- 模型配置统一为 DeerFlow 后端配置驱动
|
||||
- 支持本地大模型配置与模型列表动态下发
|
||||
- 支持结构化 artifact 的保存、读取、更新、版本切换
|
||||
|
||||
### 3.2 本次需求不包含
|
||||
|
||||
- 一次性复刻 open-canvas 全部 UI 细节
|
||||
- 完整照搬 Supabase 鉴权体系
|
||||
- 直接复用 open-canvas 的 Next.js API Route
|
||||
- 直接运行 open-canvas 的 LangGraph.js 后端
|
||||
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
|
||||
|
||||
### 3.3 迁移原则
|
||||
|
||||
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
|
||||
- 前端尽量复用其交互设计和数据结构
|
||||
- 后端以 Python 实现同等能力
|
||||
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体设计思路
|
||||
|
||||
### 4.1 前端设计思路
|
||||
|
||||
在现有 `frontend-web/src` 下新增一个专用目录,例如:
|
||||
|
||||
```text
|
||||
src/
|
||||
open-canvas/
|
||||
pages/
|
||||
routes/
|
||||
components/
|
||||
contexts/
|
||||
hooks/
|
||||
types/
|
||||
api/
|
||||
utils/
|
||||
constants/
|
||||
```
|
||||
|
||||
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
|
||||
|
||||
建议路由形式:
|
||||
|
||||
- `/canvas`
|
||||
- `/canvas/:threadId`
|
||||
|
||||
也可以挂在现有页面布局下,例如:
|
||||
|
||||
- `/workspace/canvas`
|
||||
- `/workspace/canvas/:threadId`
|
||||
|
||||
推荐优先采用独立一级功能路由:
|
||||
|
||||
- 逻辑更清晰
|
||||
- 状态隔离更容易
|
||||
- 不容易污染现有 `workspace` 功能
|
||||
|
||||
### 4.2 后端设计思路
|
||||
|
||||
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
|
||||
|
||||
#### 方案 A:新增独立 Canvas Assistant
|
||||
|
||||
- 新增 `canvas_agent`
|
||||
- 使用单独的 LangGraph 状态定义
|
||||
- 前端调用时通过 `assistant_id=canvas_agent`
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有 `lead_agent` 隔离
|
||||
- 风险更低
|
||||
- 更容易对齐 open-canvas 的图结构
|
||||
|
||||
缺点:
|
||||
|
||||
- 会产生一套新的 Agent 图维护成本
|
||||
|
||||
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
|
||||
|
||||
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
|
||||
- Agent 根据模式进入 Canvas 路径
|
||||
|
||||
优点:
|
||||
|
||||
- 统一入口
|
||||
|
||||
缺点:
|
||||
|
||||
- 会显著增加现有 Agent 复杂度
|
||||
- 容易影响现有聊天和文件产物模式
|
||||
|
||||
推荐先做方案 A。
|
||||
|
||||
### 4.3 模型配置设计思路
|
||||
|
||||
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
|
||||
|
||||
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
|
||||
|
||||
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `provider`
|
||||
- `supports_thinking`
|
||||
- `supports_reasoning_effort`
|
||||
- `supports_vision`
|
||||
- `supports_tool_calling`
|
||||
- `supports_artifact_editing`
|
||||
- `supports_code_editing`
|
||||
- `temperature_range`
|
||||
- `max_tokens_range`
|
||||
- `base_url_source` 或 provider 标识
|
||||
- `is_local`
|
||||
|
||||
---
|
||||
|
||||
## 5. 与当前项目的关键差异
|
||||
|
||||
### 5.1 当前 DeerFlow 现状
|
||||
|
||||
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
|
||||
|
||||
- `artifacts: string[]`
|
||||
|
||||
这更接近“生成文件产物”的工作模式。
|
||||
|
||||
前端当前 artifact 面板也主要是:
|
||||
|
||||
- 文件列表
|
||||
- 文件详情
|
||||
- 文件下载/预览
|
||||
|
||||
### 5.2 open-canvas 的关键能力
|
||||
|
||||
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
|
||||
|
||||
- 一个 conversation 对应一个主 artifact
|
||||
- artifact 具有类型、标题、内容、版本
|
||||
- 用户可以选中部分内容让 AI 改写
|
||||
- 每次改写都会生成新版本
|
||||
|
||||
### 5.3 需要新增的核心能力
|
||||
|
||||
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
|
||||
|
||||
- 当前 artifact
|
||||
- 当前版本索引
|
||||
- 历史版本列表
|
||||
- 选区信息
|
||||
- 当前编辑模式
|
||||
- 快速动作状态
|
||||
- Web 搜索结果状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施方案
|
||||
|
||||
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段一:前端路由与目录骨架
|
||||
|
||||
### 7.1 目标
|
||||
|
||||
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
|
||||
|
||||
### 7.2 需求
|
||||
|
||||
- 新增 `src/open-canvas/` 目录
|
||||
- 新增 Canvas 页面路由
|
||||
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
|
||||
- 提供 Canvas 首页和 Canvas Thread 页
|
||||
- 支持空状态展示
|
||||
|
||||
### 7.3 建议目录
|
||||
|
||||
```text
|
||||
src/open-canvas/
|
||||
api/
|
||||
threads.ts
|
||||
runs.ts
|
||||
models.ts
|
||||
components/
|
||||
layout/
|
||||
chat/
|
||||
canvas/
|
||||
artifacts/
|
||||
quick-actions/
|
||||
selectors/
|
||||
contexts/
|
||||
CanvasThreadContext.tsx
|
||||
CanvasGraphContext.tsx
|
||||
CanvasUIContext.tsx
|
||||
hooks/
|
||||
useCanvasThread.ts
|
||||
useCanvasStream.ts
|
||||
useCanvasArtifact.ts
|
||||
pages/
|
||||
CanvasHomePage.tsx
|
||||
CanvasThreadPage.tsx
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx
|
||||
types/
|
||||
artifact.ts
|
||||
thread.ts
|
||||
model.ts
|
||||
utils/
|
||||
artifact.ts
|
||||
selection.ts
|
||||
stream.ts
|
||||
constants/
|
||||
index.ts
|
||||
```
|
||||
|
||||
### 7.4 路由建议
|
||||
|
||||
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
|
||||
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
|
||||
|
||||
建议形式:
|
||||
|
||||
```text
|
||||
/canvas
|
||||
/canvas/:threadId
|
||||
```
|
||||
|
||||
### 7.5 验收标准
|
||||
|
||||
- 能访问 Canvas 首页
|
||||
- 能访问指定 thread 的 Canvas 页面
|
||||
- 不影响现有 `workspace/*` 与 `strategy/*`
|
||||
- 页面有独立布局骨架
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段二:前端 Canvas 布局与交互容器
|
||||
|
||||
### 8.1 目标
|
||||
|
||||
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
|
||||
|
||||
### 8.2 需求
|
||||
|
||||
- 左侧聊天区
|
||||
- 右侧画布区
|
||||
- 支持面板拖拽或折叠
|
||||
- 初始空状态
|
||||
- 支持“快速开始”:
|
||||
- 新建空白文本 artifact
|
||||
- 新建代码 artifact
|
||||
|
||||
### 8.3 组件拆分建议
|
||||
|
||||
- `CanvasShell`
|
||||
- `CanvasSidebar`
|
||||
- `CanvasChatPanel`
|
||||
- `CanvasArtifactPanel`
|
||||
- `CanvasEmptyState`
|
||||
- `QuickStartActions`
|
||||
|
||||
### 8.4 快速开始能力
|
||||
|
||||
用户进入空线程时可以选择:
|
||||
|
||||
- 开始写文章
|
||||
- 开始写代码
|
||||
- 从模板开始
|
||||
|
||||
首版模板可只做最小集:
|
||||
|
||||
- 空白文档
|
||||
- HTML 模板
|
||||
- React 组件模板
|
||||
- Python 脚本模板
|
||||
|
||||
### 8.5 验收标准
|
||||
|
||||
- 页面可稳定展示双栏布局
|
||||
- 快速开始能创建前端本地 artifact 状态
|
||||
- 代码和文本可分别进入不同渲染器
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段三:结构化 Artifact 数据模型
|
||||
|
||||
### 9.1 目标
|
||||
|
||||
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
|
||||
|
||||
### 9.2 核心要求
|
||||
|
||||
需要支持以下能力:
|
||||
|
||||
- 文本 artifact
|
||||
- 代码 artifact
|
||||
- 标题
|
||||
- 当前版本索引
|
||||
- 版本历史
|
||||
- 当前选中版本切换
|
||||
- 后续支持局部高亮编辑
|
||||
|
||||
### 9.3 建议数据结构
|
||||
|
||||
```ts
|
||||
type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
|
||||
};
|
||||
|
||||
type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 线程状态建议新增字段
|
||||
|
||||
前端与后端需共同支持:
|
||||
|
||||
- `artifact`
|
||||
- `highlighted_text`
|
||||
- `highlighted_code`
|
||||
- `canvas_mode`
|
||||
- `web_search_enabled`
|
||||
- `web_search_results`
|
||||
- `quick_action_id`
|
||||
|
||||
### 9.5 与现有 `artifacts: string[]` 的关系
|
||||
|
||||
建议保留现有 `artifacts: string[]` 不动。
|
||||
|
||||
新增字段:
|
||||
|
||||
- `artifact` 用于 Canvas 主文档
|
||||
- `artifacts` 继续用于文件产物
|
||||
|
||||
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
|
||||
|
||||
### 9.6 验收标准
|
||||
|
||||
- 前端类型定义完成
|
||||
- 后端状态字段设计完成
|
||||
- 确认与现有线程状态兼容
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段四:后端线程状态与 API 扩展
|
||||
|
||||
### 10.1 目标
|
||||
|
||||
让后端线程状态可以安全存储和返回 Canvas Artifact。
|
||||
|
||||
### 10.2 需求
|
||||
|
||||
- 扩展 Python `ThreadState`
|
||||
- 扩展状态序列化逻辑
|
||||
- 确保 `/api/threads/*` 返回结构化 `artifact`
|
||||
- 确保 LangGraph 流中可带上 `artifact` 更新
|
||||
|
||||
### 10.3 后端改造点
|
||||
|
||||
建议关注以下模块:
|
||||
|
||||
- `packages/harness/deerflow/agents/thread_state.py`
|
||||
- 线程 state serializer
|
||||
- `app/gateway/routers/threads.py`
|
||||
- 流事件适配层
|
||||
|
||||
### 10.4 注意事项
|
||||
|
||||
- 旧线程没有 `artifact` 字段时必须兼容
|
||||
- 状态更新不能破坏现有消息结构
|
||||
- 必须确保 JSON 可序列化
|
||||
|
||||
### 10.5 验收标准
|
||||
|
||||
- 新建线程可写入 `artifact`
|
||||
- 读取线程时可返回 `artifact`
|
||||
- 不影响现有线程读取
|
||||
- 前端能从线程 state 中恢复 Canvas 内容
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段五:Canvas 专用 Agent 图
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
实现与 open-canvas 类似的 AI 路由能力。
|
||||
|
||||
### 11.2 推荐方案
|
||||
|
||||
新增 `canvas_agent`,单独维护图逻辑。
|
||||
|
||||
### 11.3 节点拆分建议
|
||||
|
||||
首版建议至少拆成这些节点:
|
||||
|
||||
- `route_user_intent`
|
||||
- `reply_general_input`
|
||||
- `generate_artifact`
|
||||
- `rewrite_artifact`
|
||||
- `update_highlighted_text`
|
||||
- `update_highlighted_code`
|
||||
- `generate_followup`
|
||||
|
||||
二期可加:
|
||||
|
||||
- `rewrite_artifact_theme`
|
||||
- `rewrite_code_theme`
|
||||
- `change_length`
|
||||
- `change_reading_level`
|
||||
- `translate_artifact`
|
||||
- `add_comments`
|
||||
- `add_logs`
|
||||
- `fix_bugs`
|
||||
- `port_language`
|
||||
- `web_search_enrich`
|
||||
|
||||
### 11.4 路由逻辑要求
|
||||
|
||||
系统需要能判断:
|
||||
|
||||
- 用户是在闲聊
|
||||
- 用户要新生成 artifact
|
||||
- 用户要整体重写当前 artifact
|
||||
- 用户要局部修改当前 artifact
|
||||
- 用户要执行某个快捷动作
|
||||
|
||||
### 11.5 状态输入要求
|
||||
|
||||
节点需要读取:
|
||||
|
||||
- 当前消息历史
|
||||
- 当前 artifact
|
||||
- 当前选区
|
||||
- 当前模型配置
|
||||
- Web 搜索开关
|
||||
|
||||
### 11.6 输出要求
|
||||
|
||||
节点输出必须能更新:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `web_search_results`
|
||||
- `followup`
|
||||
|
||||
### 11.7 验收标准
|
||||
|
||||
- 能根据输入创建新 artifact
|
||||
- 能根据输入整体改写 artifact
|
||||
- 能保留版本历史
|
||||
- 能在改写后返回自然语言 followup
|
||||
|
||||
---
|
||||
|
||||
## 12. 阶段六:前端流式同步与版本管理
|
||||
|
||||
### 12.1 目标
|
||||
|
||||
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
|
||||
|
||||
### 12.2 需求
|
||||
|
||||
- 复用现有 LangGraph stream client
|
||||
- 在 Canvas 模式中解析 `artifact` 更新
|
||||
- 支持流式文本更新
|
||||
- 支持版本切换
|
||||
- 支持断线后恢复线程状态
|
||||
|
||||
### 12.3 前端状态层建议
|
||||
|
||||
- `CanvasThreadContext`:线程基础信息
|
||||
- `CanvasGraphContext`:消息、artifact、streaming 状态
|
||||
- `CanvasUIContext`:面板、编辑态、选中态
|
||||
|
||||
### 12.4 关键能力
|
||||
|
||||
- 当前版本切换
|
||||
- 新版本创建后自动定位到最新版本
|
||||
- 手动编辑内容后保存
|
||||
- 流结束后线程状态持久化
|
||||
|
||||
### 12.5 验收标准
|
||||
|
||||
- 发送消息时聊天和画布可同步更新
|
||||
- 版本数会增加
|
||||
- 切换历史版本稳定
|
||||
- 刷新页面后线程状态能恢复
|
||||
|
||||
---
|
||||
|
||||
## 13. 阶段七:局部选区改写
|
||||
|
||||
### 13.1 目标
|
||||
|
||||
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
|
||||
|
||||
### 13.2 文本选区需求
|
||||
|
||||
- 支持在 Markdown 文本中选择一段文本
|
||||
- 记录选区位置与原始文本
|
||||
- 用户发出修改请求后,仅重写该选区
|
||||
- 将选区结果合并为新版本 artifact
|
||||
|
||||
### 13.3 代码选区需求
|
||||
|
||||
- 支持在代码编辑器中选择一段代码
|
||||
- 记录 start/end index 或行列信息
|
||||
- AI 只返回替换片段
|
||||
- 前端或后端负责将新片段拼接回完整代码
|
||||
|
||||
### 13.4 实现建议
|
||||
|
||||
首版建议:
|
||||
|
||||
- 文本:前端记录纯文本字符位置
|
||||
- 代码:使用 CodeMirror selection 信息
|
||||
|
||||
不建议首版就做复杂块级富文本局部编辑。
|
||||
|
||||
### 13.5 风险
|
||||
|
||||
- Markdown 渲染态与源码态位置映射复杂
|
||||
- 富文本块级位置难维护
|
||||
- 流式更新中选区同步容易错位
|
||||
|
||||
### 13.6 首版折中方案
|
||||
|
||||
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
|
||||
|
||||
### 13.7 验收标准
|
||||
|
||||
- 文本局部改写成功
|
||||
- 代码局部改写成功
|
||||
- 原内容未改动部分保持不变
|
||||
- 新版本正确入历史
|
||||
|
||||
---
|
||||
|
||||
## 14. 阶段八:快捷动作系统
|
||||
|
||||
### 14.1 目标
|
||||
|
||||
实现 open-canvas 风格的快捷动作体系。
|
||||
|
||||
### 14.2 首批推荐动作
|
||||
|
||||
文本类:
|
||||
|
||||
- 扩写
|
||||
- 精简
|
||||
- 改正式语气
|
||||
- 改口语语气
|
||||
- 改阅读难度
|
||||
- 翻译
|
||||
|
||||
代码类:
|
||||
|
||||
- 加注释
|
||||
- 加日志
|
||||
- 修复 bug
|
||||
- 优化代码
|
||||
- 转换语言
|
||||
|
||||
### 14.3 设计方式
|
||||
|
||||
快捷动作不要直接写死在 UI,建议配置化:
|
||||
|
||||
- `id`
|
||||
- `label`
|
||||
- `type`
|
||||
- `applicable_to`
|
||||
- `prompt_template`
|
||||
|
||||
### 14.4 后端设计
|
||||
|
||||
快捷动作可通过:
|
||||
|
||||
- 前端传 `quick_action_id`
|
||||
- 后端路由节点根据动作 id 选择 prompt
|
||||
|
||||
### 14.5 验收标准
|
||||
|
||||
- 文本动作可执行
|
||||
- 代码动作可执行
|
||||
- 结果进入新版本
|
||||
- 不同动作提示词可扩展
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段九:模型配置统一与本地模型支持
|
||||
|
||||
### 15.1 目标
|
||||
|
||||
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
|
||||
|
||||
### 15.2 原则
|
||||
|
||||
- 后端 `config.yaml` 为真源
|
||||
- 前端只展示后端暴露的模型信息
|
||||
- 不维护一份脱离后端的前端常量模型表
|
||||
|
||||
### 15.3 本地模型支持范围
|
||||
|
||||
建议优先支持两类:
|
||||
|
||||
- `Ollama`
|
||||
- OpenAI-compatible 本地服务
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- LocalAI
|
||||
- one-api 风格代理
|
||||
|
||||
### 15.4 后端配置建议
|
||||
|
||||
后端模型配置应至少支持:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `use`
|
||||
- `model`
|
||||
- `api_key`
|
||||
- `base_url`
|
||||
- `supports_vision`
|
||||
- `supports_thinking`
|
||||
- `supports_tool_calling`
|
||||
- `is_local`
|
||||
- `canvas_capabilities`
|
||||
|
||||
### 15.5 Canvas 模式额外要求
|
||||
|
||||
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
|
||||
|
||||
- `supports_canvas_generation`
|
||||
- `supports_canvas_rewrite`
|
||||
- `supports_canvas_code_edit`
|
||||
- `supports_streaming_text`
|
||||
- `supports_streaming_tool_calls`
|
||||
|
||||
### 15.6 与 open-canvas 的关系
|
||||
|
||||
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
|
||||
|
||||
### 15.7 验收标准
|
||||
|
||||
- 前端模型选择器可显示后端返回模型
|
||||
- 可切换本地模型
|
||||
- Canvas 模式能用本地模型生成和改写
|
||||
|
||||
---
|
||||
|
||||
## 16. 阶段十:Web 搜索增强
|
||||
|
||||
### 16.1 目标
|
||||
|
||||
实现类似 open-canvas 的可选 Web 搜索增强。
|
||||
|
||||
### 16.2 需求
|
||||
|
||||
- UI 提供 Web 搜索开关
|
||||
- 后端在需要时走搜索节点
|
||||
- 搜索结果写入线程状态
|
||||
- 结果可在画布旁侧栏展示
|
||||
|
||||
### 16.3 首版建议
|
||||
|
||||
只做:
|
||||
|
||||
- 开关
|
||||
- 搜索结果列表
|
||||
- 搜索结果用于生成 artifact
|
||||
|
||||
暂不做复杂网页抓取编辑链路。
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
- 打开搜索后可返回搜索结果
|
||||
- 搜索结果可参与 artifact 生成
|
||||
- 结果展示不阻塞主画布
|
||||
|
||||
---
|
||||
|
||||
## 17. 阶段十一:持久化、导出与文件产物联动
|
||||
|
||||
### 17.1 目标
|
||||
|
||||
让 Canvas 文档与 DeerFlow 原有文件能力协同。
|
||||
|
||||
### 17.2 需求
|
||||
|
||||
- artifact 持久化在线程 state 中
|
||||
- 可手动导出为文件
|
||||
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
|
||||
- Canvas artifact 与 `artifacts: string[]` 可双向关联
|
||||
|
||||
### 17.3 推荐能力
|
||||
|
||||
- 导出 Markdown
|
||||
- 导出代码文件
|
||||
- 另存为 artifact file
|
||||
- 从 artifact file 回填到 Canvas
|
||||
|
||||
### 17.4 验收标准
|
||||
|
||||
- 当前版本可导出
|
||||
- 导出文件可在现有文件面板查看
|
||||
- 文件型 artifact 和 Canvas artifact 能共存
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐开发顺序
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
|
||||
1. 路由和目录骨架
|
||||
2. Canvas 页面布局
|
||||
3. 前端 artifact 数据模型
|
||||
4. 后端线程状态扩展
|
||||
5. 新建 Canvas Agent 图
|
||||
6. 前后端流式同步
|
||||
7. 整体生成与整体改写
|
||||
8. 局部选区改写
|
||||
9. 快捷动作
|
||||
10. 本地模型支持完善
|
||||
11. Web 搜索增强
|
||||
12. 导出与文件联动
|
||||
|
||||
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
|
||||
|
||||
---
|
||||
|
||||
## 19. 里程碑定义
|
||||
|
||||
### M1:页面可进入
|
||||
|
||||
- 路由接通
|
||||
- 双栏布局完成
|
||||
- 本地 mock artifact 可渲染
|
||||
|
||||
### M2:后端状态打通
|
||||
|
||||
- 线程可保存结构化 artifact
|
||||
- 页面刷新后能恢复
|
||||
|
||||
### M3:AI 生成可用
|
||||
|
||||
- 可新建文本/代码 artifact
|
||||
- 可整体改写
|
||||
- 可产生版本历史
|
||||
|
||||
### M4:核心体验可用
|
||||
|
||||
- 选区改写可用
|
||||
- 快捷动作可用
|
||||
- 模型切换可用
|
||||
|
||||
### M5:增强能力完整
|
||||
|
||||
- 本地模型支持完善
|
||||
- Web 搜索增强
|
||||
- 导出和文件联动
|
||||
|
||||
---
|
||||
|
||||
## 20. 风险与难点
|
||||
|
||||
### 20.1 最大难点
|
||||
|
||||
- 结构化 artifact 状态从无到有
|
||||
- 局部选区改写的精确性
|
||||
- 流式状态与版本管理的一致性
|
||||
- 不破坏现有 DeerFlow 文件型 artifact 体系
|
||||
|
||||
### 20.2 技术风险
|
||||
|
||||
- 线程 state JSON 结构过大
|
||||
- 大模型对局部改写指令遵循不稳定
|
||||
- 本地模型 tool calling 与流式能力差异大
|
||||
- 文本渲染态与源码态位置映射复杂
|
||||
|
||||
### 20.3 规避建议
|
||||
|
||||
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
|
||||
- 首版只支持文本和代码两种 artifact 类型
|
||||
- 首版先做整体生成和整体改写,再做局部改写
|
||||
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
|
||||
|
||||
---
|
||||
|
||||
## 21. 验收总清单
|
||||
|
||||
### 21.1 前端
|
||||
|
||||
- 有独立 Canvas 路由
|
||||
- 有专用目录和模块边界
|
||||
- 双栏布局可用
|
||||
- 文本和代码两类 artifact 可渲染
|
||||
- 历史版本可切换
|
||||
- 选区改写可交互
|
||||
- 快捷动作可触发
|
||||
- 模型可切换
|
||||
|
||||
### 21.2 后端
|
||||
|
||||
- 线程状态支持 `artifact`
|
||||
- 新增 `canvas_agent` 或等价模式
|
||||
- 生成、重写、局部改写可用
|
||||
- 流式返回稳定
|
||||
- 模型列表接口支持 Canvas 能力元数据
|
||||
|
||||
### 21.3 本地模型
|
||||
|
||||
- 可配置 Ollama
|
||||
- 可配置 OpenAI-compatible 本地服务
|
||||
- 前端可见并可切换
|
||||
- 至少能完成文本生成和整体改写
|
||||
|
||||
---
|
||||
|
||||
## 22. 推荐首版最小可交付范围
|
||||
|
||||
如果要控制风险,建议首版只交付以下内容:
|
||||
|
||||
- 新增 `canvas/*` 路由
|
||||
- 双栏布局
|
||||
- 结构化 `artifact`
|
||||
- `canvas_agent`
|
||||
- 文本/代码 artifact 新建
|
||||
- 整体生成
|
||||
- 整体改写
|
||||
- 历史版本切换
|
||||
- 后端模型动态加载
|
||||
- Ollama 或本地 OpenAI-compatible 至少一种可用
|
||||
|
||||
先不要把以下内容放进首版强制目标:
|
||||
|
||||
- 富文本块级精确编辑
|
||||
- 全量快捷动作
|
||||
- 复杂 Web 搜索增强
|
||||
- 多 artifact 并行管理
|
||||
|
||||
---
|
||||
|
||||
## 23. 后续实施建议
|
||||
|
||||
建议下一步基于本文档继续输出两份落地文档:
|
||||
|
||||
### 23.1 前端实施清单
|
||||
|
||||
逐文件列出:
|
||||
|
||||
- 要新建哪些文件
|
||||
- 哪些组件可复用
|
||||
- 哪些页面要接入路由
|
||||
- 哪些状态要从现有 hooks 抽离
|
||||
|
||||
### 23.2 后端实施清单
|
||||
|
||||
逐模块列出:
|
||||
|
||||
- 要改哪些 Python 文件
|
||||
- 新增哪些状态字段
|
||||
- 新增哪些 Agent 节点
|
||||
- 哪些 API 需要扩展
|
||||
|
||||
---
|
||||
|
||||
## 24. 结论
|
||||
|
||||
这个需求很大,但完全可拆。
|
||||
|
||||
正确做法不是“先抄 UI”,而是按下面的主线推进:
|
||||
|
||||
- 先建前端 Canvas 子系统
|
||||
- 再建结构化 artifact 状态
|
||||
- 再建 Canvas Agent 图
|
||||
- 再补局部改写和快捷动作
|
||||
- 最后统一本地模型能力
|
||||
|
||||
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
|
||||
@@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -37,27 +37,27 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,28 +11,37 @@ function Collapsible({
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
className={cn("cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const CollapsibleTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
ref={ref}
|
||||
data-slot="collapsible-trigger"
|
||||
className={cn("cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsibleTrigger.displayName =
|
||||
CollapsiblePrimitive.CollapsibleTrigger.displayName;
|
||||
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
ref={ref}
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsibleContent.displayName =
|
||||
CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -12,11 +12,20 @@ function Dialog({
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
@@ -24,61 +33,68 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
@@ -198,45 +205,51 @@ function DropdownMenuSub({
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -11,34 +11,42 @@ function HoverCard({
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const HoverCardTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="hover-card-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -24,68 +24,69 @@ function SelectValue({
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
@@ -100,32 +101,34 @@ function SelectLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
|
||||
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
const SheetTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
|
||||
});
|
||||
|
||||
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
const SheetClose = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
|
||||
});
|
||||
|
||||
SheetClose.displayName = SheetPrimitive.Close.displayName;
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
@@ -28,58 +34,62 @@ function SheetPortal({
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
>(({ className, children, side = "right", ...props }, ref) => {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
});
|
||||
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
@@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
@@ -670,37 +686,37 @@ function SidebarMenuSubItem({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
|
||||
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -28,33 +28,43 @@ function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const TooltipTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset, children, ...props }, ref) => {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -16,7 +16,7 @@ export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -84,8 +84,8 @@ export function AgentGallery() {
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="mx-6 mt-4 w-auto self-start">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="mine">自己的</TabsTrigger>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
|
||||
@@ -117,6 +117,7 @@ export function InputBox({
|
||||
onFollowupsVisibilityChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -145,6 +146,16 @@ export function InputBox({
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
* Optional toggle rendered next to the model selector. Used for the
|
||||
* admin-configurable prompt-prefix switch on the chat input. Omitting the
|
||||
* prop hides the button entirely.
|
||||
*/
|
||||
promptPrefix?: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -819,6 +830,34 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className="gap-1.5">
|
||||
{promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
promptPrefix.on
|
||||
? `已开启:${promptPrefix.label}`
|
||||
: `点击开启:${promptPrefix.label}`
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
onClick={promptPrefix.onToggle}
|
||||
aria-pressed={promptPrefix.on}
|
||||
className={cn(
|
||||
"rounded-full border px-3 shadow-none transition-colors",
|
||||
promptPrefix.on
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-[160px] truncate text-xs font-normal">
|
||||
{promptPrefix.label}
|
||||
</span>
|
||||
{promptPrefix.on && (
|
||||
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
|
||||
)}
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ModelSelector
|
||||
open={modelDialogOpen}
|
||||
onOpenChange={setModelDialogOpen}
|
||||
|
||||
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import { MessageTokenUsageList } from "./message-token-usage";
|
||||
import {
|
||||
MessageMetricsProvider,
|
||||
MessageTokenUsageList,
|
||||
} from "./message-token-usage";
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { SubtaskCard } from "./subtask-card";
|
||||
|
||||
@@ -69,6 +72,7 @@ export function MessageList({
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
@@ -265,6 +269,7 @@ export function MessageList({
|
||||
})}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,98 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { CoinsIcon } from "lucide-react";
|
||||
import { CoinsIcon, GaugeIcon } from "lucide-react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
MessageMetricsContext,
|
||||
useMessageMetric,
|
||||
useMessageMetrics,
|
||||
type MessageMetric,
|
||||
} from "./use-message-metrics";
|
||||
|
||||
const FULL_SCALE_TOKENS_PER_SEC = 80;
|
||||
|
||||
function classifySpeed(tps: number): { color: string; label: string } {
|
||||
if (tps >= 50) return { color: "#16a34a", label: "极快" };
|
||||
if (tps >= 30) return { color: "#65a30d", label: "流畅" };
|
||||
if (tps >= 15) return { color: "#eab308", label: "一般" };
|
||||
if (tps >= 5) return { color: "#f97316", label: "偏慢" };
|
||||
return { color: "#ef4444", label: "缓慢" };
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
const secs = Math.round((ms % 60_000) / 1000);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
const display =
|
||||
metric.tokensPerSec >= 100
|
||||
? Math.round(metric.tokensPerSec).toString()
|
||||
: metric.tokensPerSec.toFixed(1);
|
||||
const prefix = metric.fromUsage ? "" : "~";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
|
||||
>
|
||||
<GaugeIcon className="size-3" style={{ color }} />
|
||||
<span className="font-mono tabular-nums" style={{ color }}>
|
||||
{prefix}
|
||||
{display}
|
||||
</span>
|
||||
<span className="text-muted-foreground">tok/s</span>
|
||||
<span className="relative inline-block h-1 w-12 overflow-hidden rounded-full bg-current/15 align-middle">
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-200 ease-out"
|
||||
style={{ width: `${fill * 100}%`, backgroundColor: color }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageTokenUsage({
|
||||
className,
|
||||
enabled = false,
|
||||
isLoading = false,
|
||||
message,
|
||||
metric,
|
||||
}: {
|
||||
className?: string;
|
||||
enabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
message: Message;
|
||||
metric?: MessageMetric;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [settings] = useLocalSettings();
|
||||
const speedEnabled = settings.display.streamSpeedMeterEnabled;
|
||||
const contextMetric = useMessageMetric(message.id as string | undefined);
|
||||
const resolvedMetric = metric ?? contextMetric;
|
||||
|
||||
if (!enabled || isLoading || message.type !== "ai") {
|
||||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show speed-only line while streaming (no usage yet) if the user opted in,
|
||||
// otherwise wait until usage arrives so the bar doesn't render on its own.
|
||||
const usage = getUsageMetadata(message);
|
||||
const showUsage = enabled && !isLoading && Boolean(usage);
|
||||
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
|
||||
|
||||
if (!showUsage && !showSpeed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<CoinsIcon className="size-3" />
|
||||
{t.tokenUsage.label}
|
||||
</span>
|
||||
{usage ? (
|
||||
{showUsage && usage ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<CoinsIcon className="size-3" />
|
||||
{t.tokenUsage.label}
|
||||
</span>
|
||||
<span>
|
||||
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
|
||||
</span>
|
||||
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
|
||||
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t.tokenUsage.unavailableShort}</span>
|
||||
)}
|
||||
) : null}
|
||||
{showSpeed && resolvedMetric ? (
|
||||
<>
|
||||
<SpeedPill metric={resolvedMetric} />
|
||||
{resolvedMetric.durationMs > 0 && (
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatDuration(resolvedMetric.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
|
||||
isLoading?: boolean;
|
||||
messages: Message[];
|
||||
}) {
|
||||
if (!enabled || isLoading) {
|
||||
const [settings] = useLocalSettings();
|
||||
const speedEnabled = settings.display.streamSpeedMeterEnabled;
|
||||
|
||||
if (!enabled && !speedEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aiMessages = messages.filter((message) => message.type === "ai");
|
||||
|
||||
if (aiMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -89,3 +169,25 @@ export function MessageTokenUsageList({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoist the metrics map computation to the top of the message list so
|
||||
* descendant {@link MessageTokenUsage} components can read it via context
|
||||
* instead of each running its own hook.
|
||||
*/
|
||||
export function MessageMetricsProvider({
|
||||
messages,
|
||||
isLoading,
|
||||
children,
|
||||
}: {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const metrics = useMessageMetrics(messages, isLoading);
|
||||
return (
|
||||
<MessageMetricsContext.Provider value={metrics}>
|
||||
{children}
|
||||
</MessageMetricsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
||||
import { getUsageMetadata } from "@/core/messages/usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
|
||||
/**
|
||||
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
||||
* speed indicator for mixed Chinese/English replies without dragging in a
|
||||
* real tokeniser.
|
||||
*/
|
||||
const CHARS_PER_TOKEN = 2;
|
||||
|
||||
/** Sample throttle while a message is streaming. */
|
||||
const SAMPLE_THROTTLE_MS = 60;
|
||||
|
||||
/**
|
||||
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||
*/
|
||||
export interface MessageMetric {
|
||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||
tokensPerSec: number;
|
||||
/**
|
||||
* Wall-clock duration from when the message first appeared with text to
|
||||
* either *now* (active) or to when the stream ended (frozen).
|
||||
*/
|
||||
durationMs: number;
|
||||
/** True while this is the currently streaming message. */
|
||||
isActive: boolean;
|
||||
/**
|
||||
* Whether the speed value was computed from real usage_metadata output
|
||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||
* only to add a "~" prefix when the value is approximate.
|
||||
*/
|
||||
fromUsage: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
|
||||
type TimingEntry = {
|
||||
firstSeenAt: number;
|
||||
endedAt: number | null;
|
||||
samples: Sample[];
|
||||
lastChars: number;
|
||||
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||
hadUsage: boolean;
|
||||
};
|
||||
|
||||
function isAiMessage(message: Message): boolean {
|
||||
return message.type === "ai";
|
||||
}
|
||||
|
||||
function getMessageId(message: Message, fallbackIndex: number): string {
|
||||
const id = (message.id as string | undefined) ?? undefined;
|
||||
return id ?? `idx-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
function messageText(message: Message): string {
|
||||
// Count both the visible answer text *and* the reasoning trace so the speed
|
||||
// indicator stays meaningful during the thinking phase — for reasoning
|
||||
// models the answer body can be empty for tens of seconds while the model
|
||||
// streams reasoning tokens, and ignoring those would make the meter sit at
|
||||
// zero. We treat the two as one continuous stream of generated characters.
|
||||
const body = textOfMessage(message);
|
||||
const reasoning = extractReasoningContentFromMessage(message) ?? "";
|
||||
const bodyText = typeof body === "string" ? body : "";
|
||||
return bodyText + reasoning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map keyed by AI message id → live metric snapshot.
|
||||
*
|
||||
* Recording strategy:
|
||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||
* - On every text-length change we push a sample and recompute the rate.
|
||||
* - When `isLoading` flips to false we mark *all* still-active messages as
|
||||
* ended so subsequent renders return a frozen duration.
|
||||
* - Token totals come from usage_metadata when present (most accurate);
|
||||
* otherwise we fall back to char-count / 2.
|
||||
*/
|
||||
export function useMessageMetrics(
|
||||
messages: Message[],
|
||||
isLoading: boolean,
|
||||
): Map<string, MessageMetric> {
|
||||
const timingRef = useRef<Map<string, TimingEntry>>(new Map());
|
||||
const lastSampleAtRef = useRef(0);
|
||||
// Bump a counter to force recomputation when samples push but message refs
|
||||
// didn't change — needed because the timing map mutation alone wouldn't
|
||||
// re-render anything.
|
||||
const [, setTick] = useState(0);
|
||||
const lastActiveIdRef = useRef<string | null>(null);
|
||||
|
||||
// Walk messages and update timings. We intentionally read/write the ref
|
||||
// synchronously inside an effect so React isn't tracking the map identity.
|
||||
useEffect(() => {
|
||||
const now = performance.now();
|
||||
let activeId: string | null = null;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message || !isAiMessage(message)) continue;
|
||||
const id = getMessageId(message, i);
|
||||
const text = messageText(message);
|
||||
if (!text) continue;
|
||||
|
||||
let entry = timingRef.current.get(id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
firstSeenAt: now,
|
||||
endedAt: null,
|
||||
samples: [{ at: now, chars: text.length }],
|
||||
lastChars: text.length,
|
||||
hadUsage: false,
|
||||
};
|
||||
timingRef.current.set(id, entry);
|
||||
}
|
||||
|
||||
// Track the most recent AI message as the active one (while streaming).
|
||||
activeId = id;
|
||||
|
||||
// Sample only when actively streaming OR when content actually grew —
|
||||
// throttle to keep arrays bounded.
|
||||
const grew = text.length !== entry.lastChars;
|
||||
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
entry.samples.push({ at: now, chars: text.length });
|
||||
entry.lastChars = text.length;
|
||||
if (entry.samples.length > 256) {
|
||||
entry.samples.splice(0, entry.samples.length - 256);
|
||||
}
|
||||
lastSampleAtRef.current = now;
|
||||
} else if (grew) {
|
||||
// Still update lastChars so the next render captures it eventually.
|
||||
entry.lastChars = text.length;
|
||||
}
|
||||
|
||||
if (getUsageMetadata(message)) {
|
||||
entry.hadUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// When streaming stops, freeze any non-ended entry to the current time.
|
||||
if (!isLoading) {
|
||||
for (const entry of timingRef.current.values()) {
|
||||
if (entry.endedAt === null) {
|
||||
entry.endedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastActiveIdRef.current = activeId;
|
||||
setTick((v) => v + 1);
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Drive a slow tick while streaming so live durations refresh smoothly.
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
// Build the snapshot map. We compute on every render so consumers see the
|
||||
// freshest duration; the underlying timing storage is the source of truth.
|
||||
const out = new Map<string, MessageMetric>();
|
||||
const now = performance.now();
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message || !isAiMessage(message)) continue;
|
||||
const id = getMessageId(message, i);
|
||||
const entry = timingRef.current.get(id);
|
||||
if (!entry) continue;
|
||||
|
||||
const endedAt = entry.endedAt ?? now;
|
||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||
const isActive = isLoading && id === lastActiveIdRef.current;
|
||||
|
||||
let tokensPerSec = 0;
|
||||
let fromUsage = false;
|
||||
const usage = getUsageMetadata(message);
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||
fromUsage = true;
|
||||
} else {
|
||||
// Char-count fallback. Uses the rolling window during streaming so a
|
||||
// mid-stream snapshot reflects current speed rather than average since
|
||||
// the start.
|
||||
if (isActive && entry.samples.length >= 2) {
|
||||
const samples = entry.samples;
|
||||
let start = samples.findIndex((s) => s.at >= now - 1500);
|
||||
if (start === -1) start = samples.length - 2;
|
||||
if (start < 0) start = 0;
|
||||
const head = samples[start];
|
||||
const tail = samples[samples.length - 1];
|
||||
if (head && tail) {
|
||||
const elapsedMs = tail.at - head.at;
|
||||
const chars = Math.max(0, tail.chars - head.chars);
|
||||
if (elapsedMs > 0) {
|
||||
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
|
||||
}
|
||||
}
|
||||
} else if (durationMs > 0) {
|
||||
// Finished message without usage data: average over full duration.
|
||||
const chars = entry.lastChars;
|
||||
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
out.set(id, {
|
||||
tokensPerSec,
|
||||
durationMs,
|
||||
isActive,
|
||||
fromUsage,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const EMPTY_METRICS: Map<string, MessageMetric> = new Map();
|
||||
|
||||
/**
|
||||
* Context for sharing the metrics map computed by ``useMessageMetrics`` from
|
||||
* MessageList down to per-message renderers like MessageListItem, without
|
||||
* threading a prop through every grouping branch.
|
||||
*/
|
||||
export const MessageMetricsContext = createContext<Map<string, MessageMetric>>(
|
||||
EMPTY_METRICS,
|
||||
);
|
||||
|
||||
export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined {
|
||||
const metrics = useContext(MessageMetricsContext);
|
||||
return messageId ? metrics.get(messageId) : undefined;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CheckCheckIcon,
|
||||
ChevronLeftIcon,
|
||||
Loader2,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import {
|
||||
useDeleteNotification,
|
||||
useMarkAllNotificationsRead,
|
||||
useMarkNotificationRead,
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
} from "@/core/notifications";
|
||||
import type { NotificationItem } from "@/core/notifications";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BELL_LIMIT = 20;
|
||||
|
||||
type NotificationView = "list" | "settings";
|
||||
|
||||
export function NotificationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [view, setView] = useState<NotificationView>("list");
|
||||
|
||||
const {
|
||||
data: notifications,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useNotifications({ limit: BELL_LIMIT });
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
const removeNotification = useDeleteNotification();
|
||||
|
||||
const hasUnread = unreadCount > 0;
|
||||
const items = notifications ?? [];
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
onOpenChange(next);
|
||||
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
|
||||
if (!next) setView("list");
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{view === "list" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="size-4" />
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t.notifications.title}
|
||||
</DialogTitle>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="text-muted-foreground size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.notifications.settingsAria}
|
||||
onClick={() => setView("settings")}
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
|
||||
<BellIcon className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.notifications.empty}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
|
||||
!notification.read
|
||||
? "border-blue-500/30 bg-blue-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
onClick={() => void handleClickItem(notification)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
notification.read ? "bg-transparent" : "bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
notification.read
|
||||
? "font-medium"
|
||||
: "font-semibold",
|
||||
)}
|
||||
title={notification.title}
|
||||
>
|
||||
{notification.title || notification.type}
|
||||
</p>
|
||||
{notification.created_at && (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(notification);
|
||||
}}
|
||||
aria-label={t.notifications.deleteAria}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<NotificationSettingsView onBack={() => setView("list")} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { permission, isSupported, requestPermission, showNotification } =
|
||||
useNotification();
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
await requestPermission();
|
||||
};
|
||||
|
||||
const handleTestNotification = () => {
|
||||
showNotification(t.settings.notification.testTitle, {
|
||||
body: t.settings.notification.testBody,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnableNotification = (enabled: boolean) => {
|
||||
setSettings("notification", { enabled });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.notifications.backAria}
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t.notifications.settingsTitle}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
|
||||
{!isSupported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.notSupported}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.description}
|
||||
</p>
|
||||
<Switch
|
||||
disabled={permission !== "granted"}
|
||||
checked={
|
||||
permission === "granted" && settings.notification.enabled
|
||||
}
|
||||
onCheckedChange={handleEnableNotification}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{permission === "default" && (
|
||||
<Button onClick={handleRequestPermission} variant="default">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.requestPermission}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permission === "denied" && (
|
||||
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
|
||||
{t.settings.notification.deniedHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === "granted" && settings.notification.enabled && (
|
||||
<Button onClick={handleTestNotification} variant="outline">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.testButton}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
function roleLabel(role?: string) {
|
||||
if (role === "admin") return "管理员";
|
||||
if (role === "user") return "普通用户";
|
||||
return role || "未知角色";
|
||||
}
|
||||
|
||||
function copy(text: string, hint = "已复制") {
|
||||
if (!text) return;
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => toast.success(hint))
|
||||
.catch(() => toast.error("复制失败"));
|
||||
}
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const auth = useMemo(() => getStoredAuth(), []);
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<SettingsSection title="个人信息" description="当前未登录或登录信息无法读取。">
|
||||
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
|
||||
没有可显示的账号信息。
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
|
||||
const displayName = usernameFromEmail || auth.user_id;
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStoredAuth();
|
||||
// Match the rest of the app's logout flow: blow away the cookie session
|
||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||
// so any cached query state goes too.
|
||||
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.location.assign("/");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full">
|
||||
<UserIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{roleLabel(auth.system_role)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={auth.system_role === "admin" ? "default" : "secondary"}>
|
||||
{roleLabel(auth.system_role)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<InfoRow
|
||||
label="用户名"
|
||||
value={displayName}
|
||||
onCopy={() => copy(displayName, "用户名已复制")}
|
||||
/>
|
||||
<InfoRow
|
||||
label="用户 ID"
|
||||
value={auth.user_id}
|
||||
onCopy={() => copy(auth.user_id, "用户 ID 已复制")}
|
||||
/>
|
||||
<InfoRow label="账号角色" value={roleLabel(auth.system_role)} />
|
||||
<InfoRow
|
||||
label="账号状态"
|
||||
value={auth.needs_setup ? "待初始化" : "已就绪"}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
||||
<LogOutIcon className="size-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCopy?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-t pt-3 first:border-t-0 first:pt-0">
|
||||
<div className="text-muted-foreground w-20 shrink-0 text-xs">{label}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm">{value || "—"}</div>
|
||||
{onCopy && value && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={onCopy}
|
||||
aria-label={`复制${label}`}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
title="对话体验"
|
||||
description="问答过程中的辅助显示。"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">回答速度指示器</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
在生成回答时显示一个小胶囊,实时估算当前 token/s
|
||||
并用绿到红的渐变条直观反映快慢。默认关闭。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.streamSpeedMeterEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { streamSpeedMeterEnabled: checked })
|
||||
}
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useUpsertMCPServer } from "@/core/mcp/hooks";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
MCPServerTransport,
|
||||
} from "@/core/mcp/types";
|
||||
|
||||
type EditMode = "create" | "edit";
|
||||
|
||||
export interface MCPServerEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: EditMode;
|
||||
originalName?: string;
|
||||
initialServer?: MCPServerConfig;
|
||||
existingNames: string[];
|
||||
}
|
||||
|
||||
const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [
|
||||
{ value: "stdio", label: "stdio(本地进程)" },
|
||||
{ value: "http", label: "http(远端 HTTP)" },
|
||||
{ value: "sse", label: "sse(远端 SSE)" },
|
||||
];
|
||||
|
||||
const DEFAULT_SERVER: MCPServerConfig = {
|
||||
enabled: true,
|
||||
type: "stdio",
|
||||
command: "",
|
||||
args: [],
|
||||
env: {},
|
||||
url: "",
|
||||
headers: {},
|
||||
description: "",
|
||||
disabled_tools: [],
|
||||
};
|
||||
|
||||
function argsToText(args?: string[]): string {
|
||||
return (args ?? []).join("\n");
|
||||
}
|
||||
|
||||
function textToArgs(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function kvToText(map?: Record<string, string>): string {
|
||||
if (!map) return "";
|
||||
return Object.entries(map)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function textToKV(text: string): { value: Record<string, string>; error?: string } {
|
||||
const result: Record<string, string> = {};
|
||||
const lines = text.split("\n");
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) {
|
||||
return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` };
|
||||
}
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1);
|
||||
if (!key) {
|
||||
return { value: {}, error: `键名不能为空:${line}` };
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
return { value: result };
|
||||
}
|
||||
|
||||
export function MCPServerEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
originalName,
|
||||
initialServer,
|
||||
existingNames,
|
||||
}: MCPServerEditDialogProps) {
|
||||
const upsert = useUpsertMCPServer();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<MCPServerTransport>("stdio");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [description, setDescription] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [argsText, setArgsText] = useState("");
|
||||
const [envText, setEnvText] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [headersText, setHeadersText] = useState("");
|
||||
const [disabledToolsText, setDisabledToolsText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const base = initialServer ?? DEFAULT_SERVER;
|
||||
setName(originalName ?? "");
|
||||
setType((base.type ?? "stdio") as MCPServerTransport);
|
||||
setEnabled(base.enabled);
|
||||
setDescription(base.description ?? "");
|
||||
setCommand(base.command ?? "");
|
||||
setArgsText(argsToText(base.args));
|
||||
setEnvText(kvToText(base.env));
|
||||
setUrl(base.url ?? "");
|
||||
setHeadersText(kvToText(base.headers));
|
||||
setDisabledToolsText((base.disabled_tools ?? []).join("\n"));
|
||||
}, [open, initialServer, originalName]);
|
||||
|
||||
const nameConflict = useMemo(() => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return false;
|
||||
if (mode === "edit" && trimmed === originalName) return false;
|
||||
return existingNames.includes(trimmed);
|
||||
}, [name, existingNames, mode, originalName]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("请填写 MCP 服务器名称");
|
||||
return;
|
||||
}
|
||||
if (nameConflict) {
|
||||
toast.error(`已存在同名 MCP 服务器:${trimmedName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const env = textToKV(envText);
|
||||
if (env.error) {
|
||||
toast.error(`环境变量格式错误:${env.error}`);
|
||||
return;
|
||||
}
|
||||
const headers = textToKV(headersText);
|
||||
if (headers.error) {
|
||||
toast.error(`Headers 格式错误:${headers.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = textToArgs(argsText);
|
||||
const disabledTools = textToArgs(disabledToolsText);
|
||||
|
||||
if (type === "stdio" && !command.trim()) {
|
||||
toast.error("stdio 传输需要填写启动命令");
|
||||
return;
|
||||
}
|
||||
if ((type === "http" || type === "sse") && !url.trim()) {
|
||||
toast.error(`${type} 传输需要填写服务器 URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
const server: MCPServerConfig = {
|
||||
enabled,
|
||||
type,
|
||||
description: description.trim(),
|
||||
command: type === "stdio" ? command.trim() : null,
|
||||
args: type === "stdio" ? args : [],
|
||||
env: type === "stdio" ? env.value : {},
|
||||
url: type === "stdio" ? null : url.trim(),
|
||||
headers: type === "stdio" ? {} : headers.value,
|
||||
disabled_tools: disabledTools,
|
||||
};
|
||||
|
||||
try {
|
||||
await upsert.mutateAsync({
|
||||
serverName: trimmedName,
|
||||
server,
|
||||
originalName: mode === "edit" ? originalName : undefined,
|
||||
});
|
||||
toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新");
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<Field label="名称" hint="作为 MCP 服务器的唯一标识,用于工具命名前缀">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如 github、filesystem"
|
||||
aria-invalid={nameConflict || undefined}
|
||||
/>
|
||||
{nameConflict && (
|
||||
<div className="text-destructive text-xs">已存在同名服务器</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="传输类型">
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as MCPServerTransport)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSPORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="启用">
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
{enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="描述">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="给这个 MCP 服务器写一段说明,会展示在工具列表里"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{type === "stdio" && (
|
||||
<>
|
||||
<Field
|
||||
label="启动命令"
|
||||
hint="例如 npx、uvx、python,配合参数启动 MCP server 进程"
|
||||
>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="参数" hint="每行一个参数">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={argsText}
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
placeholder={"-y\n@modelcontextprotocol/server-github"}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="环境变量"
|
||||
hint="每行 KEY=VALUE;值以 $ 开头会在加载时解析为对应环境变量"
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={envText}
|
||||
onChange={(e) => setEnvText(e.target.value)}
|
||||
placeholder={"GITHUB_TOKEN=$GITHUB_TOKEN"}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(type === "http" || type === "sse") && (
|
||||
<>
|
||||
<Field label="服务器 URL">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Headers" hint="每行 KEY=VALUE">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={headersText}
|
||||
onChange={(e) => setHeadersText(e.target.value)}
|
||||
placeholder={"Authorization=Bearer xxx"}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="禁用的工具"
|
||||
hint="每行一个工具名(不带服务器前缀),加载时会被过滤掉"
|
||||
>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={disabledToolsText}
|
||||
onChange={(e) => setDisabledToolsText(e.target.value)}
|
||||
placeholder={"some_dangerous_tool"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={upsert.isPending}>
|
||||
{upsert.isPending
|
||||
? "保存中..."
|
||||
: mode === "create"
|
||||
? "创建"
|
||||
: "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{hint && <div className="text-muted-foreground text-xs">{hint}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function NotificationSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { permission, isSupported, requestPermission, showNotification } =
|
||||
useNotification();
|
||||
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
await requestPermission();
|
||||
};
|
||||
|
||||
const handleTestNotification = () => {
|
||||
showNotification(t.settings.notification.testTitle, {
|
||||
body: t.settings.notification.testBody,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnableNotification = async (enabled: boolean) => {
|
||||
setSettings("notification", {
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.notification.title}
|
||||
description={t.settings.notification.description}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.notSupported}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.notification.title}
|
||||
description={
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{t.settings.notification.description}</div>
|
||||
<div>
|
||||
<Switch
|
||||
disabled={permission !== "granted"}
|
||||
checked={
|
||||
permission === "granted" && settings.notification.enabled
|
||||
}
|
||||
onCheckedChange={handleEnableNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{permission === "default" && (
|
||||
<Button onClick={handleRequestPermission} variant="default">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.requestPermission}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permission === "denied" && (
|
||||
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
|
||||
{t.settings.notification.deniedHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === "granted" && settings.notification.enabled && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button onClick={handleTestNotification} variant="outline">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.testButton}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { BellIcon, BrainIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -17,20 +11,18 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "notification"
|
||||
| "about";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
@@ -70,14 +62,18 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
label: t.settings.sections.memory,
|
||||
icon: BrainIcon,
|
||||
},
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{
|
||||
id: "account",
|
||||
label: "个人信息",
|
||||
icon: UserIcon,
|
||||
},
|
||||
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||
],
|
||||
[
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
t.settings.sections.notification,
|
||||
t.settings.sections.about,
|
||||
],
|
||||
);
|
||||
@@ -123,11 +119,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
</nav>
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||
<div className="min-w-0 space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "skills" && <SkillSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkillContent,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -92,7 +93,7 @@ function SkillSettingsList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
@@ -101,9 +102,9 @@ function SkillSettingsList({
|
||||
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).
|
||||
// 把可见技能分类成 UI 关心的几个桶。
|
||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||
const all: Skill[] = [];
|
||||
const mine: Skill[] = [];
|
||||
@@ -115,8 +116,6 @@ function SkillSettingsList({
|
||||
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);
|
||||
@@ -127,7 +126,7 @@ function SkillSettingsList({
|
||||
continue;
|
||||
}
|
||||
// state === active
|
||||
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
|
||||
if (isBuiltin || !!skill.published) {
|
||||
all.push(skill);
|
||||
}
|
||||
if (isOwnedByMe) {
|
||||
@@ -156,8 +155,8 @@ function SkillSettingsList({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
@@ -258,6 +257,7 @@ function SkillRow({
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -308,7 +308,10 @@ function SkillRow({
|
||||
return (
|
||||
<>
|
||||
<Item className="w-full" variant="outline">
|
||||
<ItemContent>
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
@@ -473,10 +476,55 @@ function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SkillDetailDialog
|
||||
skill={skill}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDetailDialog({
|
||||
skill,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skill: Skill;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogDescription>{skill.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive p-4 text-sm">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
|
||||
{data?.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Plus, Trash2 } 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,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -8,16 +21,54 @@ import {
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
|
||||
import {
|
||||
useDeleteMCPServer,
|
||||
useEnableMCPServer,
|
||||
useMCPConfig,
|
||||
} from "@/core/mcp/hooks";
|
||||
import type { MCPServerConfig } from "@/core/mcp/types";
|
||||
import { env } from "@/env";
|
||||
|
||||
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function ToolSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { config, isLoading, error } = useMCPConfig();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState<"create" | "edit">("create");
|
||||
const [editingName, setEditingName] = useState<string | undefined>(undefined);
|
||||
const [editingServer, setEditingServer] = useState<
|
||||
MCPServerConfig | undefined
|
||||
>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
const servers = useMemo(() => {
|
||||
if (!config) return [] as Array<[string, MCPServerConfig]>;
|
||||
return Object.entries(config.mcp_servers);
|
||||
}, [config]);
|
||||
|
||||
const existingNames = useMemo(() => servers.map(([n]) => n), [servers]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditMode("create");
|
||||
setEditingName(undefined);
|
||||
setEditingServer(undefined);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (name: string, server: MCPServerConfig) => {
|
||||
setEditMode("edit");
|
||||
setEditingName(name);
|
||||
setEditingServer(server);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.tools.title}
|
||||
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
<div className="text-destructive text-sm">Error: {error.message}</div>
|
||||
) : (
|
||||
config && <MCPServerList servers={config.mcp_servers} />
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{isAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={isStaticMode}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新增 MCP 服务器
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MCPServerList
|
||||
servers={servers}
|
||||
isAdmin={isAdmin}
|
||||
isStaticMode={isStaticMode}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(name) => setDeleteTarget(name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MCPServerEditDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
mode={editMode}
|
||||
originalName={editingName}
|
||||
initialServer={editingServer}
|
||||
existingNames={existingNames}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPServerList({
|
||||
servers,
|
||||
isAdmin,
|
||||
isStaticMode,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
servers: Array<[string, MCPServerConfig]>;
|
||||
isAdmin: boolean;
|
||||
isStaticMode: boolean;
|
||||
onEdit: (name: string, server: MCPServerConfig) => void;
|
||||
onDelete: (name: string) => void;
|
||||
}) {
|
||||
const { mutate: enableMCPServer } = useEnableMCPServer();
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
|
||||
{isAdmin
|
||||
? "还没有配置任何 MCP 服务器,点上面的按钮添加一个。"
|
||||
: "管理员还没有配置任何 MCP 服务器。"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{Object.entries(servers).map(([name, config]) => (
|
||||
{servers.map(([name, config]) => (
|
||||
<Item className="w-full" variant="outline" key={name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{name}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.type ?? "stdio"}
|
||||
</Badge>
|
||||
{config.disabled_tools &&
|
||||
config.disabled_tools.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已禁用 {config.disabled_tools.length} 个工具
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{config.description}
|
||||
{config.description ||
|
||||
(config.type === "stdio"
|
||||
? config.command
|
||||
: config.url) ||
|
||||
"暂无描述"}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ItemActions className="flex items-center gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
onClick={() => onEdit(name, config)}
|
||||
disabled={isStaticMode}
|
||||
aria-label="编辑"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive size-8"
|
||||
onClick={() => onDelete(name)}
|
||||
disabled={isStaticMode}
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
disabled={isStaticMode || !isAdmin}
|
||||
onCheckedChange={(checked) =>
|
||||
enableMCPServer({ serverName: name, enabled: checked })
|
||||
}
|
||||
@@ -68,3 +209,49 @@ function MCPServerList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
target,
|
||||
onOpenChange,
|
||||
}: {
|
||||
target: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const deleteMcp = useDeleteMCPServer();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!target) return;
|
||||
try {
|
||||
await deleteMcp.mutateAsync({ serverName: target });
|
||||
toast.success(`已删除 MCP 服务器:${target}`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={target !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除 MCP 服务器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2 text-sm">
|
||||
确定要删除 MCP 服务器 <b>{target}</b> 吗?该操作不可撤销。
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={deleteMcp.isPending}
|
||||
>
|
||||
{deleteMcp.isPending ? "删除中..." : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
BellIcon,
|
||||
ChevronsUpDown,
|
||||
GaugeIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -19,8 +27,9 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
@@ -50,11 +59,13 @@ export function WorkspaceNavMenu() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "notification" | "about"
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -67,10 +78,11 @@ export function WorkspaceNavMenu() {
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
<NotificationBell />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
@@ -88,6 +100,15 @@ export function WorkspaceNavMenu() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
@@ -107,6 +128,26 @@ export function WorkspaceNavMenu() {
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/prompt-prefix")
|
||||
}
|
||||
>
|
||||
<MessageSquareCodeIcon />
|
||||
问答前缀配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/llm-metrics")
|
||||
}
|
||||
>
|
||||
<GaugeIcon />
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -177,10 +177,10 @@ export const enUS: Translations = {
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: "Agents",
|
||||
title: "Experts",
|
||||
description:
|
||||
"Create and manage custom agents with specialized prompts and capabilities.",
|
||||
newAgent: "New Agent",
|
||||
"Create and manage custom experts with specialized prompts and capabilities.",
|
||||
newAgent: "New Expert",
|
||||
emptyTitle: "No custom agents yet",
|
||||
emptyDescription:
|
||||
"Create your first custom agent with a specialized system prompt.",
|
||||
@@ -437,7 +437,7 @@ export const enUS: Translations = {
|
||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||
badgeSourceUpload: "Uploaded",
|
||||
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||
tabAll: "All",
|
||||
tabAll: "Square",
|
||||
tabMine: "Mine",
|
||||
entryStale: "Stale {count}",
|
||||
entryArchived: "Archived {count}",
|
||||
@@ -613,5 +613,8 @@ export const enUS: Translations = {
|
||||
markAllRead: "Mark all read",
|
||||
empty: "No notifications",
|
||||
deleteAria: "Delete notification",
|
||||
settingsAria: "Notification settings",
|
||||
backAria: "Back",
|
||||
settingsTitle: "Notification settings",
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,9 @@ export interface NotificationsTranslations {
|
||||
markAllRead: string;
|
||||
empty: string;
|
||||
deleteAria: string;
|
||||
settingsAria: string;
|
||||
backAria: string;
|
||||
settingsTitle: string;
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
CompassIcon,
|
||||
GraduationCapIcon,
|
||||
ImageIcon,
|
||||
@@ -89,7 +89,7 @@ export const zhCN: Translations = {
|
||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||
proMode: "专业问答",
|
||||
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
||||
ultraMode: "多智能体问答",
|
||||
ultraMode: "多专家问答",
|
||||
ultraModeDescription:
|
||||
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
||||
reasoningEffort: "推理深度",
|
||||
@@ -165,47 +165,47 @@ export const zhCN: Translations = {
|
||||
chats: "对话",
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
agents: "专家",
|
||||
users: "用户管理",
|
||||
},
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: "智能体",
|
||||
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
||||
newAgent: "新建智能体",
|
||||
emptyTitle: "还没有自定义智能体",
|
||||
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
|
||||
title: "专家",
|
||||
description: "创建和管理具有专属 Prompt 与能力的自定义专家。",
|
||||
newAgent: "新建专家",
|
||||
emptyTitle: "还没有自定义专家",
|
||||
emptyDescription: "创建你的第一个自定义专家,设置专属系统提示词。",
|
||||
chat: "对话",
|
||||
delete: "删除",
|
||||
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
|
||||
deleteSuccess: "智能体已删除",
|
||||
deleteConfirm: "确定要删除该专家吗?此操作不可撤销。",
|
||||
deleteSuccess: "专家已删除",
|
||||
newChat: "新对话",
|
||||
createPageTitle: "设计你的智能体",
|
||||
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
|
||||
nameStepTitle: "给新智能体起个名字",
|
||||
createPageTitle: "设计你的专家",
|
||||
createPageSubtitle: "描述你想要的专家,我来帮你通过对话创建。",
|
||||
nameStepTitle: "给新专家起个名字",
|
||||
nameStepHint:
|
||||
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
||||
nameStepPlaceholder: "例如 code-reviewer",
|
||||
nameStepContinue: "继续",
|
||||
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
||||
nameStepAlreadyExistsError: "已存在同名智能体",
|
||||
nameStepAlreadyExistsError: "已存在同名专家",
|
||||
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
|
||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||
nameStepBootstrapMessage:
|
||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
save: "保存智能体",
|
||||
saving: "正在保存智能体...",
|
||||
"新专家的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
save: "保存专家",
|
||||
saving: "正在保存专家...",
|
||||
saveRequested:
|
||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版专家。",
|
||||
saveHint:
|
||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||
"你可以在右上角的菜单里随时保存这个专家,就算目前还只是初稿也可以。",
|
||||
saveCommandMessage:
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义专家。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
agentCreatedPendingRefresh:
|
||||
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
"专家已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
more: "更多操作",
|
||||
agentCreated: "智能体已创建!",
|
||||
agentCreated: "专家已创建!",
|
||||
startChatting: "开始对话",
|
||||
backToGallery: "返回 Gallery",
|
||||
},
|
||||
@@ -420,7 +420,7 @@ export const zhCN: Translations = {
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||
tabAll: "全部",
|
||||
tabAll: "广场",
|
||||
tabMine: "我的",
|
||||
entryStale: "过时 {count}",
|
||||
entryArchived: "归档 {count}",
|
||||
@@ -566,12 +566,12 @@ export const zhCN: Translations = {
|
||||
|
||||
adminUsers: {
|
||||
title: "用户管理",
|
||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
||||
description: "查看所有注册用户及其关联的技能和专家数量。",
|
||||
email: "邮箱",
|
||||
role: "角色",
|
||||
userId: "用户 ID",
|
||||
skillCount: "技能数",
|
||||
agentCount: "智能体数",
|
||||
agentCount: "专家数",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "普通用户",
|
||||
empty: "暂无用户",
|
||||
@@ -584,5 +584,8 @@ export const zhCN: Translations = {
|
||||
markAllRead: "全部已读",
|
||||
empty: "暂无通知",
|
||||
deleteAria: "删除通知",
|
||||
settingsAria: "通知设置",
|
||||
backAria: "返回",
|
||||
settingsTitle: "通知设置",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface LlmMetric {
|
||||
id: string;
|
||||
created_at?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
thread_id?: string | null;
|
||||
run_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
model_name?: string | null;
|
||||
duration_ms: number;
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
total_tokens?: number | null;
|
||||
tokens_per_sec?: number | null;
|
||||
status: "success" | "error" | string;
|
||||
error_type?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface LlmMetricsListResponse {
|
||||
items: LlmMetric[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface LlmMetricsFilters {
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
model_name?: string;
|
||||
status?: "success" | "error";
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface LlmMetricsListQuery extends LlmMetricsFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildQuery(filters: LlmMetricsListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export async function loadLlmMetrics(
|
||||
query: LlmMetricsListQuery,
|
||||
): Promise<LlmMetricsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/llm-metrics${buildQuery(query)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load LLM metrics");
|
||||
}
|
||||
return (await response.json()) as LlmMetricsListResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the Excel download. Returns the blob URL used so callers can revoke
|
||||
* it if they want; the function itself handles the click and cleanup.
|
||||
*/
|
||||
export async function downloadLlmMetricsExcel(
|
||||
filters: LlmMetricsFilters,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/llm-metrics/export${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to export LLM metrics");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = (() => {
|
||||
const header = response.headers.get("content-disposition") ?? "";
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
return plain?.[1] ?? `llm_call_metrics.xlsx`;
|
||||
})();
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { loadLlmMetrics, type LlmMetricsListQuery } from "./api";
|
||||
|
||||
export function useLlmMetrics(query: LlmMetricsListQuery) {
|
||||
return useQuery({
|
||||
queryKey: ["llmMetrics", query],
|
||||
queryFn: () => loadLlmMetrics(query),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { loadMCPConfig, updateMCPConfig } from "./api";
|
||||
import type { MCPServerConfig } from "./types";
|
||||
|
||||
export function useMCPConfig() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -42,3 +43,44 @@ export function useEnableMCPServer() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useMCPConfig();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
serverName,
|
||||
server,
|
||||
originalName,
|
||||
}: {
|
||||
serverName: string;
|
||||
server: MCPServerConfig;
|
||||
originalName?: string;
|
||||
}) => {
|
||||
const next = { ...(config?.mcp_servers ?? {}) };
|
||||
if (originalName && originalName !== serverName) {
|
||||
delete next[originalName];
|
||||
}
|
||||
next[serverName] = server;
|
||||
await updateMCPConfig({ mcp_servers: next });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useMCPConfig();
|
||||
return useMutation({
|
||||
mutationFn: async ({ serverName }: { serverName: string }) => {
|
||||
const next = { ...(config?.mcp_servers ?? {}) };
|
||||
delete next[serverName];
|
||||
await updateMCPConfig({ mcp_servers: next });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
export interface MCPServerConfig extends Record<string, unknown> {
|
||||
export type MCPServerTransport = "stdio" | "http" | "sse";
|
||||
|
||||
export interface MCPOAuthConfig {
|
||||
enabled?: boolean;
|
||||
token_url?: string;
|
||||
grant_type?: "client_credentials" | "refresh_token";
|
||||
client_id?: string | null;
|
||||
client_secret?: string | null;
|
||||
refresh_token?: string | null;
|
||||
scope?: string | null;
|
||||
audience?: string | null;
|
||||
token_field?: string;
|
||||
token_type_field?: string;
|
||||
expires_in_field?: string;
|
||||
default_token_type?: string;
|
||||
refresh_skew_seconds?: number;
|
||||
extra_token_params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
enabled: boolean;
|
||||
type?: MCPServerTransport;
|
||||
command?: string | null;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
oauth?: MCPOAuthConfig | null;
|
||||
description: string;
|
||||
disabled_tools?: string[];
|
||||
}
|
||||
|
||||
export interface MCPConfig {
|
||||
|
||||
@@ -1,104 +1,181 @@
|
||||
/**
|
||||
* Convert markdown text to a Word (.docx) Blob in the browser.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Uses the `docx` npm package to construct an OOXML document client-side.
|
||||
* - Parser is intentionally lightweight: it covers the markdown features that
|
||||
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
|
||||
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
|
||||
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
|
||||
* - We deliberately do NOT reach for a full markdown AST library (remark/
|
||||
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
|
||||
* degrade to plain text rather than failing loudly.
|
||||
* Designed for scheduled-task reports that often contain Chinese text,
|
||||
* markdown tables, and emojis. Compared to Word's stock heading styles we
|
||||
* deliberately:
|
||||
* - Force every run color to pure black (`#000000`) so headings, links,
|
||||
* and inline code never render as Word's default blue / hyperlink theme.
|
||||
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
|
||||
* instead of falling back to plain text rows that overflow the page.
|
||||
* - Strip variation selectors, zero-width joiners, and other invisible
|
||||
* characters that often break Word renderers when emojis leak into the
|
||||
* report.
|
||||
*
|
||||
* Block grammar handled line-by-line:
|
||||
* - ATX heading: `^(#{1,6})\s+(.*)$`
|
||||
* - Fenced code: lines between ``` ``` are emitted verbatim in a mono font
|
||||
* - Blockquote: `^>\s?(.*)$`
|
||||
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
|
||||
* - Unordered list: `^\s*[-*+]\s+(.*)$`
|
||||
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
|
||||
* - Pipe table: contiguous lines starting with `|` plus a separator row
|
||||
* - Blank line: paragraph break
|
||||
* - Anything else: paragraph (with inline formatting)
|
||||
*
|
||||
* Inline grammar handled:
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
|
||||
*/
|
||||
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
type IRunOptions,
|
||||
} from "docx";
|
||||
|
||||
const HEADING_LEVELS = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
];
|
||||
const BLACK = "000000";
|
||||
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
|
||||
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
|
||||
|
||||
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
|
||||
const HEADING_SIZES_HALF_PT: Record<number, number> = {
|
||||
// Word "size" property is half-points. 32 = 16pt.
|
||||
1: 36,
|
||||
2: 32,
|
||||
3: 28,
|
||||
4: 26,
|
||||
5: 24,
|
||||
6: 22,
|
||||
};
|
||||
|
||||
const BODY_SIZE_HALF_PT = 22; // 11pt
|
||||
|
||||
type InlineSegment = {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
code?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop characters that Word renders inconsistently or that frequently smuggle
|
||||
* emoji glitches into corporate reports.
|
||||
*
|
||||
* Removes:
|
||||
* - Variation selectors (FE00–FE0F, E0100–E01EF)
|
||||
* - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B)
|
||||
* - Soft hyphen (00AD)
|
||||
* - Object replacement character (FFFC)
|
||||
*/
|
||||
function sanitizeText(text: string): string {
|
||||
return text
|
||||
.replace(/[︀-️]/g, "")
|
||||
.replace(/[-]/g, "")
|
||||
.replace(//g, "")
|
||||
.replace(//g, "")
|
||||
.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenise a single line of markdown into formatted segments.
|
||||
*
|
||||
* Recognised inline syntax:
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_
|
||||
* Order matters: code spans win over emphasis so backticks don't get parsed
|
||||
* as italic delimiters.
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
|
||||
* Markdown links are flattened to their text content — we deliberately do not
|
||||
* emit hyperlink runs because they pick up Word's blue theme color.
|
||||
*/
|
||||
function parseInlineSegments(text: string): InlineSegment[] {
|
||||
const cleaned = sanitizeText(text);
|
||||
// Strip markdown link syntax first so the regex below doesn't trip on `[...]`.
|
||||
const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
||||
|
||||
const tokens: InlineSegment[] = [];
|
||||
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
||||
const pattern =
|
||||
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
while ((match = pattern.exec(withoutLinks)) !== null) {
|
||||
if (match.index > cursor) {
|
||||
tokens.push({ text: text.slice(cursor, match.index) });
|
||||
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
|
||||
}
|
||||
if (match[2] !== undefined) {
|
||||
tokens.push({ text: match[2], code: true });
|
||||
} else if (match[3] !== undefined) {
|
||||
tokens.push({ text: match[3], bold: true });
|
||||
} else if (match[4] !== undefined) {
|
||||
tokens.push({ text: match[4], bold: true });
|
||||
} else if (match[5] !== undefined) {
|
||||
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
|
||||
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
|
||||
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
|
||||
else if (match[5] !== undefined)
|
||||
tokens.push({ text: match[5], italic: true });
|
||||
} else if (match[6] !== undefined) {
|
||||
else if (match[6] !== undefined)
|
||||
tokens.push({ text: match[6], italic: true });
|
||||
}
|
||||
cursor = pattern.lastIndex;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
tokens.push({ text: text.slice(cursor) });
|
||||
if (cursor < withoutLinks.length) {
|
||||
tokens.push({ text: withoutLinks.slice(cursor) });
|
||||
}
|
||||
return tokens.length > 0 ? tokens : [{ text }];
|
||||
return tokens.length > 0 ? tokens : [{ text: withoutLinks }];
|
||||
}
|
||||
|
||||
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
|
||||
function segmentsToRuns(
|
||||
segments: InlineSegment[],
|
||||
extra?: Partial<IRunOptions>,
|
||||
): TextRun[] {
|
||||
return segments.map((seg) => {
|
||||
const opts: IRunOptions = {
|
||||
text: seg.text,
|
||||
bold: seg.bold,
|
||||
italics: seg.italic,
|
||||
color: BLACK,
|
||||
font: seg.code ? MONO_FONT : BODY_FONT,
|
||||
size: extra?.size ?? BODY_SIZE_HALF_PT,
|
||||
...extra,
|
||||
};
|
||||
if (seg.code) {
|
||||
opts.font = { name: "Consolas" };
|
||||
}
|
||||
return new TextRun(opts);
|
||||
});
|
||||
}
|
||||
|
||||
function makeParagraphFromInline(text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
});
|
||||
}
|
||||
|
||||
function makeHeadingParagraph(level: number, text: string): Paragraph {
|
||||
const clamped = Math.max(1, Math.min(level, 6));
|
||||
return new Paragraph({
|
||||
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
// We attach a heading level so the table-of-contents still works, but the
|
||||
// run-level color / font / size below override Word's blue theme.
|
||||
heading:
|
||||
({
|
||||
1: HeadingLevel.HEADING_1,
|
||||
2: HeadingLevel.HEADING_2,
|
||||
3: HeadingLevel.HEADING_3,
|
||||
4: HeadingLevel.HEADING_4,
|
||||
5: HeadingLevel.HEADING_5,
|
||||
6: HeadingLevel.HEADING_6,
|
||||
}[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined,
|
||||
spacing: { before: 200, after: 120 },
|
||||
children: segmentsToRuns(parseInlineSegments(text), {
|
||||
bold: true,
|
||||
color: BLACK,
|
||||
size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
|
||||
function makeListItem(
|
||||
text: string,
|
||||
ordered: boolean,
|
||||
level: number,
|
||||
): Paragraph {
|
||||
return new Paragraph({
|
||||
spacing: { after: 80 },
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
numbering: ordered
|
||||
? { reference: "ordered-list", level: Math.min(level, 8) }
|
||||
@@ -109,10 +186,13 @@ function makeListItem(text: string, ordered: boolean, level: number): Paragraph
|
||||
|
||||
function makeCodeParagraph(line: string): Paragraph {
|
||||
return new Paragraph({
|
||||
spacing: { after: 0 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: line || " ",
|
||||
font: { name: "Consolas" },
|
||||
text: sanitizeText(line) || " ",
|
||||
font: MONO_FONT,
|
||||
color: BLACK,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -120,45 +200,124 @@ function makeCodeParagraph(line: string): Paragraph {
|
||||
|
||||
function makeQuoteParagraph(text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
||||
indent: { left: 720 },
|
||||
spacing: { after: 120 },
|
||||
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
||||
});
|
||||
}
|
||||
|
||||
function makeHorizontalRule(): Paragraph {
|
||||
return new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "────────" })],
|
||||
spacing: { before: 120, after: 120 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "────────",
|
||||
color: BLACK,
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const CELL_BORDER = {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 4,
|
||||
color: "BFBFBF",
|
||||
};
|
||||
|
||||
function makeTableCell(
|
||||
text: string,
|
||||
options: { header?: boolean } = {},
|
||||
): TableCell {
|
||||
const segments = parseInlineSegments(text);
|
||||
return new TableCell({
|
||||
margins: { top: 80, bottom: 80, left: 100, right: 100 },
|
||||
children: [
|
||||
new Paragraph({
|
||||
spacing: { after: 0 },
|
||||
children: segmentsToRuns(segments, {
|
||||
bold: options.header || undefined,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
// Strip leading/trailing pipe and any escaped pipes (rare in agent output).
|
||||
const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "");
|
||||
return trimmed.split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function buildTable(headerLine: string, bodyLines: string[]): Table {
|
||||
const headerCells = splitTableRow(headerLine);
|
||||
const colCount = headerCells.length;
|
||||
const rows: TableRow[] = [];
|
||||
rows.push(
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: headerCells.map((cell) =>
|
||||
makeTableCell(cell, { header: true }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
for (const row of bodyLines) {
|
||||
const cells = splitTableRow(row);
|
||||
// Pad/truncate to match the header column count so Word renders evenly.
|
||||
const normalized = Array.from(
|
||||
{ length: colCount },
|
||||
(_, i) => cells[i] ?? "",
|
||||
);
|
||||
rows.push(
|
||||
new TableRow({
|
||||
children: normalized.map((c) => makeTableCell(c)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows,
|
||||
borders: {
|
||||
top: CELL_BORDER,
|
||||
bottom: CELL_BORDER,
|
||||
left: CELL_BORDER,
|
||||
right: CELL_BORDER,
|
||||
insideHorizontal: CELL_BORDER,
|
||||
insideVertical: CELL_BORDER,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isTableSeparator(line: string): boolean {
|
||||
// e.g. |---|:---:|---:| with optional spaces
|
||||
return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
|
||||
}
|
||||
|
||||
function looksLikeTableRow(line: string): boolean {
|
||||
return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown text into an array of docx Paragraph objects.
|
||||
*
|
||||
* Block grammar handled (line-by-line):
|
||||
* - ATX heading: `^(#{1,6})\s+(.*)$`
|
||||
* - Fenced code: lines between ``` ``` are emitted verbatim
|
||||
* - Blockquote: `^>\s?(.*)$`
|
||||
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
|
||||
* - Unordered list: `^\s*[-*+]\s+(.*)$`
|
||||
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
|
||||
* - Blank line: paragraph break
|
||||
* - Anything else: paragraph (with inline formatting)
|
||||
* Convert markdown text into an array of docx block elements (Paragraph | Table).
|
||||
*/
|
||||
function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const blocks: Array<Paragraph | Table> = [];
|
||||
|
||||
let inFence = false;
|
||||
let paragraphBuffer: string[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return;
|
||||
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
||||
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
||||
paragraphBuffer = [];
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i] ?? "";
|
||||
const line = rawLine.replace(/\t/g, " ");
|
||||
|
||||
if (line.trim().startsWith("```")) {
|
||||
@@ -167,7 +326,7 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
paragraphs.push(makeCodeParagraph(line));
|
||||
blocks.push(makeCodeParagraph(line));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -179,36 +338,63 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
|
||||
blocks.push(
|
||||
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeHorizontalRule());
|
||||
blocks.push(makeHorizontalRule());
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockquote = /^>\s?(.*)$/.exec(line);
|
||||
if (blockquote) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeQuoteParagraph(blockquote[1]));
|
||||
blocks.push(makeQuoteParagraph(blockquote[1] ?? ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table detection — a header row followed by a separator row.
|
||||
if (
|
||||
looksLikeTableRow(line) &&
|
||||
i + 1 < lines.length &&
|
||||
isTableSeparator(lines[i + 1] ?? "")
|
||||
) {
|
||||
flushParagraph();
|
||||
const headerLine = line;
|
||||
i += 2; // skip header + separator
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) {
|
||||
body.push(lines[i] ?? "");
|
||||
i++;
|
||||
}
|
||||
// Re-step back one so the outer loop's i++ lands on the next unprocessed
|
||||
// line. Without this, blank lines after the table get swallowed.
|
||||
i--;
|
||||
blocks.push(buildTable(headerLine, body));
|
||||
// Word's table rendering eats following spacing; inject a tiny gap.
|
||||
blocks.push(
|
||||
new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
|
||||
if (orderedItem) {
|
||||
flushParagraph();
|
||||
const indent = Math.floor(orderedItem[1].length / 2);
|
||||
paragraphs.push(makeListItem(orderedItem[3], true, indent));
|
||||
const indent = Math.floor(orderedItem[1]!.length / 2);
|
||||
blocks.push(makeListItem(orderedItem[3]!, true, indent));
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||
if (unorderedItem) {
|
||||
flushParagraph();
|
||||
const indent = Math.floor(unorderedItem[1].length / 2);
|
||||
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
|
||||
const indent = Math.floor(unorderedItem[1]!.length / 2);
|
||||
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -216,33 +402,70 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return paragraphs;
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown string into a .docx Blob suitable for download.
|
||||
*/
|
||||
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
|
||||
const children: Paragraph[] = [];
|
||||
export async function markdownToDocxBlob(
|
||||
markdown: string,
|
||||
title?: string,
|
||||
): Promise<Blob> {
|
||||
const children: Array<Paragraph | Table> = [];
|
||||
if (title?.trim()) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
children: [new TextRun({ text: title.trim(), bold: true })],
|
||||
spacing: { before: 0, after: 240 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: sanitizeText(title.trim()),
|
||||
bold: true,
|
||||
color: BLACK,
|
||||
font: BODY_FONT,
|
||||
size: 40, // 20pt
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
children.push(...markdownToParagraphs(markdown));
|
||||
children.push(...markdownToBlocks(markdown));
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
color: BLACK,
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "ordered-list",
|
||||
levels: [
|
||||
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
|
||||
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
|
||||
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
|
||||
{
|
||||
level: 0,
|
||||
format: "decimal",
|
||||
text: "%1.",
|
||||
alignment: AlignmentType.START,
|
||||
},
|
||||
{
|
||||
level: 1,
|
||||
format: "decimal",
|
||||
text: "%2.",
|
||||
alignment: AlignmentType.START,
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
format: "decimal",
|
||||
text: "%3.",
|
||||
alignment: AlignmentType.START,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -27,6 +27,8 @@ export type ScheduledTask = {
|
||||
published_at?: string | null;
|
||||
role?: ScheduledTaskRole;
|
||||
_subscription?: ScheduledTaskSubscription | null;
|
||||
creator_user_id?: string | null;
|
||||
creator_email?: string | null;
|
||||
};
|
||||
|
||||
export type PublishedScheduledTask = {
|
||||
@@ -37,6 +39,8 @@ export type PublishedScheduledTask = {
|
||||
timezone: string;
|
||||
published_at?: string | null;
|
||||
owner_user_id: string;
|
||||
creator_user_id?: string | null;
|
||||
creator_email?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulerThread = {
|
||||
|
||||
@@ -4,6 +4,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: false,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
@@ -22,6 +25,14 @@ export interface LocalSettings {
|
||||
notification: {
|
||||
enabled: boolean;
|
||||
};
|
||||
display: {
|
||||
/**
|
||||
* Whether to render a small token/s speed meter while a streaming
|
||||
* AI reply is in flight. Default off; controlled by the user from
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
| "thread_id"
|
||||
@@ -48,6 +59,10 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
|
||||
...DEFAULT_LOCAL_SETTINGS.notification,
|
||||
...settings?.notification,
|
||||
},
|
||||
display: {
|
||||
...DEFAULT_LOCAL_SETTINGS.display,
|
||||
...settings?.display,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,24 @@ export async function setSkillPinned(
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillContentResponse {
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function getSkillContent(
|
||||
skillName: string,
|
||||
): Promise<SkillContentResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill content");
|
||||
}
|
||||
return response.json() as Promise<SkillContentResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
@@ -21,6 +22,14 @@ export function useSkills() {
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
queryFn: () => getSkillContent(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface PromptPrefixSettings {
|
||||
enabled: boolean;
|
||||
switch_label: string;
|
||||
prompt_prefix: string;
|
||||
}
|
||||
|
||||
export interface PromptPrefixUpdate {
|
||||
enabled?: boolean;
|
||||
switch_label?: string;
|
||||
prompt_prefix?: string;
|
||||
}
|
||||
|
||||
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load prompt prefix settings: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as PromptPrefixSettings;
|
||||
}
|
||||
|
||||
export async function updatePromptPrefixSettings(
|
||||
body: PromptPrefixUpdate,
|
||||
): Promise<PromptPrefixSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update prompt prefix" }));
|
||||
throw new Error(detail.detail ?? "Failed to update prompt prefix");
|
||||
}
|
||||
return (await response.json()) as PromptPrefixSettings;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadPromptPrefixSettings,
|
||||
updatePromptPrefixSettings,
|
||||
type PromptPrefixUpdate,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
queryKey: PROMPT_PREFIX_KEY,
|
||||
queryFn: () => loadPromptPrefixSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePromptPrefixSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PromptPrefixUpdate) => updatePromptPrefixSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PROMPT_PREFIX_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
@@ -41,6 +42,8 @@ export default function ChatPage() {
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,6 +53,7 @@ export default function ChatPage() {
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
@@ -89,13 +93,27 @@ export default function ChatPage() {
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const shouldApplyPrefix =
|
||||
!hasStartedConversation &&
|
||||
promptPrefixOn &&
|
||||
promptPrefixSettings?.enabled === true;
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message);
|
||||
const messageToSend = shouldApplyPrefix
|
||||
? {
|
||||
...message,
|
||||
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
|
||||
}
|
||||
: message;
|
||||
void sendMessage(
|
||||
threadId,
|
||||
messageToSend,
|
||||
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
|
||||
);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
@@ -109,6 +127,16 @@ export default function ChatPage() {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
if (hasStartedConversation) return undefined;
|
||||
return {
|
||||
label: promptPrefixSettings.switch_label || "前缀",
|
||||
on: promptPrefixOn,
|
||||
onToggle: () => setPromptPrefixOn((v) => !v),
|
||||
};
|
||||
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
@@ -195,6 +223,7 @@ export default function ChatPage() {
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
promptPrefix={promptPrefixProp}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Loader2 } 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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
downloadLlmMetricsExcel,
|
||||
type LlmMetricsFilters,
|
||||
} from "@/core/llm-metrics/api";
|
||||
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type StatusFilter = "all" | "success" | "error";
|
||||
|
||||
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
|
||||
// Prefer the resolved email's local part (sent by the backend). Fall back to
|
||||
// the raw UUID only when no account record exists for the recorded user_id —
|
||||
// showing a UUID is uglier but preserves the link to who triggered the call.
|
||||
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
|
||||
const id = (row.user_id ?? "").trim();
|
||||
return id || "—";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value?: number | null, fractionDigits = 0): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(value?: number | null): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||
if (value === 0) return "0 ms";
|
||||
if (value < 1000) return `${value} ms`;
|
||||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||||
const minutes = Math.floor(value / 60_000);
|
||||
const seconds = Math.round((value % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
function speedColor(tps?: number | null): string {
|
||||
if (tps == null) return "#9ca3af";
|
||||
if (tps >= 50) return "#16a34a";
|
||||
if (tps >= 30) return "#65a30d";
|
||||
if (tps >= 15) return "#eab308";
|
||||
if (tps >= 5) return "#f97316";
|
||||
return "#ef4444";
|
||||
}
|
||||
|
||||
export default function LlmMetricsPage() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [model, setModel] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const filters = useMemo<LlmMetricsFilters>(() => {
|
||||
const out: LlmMetricsFilters = {};
|
||||
if (model.trim()) out.model_name = model.trim();
|
||||
if (user.trim()) out.user_name = user.trim();
|
||||
if (status !== "all") out.status = status;
|
||||
if (since) out.since = new Date(since).toISOString();
|
||||
if (until) out.until = new Date(until).toISOString();
|
||||
return out;
|
||||
}, [model, user, status, since, until]);
|
||||
|
||||
const query = useMemo(
|
||||
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
|
||||
[filters, page],
|
||||
);
|
||||
|
||||
const { data, isLoading, error, isFetching } = useLlmMetrics(query);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadLlmMetricsExcel(filters);
|
||||
toast.success("已导出为 Excel");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "导出失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setModel("");
|
||||
setUser("");
|
||||
setStatus("all");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
// Hide columns that are empty for every visible row. Keeps the table dense
|
||||
// when older records lack a tokens breakdown — better to drop the column
|
||||
// than fill the page with placeholder dashes.
|
||||
const visibleColumns = useMemo(() => {
|
||||
type Col = {
|
||||
id: string;
|
||||
header: string;
|
||||
align?: "left" | "right";
|
||||
alwaysShow?: boolean;
|
||||
hasValue: (row: typeof items[number]) => boolean;
|
||||
render: (row: typeof items[number]) => React.ReactNode;
|
||||
title?: (row: typeof items[number]) => string | undefined;
|
||||
className?: string;
|
||||
};
|
||||
const columns: Col[] = [
|
||||
{
|
||||
id: "time",
|
||||
header: "时间",
|
||||
alwaysShow: true,
|
||||
hasValue: () => true,
|
||||
render: (row) => formatDate(row.created_at),
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
header: "用户",
|
||||
alwaysShow: true,
|
||||
hasValue: () => true,
|
||||
render: (row) => displayUser(row),
|
||||
title: (row) => row.user_id ?? undefined,
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
header: "模型",
|
||||
alwaysShow: true,
|
||||
hasValue: () => true,
|
||||
render: (row) => row.model_name ?? "—",
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
header: "Agent",
|
||||
hasValue: (row) => Boolean(row.agent_name),
|
||||
render: (row) => row.agent_name ?? "—",
|
||||
},
|
||||
{
|
||||
id: "input",
|
||||
header: "输入",
|
||||
align: "right",
|
||||
hasValue: (row) => row.input_tokens != null && row.input_tokens > 0,
|
||||
render: (row) => formatNumber(row.input_tokens),
|
||||
className: "tabular-nums",
|
||||
},
|
||||
{
|
||||
id: "output",
|
||||
header: "输出",
|
||||
align: "right",
|
||||
hasValue: (row) => row.output_tokens != null && row.output_tokens > 0,
|
||||
render: (row) => formatNumber(row.output_tokens),
|
||||
className: "tabular-nums",
|
||||
},
|
||||
{
|
||||
id: "total",
|
||||
header: "总 Token",
|
||||
align: "right",
|
||||
hasValue: (row) => row.total_tokens != null && row.total_tokens > 0,
|
||||
render: (row) => formatNumber(row.total_tokens),
|
||||
className: "tabular-nums",
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
header: "耗时",
|
||||
align: "right",
|
||||
alwaysShow: true,
|
||||
hasValue: (row) => row.duration_ms != null && row.duration_ms > 0,
|
||||
render: (row) => formatDuration(row.duration_ms),
|
||||
title: (row) => `${row.duration_ms} ms`,
|
||||
className: "tabular-nums",
|
||||
},
|
||||
{
|
||||
id: "tokens_per_sec",
|
||||
header: "tok/s",
|
||||
align: "right",
|
||||
hasValue: (row) => row.tokens_per_sec != null && row.tokens_per_sec > 0,
|
||||
render: (row) =>
|
||||
row.tokens_per_sec != null ? (
|
||||
<span
|
||||
className="font-mono tabular-nums"
|
||||
style={{ color: speedColor(row.tokens_per_sec) }}
|
||||
>
|
||||
{row.tokens_per_sec.toFixed(1)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "状态",
|
||||
alwaysShow: true,
|
||||
hasValue: () => true,
|
||||
render: (row) => (
|
||||
<Badge
|
||||
variant={row.status === "success" ? "default" : "destructive"}
|
||||
>
|
||||
{row.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "错误",
|
||||
hasValue: (row) => Boolean(row.error_type || row.error_message),
|
||||
render: (row) => row.error_type ?? row.error_message ?? "—",
|
||||
title: (row) => row.error_message ?? undefined,
|
||||
className: "text-muted-foreground max-w-[260px] truncate",
|
||||
},
|
||||
];
|
||||
return columns.filter(
|
||||
(col) => col.alwaysShow || items.some((row) => col.hasValue(row)),
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const colSpan = visibleColumns.length || 1;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">大模型调用统计</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
|
||||
后台无条件记录,与前端速度指示器开关无关。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} disabled={exporting}>
|
||||
{exporting ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 size-4" />
|
||||
)}
|
||||
导出 Excel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">模型</label>
|
||||
<Input
|
||||
value={model}
|
||||
placeholder="按模型名筛选"
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">用户名</label>
|
||||
<Input
|
||||
value={user}
|
||||
placeholder="按用户名匹配(@ 前部分)"
|
||||
onChange={(e) => {
|
||||
setUser(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">状态</label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value as StatusFilter);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="error">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">开始时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(e) => {
|
||||
setSince(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">结束时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(e) => {
|
||||
setUntil(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-5 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
清空筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1080px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
{visibleColumns.map((col) => (
|
||||
<Th
|
||||
key={col.id}
|
||||
className={col.align === "right" ? "text-right" : undefined}
|
||||
>
|
||||
{col.header}
|
||||
</Th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="py-10 text-center">
|
||||
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="text-destructive py-10 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="text-muted-foreground py-10 text-center text-sm">
|
||||
暂无符合条件的调用记录。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((row) => (
|
||||
<tr key={row.id} className="border-t">
|
||||
{visibleColumns.map((col) => (
|
||||
<Td
|
||||
key={col.id}
|
||||
className={cn(
|
||||
col.align === "right" && "text-right",
|
||||
col.className,
|
||||
)}
|
||||
title={col.title?.(row)}
|
||||
>
|
||||
{col.render(row)}
|
||||
</Td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 0 || isLoading}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
第 {page + 1} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page + 1 >= totalPages || isLoading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={`px-3 py-2 align-top text-sm ${className ?? ""}`}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -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,125 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
usePromptPrefixSettings,
|
||||
useUpdatePromptPrefixSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function PromptPrefixSettingsPage() {
|
||||
const { data, isLoading, error } = usePromptPrefixSettings();
|
||||
const update = useUpdatePromptPrefixSettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [switchLabel, setSwitchLabel] = useState("");
|
||||
const [promptPrefix, setPromptPrefix] = useState("");
|
||||
// Only seed the draft from server data once. Re-syncing every time react-query
|
||||
// refetches (window focus, route remount, etc.) would silently overwrite the
|
||||
// user's in-progress edits — and made the toggle look unresponsive: a click
|
||||
// would flip state to true and the next refetch would reset it to false.
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
setSwitchLabel(data.switch_label);
|
||||
setPromptPrefix(data.prompt_prefix);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (enabled && !switchLabel.trim()) {
|
||||
toast.error("开启时必须填写开关名称");
|
||||
return;
|
||||
}
|
||||
if (enabled && !promptPrefix.trim()) {
|
||||
toast.error("开启时必须填写拼接的前缀内容");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
switch_label: switchLabel,
|
||||
prompt_prefix: promptPrefix,
|
||||
});
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">问答前缀配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
|
||||
会将下方配置的前缀文本和原问题拼接后再发送给模型。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">向用户暴露开关</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭时,问答页不显示该开关,且后端不会做任何拼接。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">开关名称</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
展示在问答页输入框附近的标签文本。
|
||||
</p>
|
||||
<Input
|
||||
value={switchLabel}
|
||||
onChange={(e) => setSwitchLabel(e.target.value)}
|
||||
placeholder="例:加入立场前缀"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">拼接内容</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
拼接到首条提问最前面的文字。例如:你需要站在中方角度,进行评价。
|
||||
</p>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={promptPrefix}
|
||||
onChange={(e) => setPromptPrefix(e.target.value)}
|
||||
placeholder="在新会话的第一次提问前自动加入这段文字"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
@@ -23,9 +24,11 @@ import {
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
updateScheduledTaskRunFile,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
@@ -92,6 +95,8 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
@@ -101,6 +106,9 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
|
||||
const canEditRun = run?.can_edit === true;
|
||||
const canDeleteRun = canEditRun;
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
@@ -170,8 +178,82 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFileAsWord = async (file: ScheduledTaskResultFileContent) => {
|
||||
// Prefer the in-progress draft if the user is editing the file.
|
||||
const source = edits[file.index]?.draft ?? file.content ?? "";
|
||||
if (!source) {
|
||||
toast.error("文件内容未加载,无法转换为 Word");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadMarkdownAsDocx(source, file.name, result?.title);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterEdit = useCallback((file: ScheduledTaskResultFileContent) => {
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: { draft: file.content ?? "", saving: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleCancelEdit = useCallback((index: number) => {
|
||||
setEdits((prev) => ({ ...prev, [index]: null }));
|
||||
}, []);
|
||||
|
||||
const handleSaveEdit = useCallback(
|
||||
async (file: ScheduledTaskResultFileContent) => {
|
||||
if (!run) return;
|
||||
const current = edits[file.index];
|
||||
if (!current) return;
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: { ...current, saving: true },
|
||||
}));
|
||||
try {
|
||||
const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
|
||||
setFileContents((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.index === file.index
|
||||
? { ...entry, content: current.draft, size: updated.size ?? entry.size }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
setEdits((prev) => ({ ...prev, [file.index]: null }));
|
||||
toast.success(`文件 ${file.name} 已保存`);
|
||||
} catch (error) {
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: { ...current, saving: false },
|
||||
}));
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
},
|
||||
[edits, run],
|
||||
);
|
||||
|
||||
const handleDownloadBodyAsWord = async () => {
|
||||
if (!result?.content) {
|
||||
toast.error("正文为空,无法转换为 Word");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadMarkdownAsDocx(
|
||||
result.content,
|
||||
result.title || "scheduled-task-result",
|
||||
result.title || undefined,
|
||||
);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRun = async () => {
|
||||
if (!run) return;
|
||||
if (!run || !canDeleteRun) return;
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
@@ -207,10 +289,23 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<Download className="size-3" />
|
||||
下载全部
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleDownloadBodyAsWord}
|
||||
disabled={!result?.content}
|
||||
title="将正文 Markdown 导出为 .docx"
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
下载正文 Word
|
||||
</Button>
|
||||
{canDeleteRun && (
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun}>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 执行信息(可折叠) */}
|
||||
{showMeta && run && (
|
||||
@@ -281,26 +376,94 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileContents.map((file) => (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
{fileContents.map((file) => {
|
||||
const isMarkdown = isMarkdownFile(file);
|
||||
const editable = canEditRun && file.previewable;
|
||||
const editState = edits[file.index] ?? null;
|
||||
const isEditing = editState !== null;
|
||||
return (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isMarkdown && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadFileAsWord(file)}
|
||||
disabled={!file.content && !editState?.draft}
|
||||
title="将 Markdown 导出为 Word"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
{editable && !isEditing && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
|
||||
<Edit3 className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelEdit(file.index)}
|
||||
disabled={editState?.saving}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(file)}
|
||||
disabled={editState?.saving}
|
||||
>
|
||||
{editState?.saving ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
<div className="p-3">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
value={editState?.draft ?? ""}
|
||||
disabled={editState?.saving}
|
||||
onChange={(event) =>
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: {
|
||||
draft: event.target.value,
|
||||
saving: prev[file.index]?.saving ?? false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RenderPreview file={file} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<RenderPreview file={file} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
|
||||
@@ -73,39 +73,91 @@ const MINUTES = [0, 15, 30, 45];
|
||||
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||
|
||||
function buildCron(cfg: ScheduleConfig): string {
|
||||
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
|
||||
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
|
||||
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
|
||||
switch (cfg.freq) {
|
||||
case "hourly":
|
||||
return `${cfg.minute} * * * *`;
|
||||
return `${m} * * * *`;
|
||||
case "daily":
|
||||
return `0 ${cfg.hour} * * *`;
|
||||
return `${m} ${cfg.hour} * * *`;
|
||||
case "weekly":
|
||||
return `0 ${cfg.hour} * * ${cfg.weekday}`;
|
||||
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
|
||||
case "monthly":
|
||||
return `0 ${cfg.hour} ${cfg.date} * *`;
|
||||
return `${m} ${cfg.hour} ${cfg.date} * *`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCron(cron: string): ScheduleConfig | null {
|
||||
// Parse a 5-field cron expression into the form picker state.
|
||||
//
|
||||
// Bug we keep getting bitten by: the edit dialog used to call
|
||||
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
|
||||
// input (free-text like "每天 9 点") rather than a real cron string. The
|
||||
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
|
||||
// reinterpret the task as "every day at 09:00" — or, when natural-language
|
||||
// resolved to "0 * * * *" on the backend, the picker honestly reflected
|
||||
// that as "每小时 0 分". Either way the user lost their original intent.
|
||||
//
|
||||
// Caller now prefers `task.cron_expr` (the canonical schedule produced by
|
||||
// `_schedule_to_cron`); this function stays the single source of truth for
|
||||
// cron → picker, and also handles the looser step-syntax variants
|
||||
// (e.g. "0 [STAR]/3 * * *") we sometimes generate.
|
||||
function parseCron(cron: string | null | undefined): ScheduleConfig | null {
|
||||
if (!cron) return null;
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [min, hr, date, , wd] = parts;
|
||||
const minute = parseInt(min ?? "", 10);
|
||||
const hour = parseInt(hr ?? "", 10);
|
||||
|
||||
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
|
||||
// cron isn't misread as daily.
|
||||
if (hr === "*" && date === "*" && wd === "*") {
|
||||
const minute = parseInt(min);
|
||||
if (!isNaN(minute)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
if (!isNaN(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
}
|
||||
}
|
||||
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
|
||||
// picker doesn't model "every N hours" yet, so this is the safest fallback
|
||||
// that at least doesn't silently round to 9:00.
|
||||
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
|
||||
if (!isNaN(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
}
|
||||
}
|
||||
if (date === "*" && wd === "*") {
|
||||
const hour = parseInt(hr);
|
||||
if (!isNaN(hour)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "daily", hour };
|
||||
if (!isNaN(hour)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "daily",
|
||||
hour,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (date === "*" && wd !== "*") {
|
||||
const hour = parseInt(hr);
|
||||
const weekday = parseInt(wd);
|
||||
if (!isNaN(hour) && !isNaN(weekday)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "weekly", hour, weekday };
|
||||
const weekday = parseInt(wd ?? "", 10);
|
||||
if (!isNaN(hour) && !isNaN(weekday)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "weekly",
|
||||
hour,
|
||||
weekday,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (wd === "*" && date !== "*") {
|
||||
const hour = parseInt(hr);
|
||||
const d = parseInt(date);
|
||||
if (!isNaN(hour) && !isNaN(d)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "monthly", hour, date: d };
|
||||
const d = parseInt(date ?? "", 10);
|
||||
if (!isNaN(hour) && !isNaN(d)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "monthly",
|
||||
hour,
|
||||
date: d,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -191,6 +243,14 @@ function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
/** Render a creator-like identifier without the email domain part. */
|
||||
function displayUsername(value?: string | null): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
const at = raw.indexOf("@");
|
||||
return at === -1 ? raw : raw.slice(0, at);
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
@@ -248,6 +308,7 @@ export default function ScheduledTasksPage() {
|
||||
[tasks],
|
||||
);
|
||||
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
|
||||
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
|
||||
|
||||
const filteredRuns = useMemo(() => {
|
||||
@@ -292,7 +353,14 @@ export default function ScheduledTasksPage() {
|
||||
prompt: task.prompt,
|
||||
execution_agent_name: task.execution_agent_name ?? "",
|
||||
});
|
||||
setScheduleConfig(parseCron(task.schedule_text) ?? DEFAULT_SCHEDULE_CONFIG);
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
// which is what made the dialog default back to "每天9点 → 每小时0分".
|
||||
setScheduleConfig(
|
||||
parseCron(task.cron_expr) ??
|
||||
parseCron(task.schedule_text) ??
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
@@ -451,6 +519,13 @@ export default function ScheduledTasksPage() {
|
||||
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
||||
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
||||
<div>最近状态:{statusLabel(task.last_status)}</div>
|
||||
{(task.creator_email || task.creator_user_id) && (
|
||||
<div className="truncate">
|
||||
创建者:
|
||||
{displayUsername(task.creator_email) ||
|
||||
task.creator_user_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{isOwner ? (
|
||||
@@ -641,6 +716,13 @@ export default function ScheduledTasksPage() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
||||
</p>
|
||||
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
创建者:
|
||||
{displayUsername(selectedTask.creator_email) ||
|
||||
selectedTask.creator_user_id}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-0">
|
||||
<ScrollArea className="size-full">
|
||||
@@ -700,14 +782,16 @@ export default function ScheduledTasksPage() {
|
||||
<Download className="size-4" />
|
||||
下载结果
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteRun(run.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
删除
|
||||
</Button>
|
||||
{selectedTaskCanEditRuns && run.can_edit !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteRun(run.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
@@ -18,19 +16,10 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<SidebarProvider>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
@@ -89,6 +78,7 @@ export default function StrategyRoutes() {
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,8 @@ import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
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";
|
||||
@@ -64,7 +66,7 @@ export function WorkspaceLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
@@ -193,6 +195,30 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptPrefixSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<LlmMetricsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.gateway.routers import (
|
||||
auth,
|
||||
channels,
|
||||
feedback,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
memory,
|
||||
models,
|
||||
@@ -36,6 +37,7 @@ from app.gateway.routers import (
|
||||
scheduled_tasks,
|
||||
skills,
|
||||
suggestions,
|
||||
system_settings,
|
||||
thread_runs,
|
||||
threads,
|
||||
uploads,
|
||||
@@ -572,6 +574,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Scheduled task API is mounted at /api/scheduled-tasks
|
||||
app.include_router(scheduled_tasks.router)
|
||||
|
||||
# System settings API is mounted at /api/system-settings
|
||||
app.include_router(system_settings.router)
|
||||
|
||||
# Admin LLM metrics audit + export
|
||||
app.include_router(llm_metrics.router)
|
||||
|
||||
# Channels API is mounted at /api/channels
|
||||
app.include_router(channels.router)
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ class LocalAuthProvider(AuthProvider):
|
||||
"""Get user by ID."""
|
||||
return await self._repo.get_user_by_id(user_id)
|
||||
|
||||
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
|
||||
"""Find a user whose email starts with ``local_part@`` regardless of domain.
|
||||
|
||||
Used by the passwordless username-login flow to keep a single
|
||||
username mapped to the same account across legacy / canonical
|
||||
email-domain defaults.
|
||||
"""
|
||||
return await self._repo.find_user_by_email_local_part(local_part)
|
||||
|
||||
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
|
||||
"""Create a new local user.
|
||||
|
||||
|
||||
@@ -61,6 +61,26 @@ class UserRepository(ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
|
||||
"""Find a user by the local part of their email (the bit before ``@``).
|
||||
|
||||
Used by the passwordless username-login flow so that a single
|
||||
username keeps mapping to the same account even when the synthetic
|
||||
email domain (e.g. ``cm.com`` vs the legacy ``local.deerflow``)
|
||||
differs between registration and current login.
|
||||
|
||||
Returns the oldest matching user when multiple rows share the same
|
||||
local part, so collisions resolve deterministically.
|
||||
|
||||
Args:
|
||||
local_part: Substring to match before the ``@``. Case-insensitive.
|
||||
|
||||
Returns:
|
||||
User if found, None otherwise.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_user(self, user: User) -> User:
|
||||
"""Update an existing user.
|
||||
|
||||
@@ -88,6 +88,27 @@ class SQLiteUserRepository(UserRepository):
|
||||
row = result.scalar_one_or_none()
|
||||
return self._row_to_user(row) if row is not None else None
|
||||
|
||||
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
|
||||
local = (local_part or "").strip().lower()
|
||||
if not local:
|
||||
return None
|
||||
# Escape SQL LIKE wildcards so usernames like ``foo_bar`` or ``a%b`` do
|
||||
# not match unrelated rows. ``\`` is the chosen escape char below.
|
||||
escaped = local.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"{escaped}@%"
|
||||
# Case-insensitive prefix match on the email field, oldest first so a
|
||||
# subsequent registration cannot shadow an existing account.
|
||||
stmt = (
|
||||
select(UserRow)
|
||||
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
|
||||
.order_by(UserRow.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
return self._row_to_user(row) if row is not None else None
|
||||
|
||||
async def update_user(self, user: User) -> User:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserRow, str(user.id))
|
||||
|
||||
@@ -92,6 +92,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.scheduled_task_store = make_scheduled_task_store(sf)
|
||||
|
||||
from deerflow.persistence.llm_metrics import make_llm_metrics_store
|
||||
|
||||
app.state.llm_metrics_store = make_llm_metrics_store(sf)
|
||||
|
||||
from deerflow.persistence.agents import make_agent_store
|
||||
|
||||
app.state.agent_store = make_agent_store(sf)
|
||||
@@ -176,6 +180,11 @@ def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
|
||||
return val
|
||||
|
||||
|
||||
def get_llm_metrics_store(request: Request):
|
||||
"""Return the LLM metrics store, or None when not configured."""
|
||||
return getattr(request.app.state, "llm_metrics_store", None)
|
||||
|
||||
|
||||
def get_agent_store(request: Request) -> AgentStore:
|
||||
"""Return the custom-agent store."""
|
||||
val = getattr(request.app.state, "agent_store", None)
|
||||
|
||||
@@ -164,25 +164,40 @@ class MessageResponse(BaseModel):
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_username_local_part(username: str) -> str | None:
|
||||
"""Return the canonical local-part for a username, or None if user typed an email.
|
||||
|
||||
Used both to build the synthetic email and to look up legacy accounts that
|
||||
were registered with a different default domain. Returning ``None`` for
|
||||
raw-email input tells callers to skip the domain-agnostic fallback —
|
||||
if you typed ``alice@old.com``, you meant that specific row.
|
||||
"""
|
||||
raw = username.strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
|
||||
if "@" in raw:
|
||||
return None
|
||||
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _username_to_email(username: str) -> str:
|
||||
"""Map a frontend username to a valid email used by user storage.
|
||||
|
||||
- If username already looks like an email (`contains @`), use it directly.
|
||||
- Otherwise generate `<normalized-username>@<domain>`.
|
||||
"""
|
||||
raw = username.strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
|
||||
local = _normalize_username_local_part(username)
|
||||
if local is None:
|
||||
return username.strip().lower()
|
||||
|
||||
if "@" in raw:
|
||||
return raw.lower()
|
||||
|
||||
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
|
||||
|
||||
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "local.deerflow").strip().lower() or "local.deerflow"
|
||||
return f"{normalized}@{domain}"
|
||||
# Default mail domain for passwordless / username-only registrations.
|
||||
# Kept overridable via env so individual deployments can swap it out, but the
|
||||
# built-in default targets the cm.com tenant.
|
||||
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "cm.com").strip().lower() or "cm.com"
|
||||
return f"{local}@{domain}"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
|
||||
@@ -368,9 +383,21 @@ async def login_by_username(request: Request, response: Response, body: Username
|
||||
"""
|
||||
provider = get_local_provider()
|
||||
email = _username_to_email(body.username)
|
||||
local_part = _normalize_username_local_part(body.username)
|
||||
|
||||
created = False
|
||||
user = await provider.get_user_by_email(email)
|
||||
# Domain-agnostic fallback: if no row exists at the canonical
|
||||
# ``<username>@<current-default-domain>``, look for an existing user with
|
||||
# the same local part under any domain. This is the safety net that keeps
|
||||
# usernames globally unique when the default email domain changes (e.g.
|
||||
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
|
||||
# would let the same username log into two different accounts.
|
||||
if user is None and local_part is not None:
|
||||
legacy = await provider.find_user_by_email_local_part(local_part)
|
||||
if legacy is not None:
|
||||
user = legacy
|
||||
logger.info("Matched username %r to legacy account %s", body.username, legacy.email)
|
||||
if user is None:
|
||||
try:
|
||||
role = "admin" if body.username == "admin" else "user"
|
||||
@@ -380,6 +407,8 @@ async def login_by_username(request: Request, response: Response, body: Username
|
||||
except ValueError:
|
||||
# Concurrent first login may create the same user in parallel.
|
||||
user = await provider.get_user_by_email(email)
|
||||
if user is None and local_part is not None:
|
||||
user = await provider.find_user_by_email_local_part(local_part)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user")
|
||||
elif body.username == "admin" and user.system_role != "admin":
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Admin-only LLM call metrics: list + Excel export.
|
||||
|
||||
Lets administrators audit every provider invocation made by the runtime —
|
||||
including failures — and download the full table as ``.xlsx``. The recording
|
||||
is unconditional on the backend, so this view always reflects every call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_llm_metrics_store, get_local_provider, get_optional_user_from_request
|
||||
from deerflow.persistence.llm_metrics.base import LlmMetricsQuery, LlmMetricsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin/llm-metrics", tags=["llm-metrics"])
|
||||
|
||||
|
||||
class LlmMetricResponse(BaseModel):
|
||||
id: str
|
||||
created_at: str | None = None
|
||||
user_id: str | None = None
|
||||
user_name: str | None = Field(
|
||||
default=None,
|
||||
description="Local part (before @) of the user's email, resolved from user_id",
|
||||
)
|
||||
thread_id: str | None = None
|
||||
run_id: str | None = None
|
||||
agent_name: str | None = None
|
||||
model_name: str | None = None
|
||||
duration_ms: int = 0
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
tokens_per_sec: float | None = None
|
||||
status: str = "success"
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class LlmMetricsListResponse(BaseModel):
|
||||
items: list[LlmMetricResponse]
|
||||
total: int = Field(..., description="Total rows matching the filter, ignoring pagination")
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="LLM metrics admin view is admin-only")
|
||||
|
||||
|
||||
def _require_store(request: Request) -> LlmMetricsStore:
|
||||
store = get_llm_metrics_store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="LLM metrics store is not configured")
|
||||
return store
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
# Accept both ``Z`` suffix and ``+00:00``.
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid datetime: {value!r} ({exc})") from exc
|
||||
|
||||
|
||||
def _email_local_part(email: str | None) -> str | None:
|
||||
if not email:
|
||||
return None
|
||||
at = email.find("@")
|
||||
return email[:at] if at > 0 else email
|
||||
|
||||
|
||||
def _derive_tokens_per_sec(row: dict[str, Any]) -> None:
|
||||
"""Fill in ``tokens_per_sec`` when the row was recorded before we knew how
|
||||
to extract ``output_tokens`` from response_metadata.token_usage.
|
||||
|
||||
Uses ``output_tokens / elapsed`` when available, otherwise falls back to
|
||||
``total_tokens / elapsed`` as an approximation. Newer rows already have
|
||||
the field set during recording, so this only kicks in for legacy data.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return
|
||||
if row.get("tokens_per_sec") not in (None, 0):
|
||||
return
|
||||
duration_ms = row.get("duration_ms") or 0
|
||||
if duration_ms <= 0:
|
||||
return
|
||||
numerator = row.get("output_tokens") or row.get("total_tokens")
|
||||
if not numerator:
|
||||
return
|
||||
row["tokens_per_sec"] = round((numerator / duration_ms) * 1000, 3)
|
||||
|
||||
|
||||
async def _resolve_user_name_to_id(name: str | None) -> str | None:
|
||||
"""Look up a UUID for the given username (email local-part)."""
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
user = await provider.find_user_by_email_local_part(name)
|
||||
except Exception:
|
||||
return None
|
||||
return str(user.id) if user is not None else None
|
||||
|
||||
|
||||
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
|
||||
|
||||
Batches lookups so one query per distinct user_id, not per row. Tolerates
|
||||
missing accounts (deleted users) by leaving ``user_name`` blank.
|
||||
"""
|
||||
if not rows:
|
||||
return rows
|
||||
user_ids = {row["user_id"] for row in rows if isinstance(row, dict) and row.get("user_id")}
|
||||
if not user_ids:
|
||||
return rows
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
except Exception:
|
||||
provider = None
|
||||
email_by_id: dict[str, str] = {}
|
||||
if provider is not None:
|
||||
for uid in user_ids:
|
||||
try:
|
||||
user = await provider.get_user(uid)
|
||||
except Exception:
|
||||
user = None
|
||||
if user is not None and user.email:
|
||||
email_by_id[uid] = user.email
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and row.get("user_id"):
|
||||
email = email_by_id.get(row["user_id"])
|
||||
row["user_name"] = _email_local_part(email) or row["user_id"]
|
||||
return rows
|
||||
|
||||
|
||||
def _build_query(
|
||||
*,
|
||||
user_id: str | None,
|
||||
model_name: str | None,
|
||||
status: str | None,
|
||||
since: str | None,
|
||||
until: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> LlmMetricsQuery:
|
||||
return LlmMetricsQuery(
|
||||
user_id=user_id or None,
|
||||
model_name=model_name or None,
|
||||
status=status or None,
|
||||
since=_parse_dt(since),
|
||||
until=_parse_dt(until),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=LlmMetricsListResponse)
|
||||
async def list_llm_metrics(
|
||||
request: Request,
|
||||
user_id: str | None = Query(default=None),
|
||||
user_name: str | None = Query(default=None, description="Username (email local part) to filter by"),
|
||||
model_name: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None, pattern="^(success|error)$"),
|
||||
since: str | None = Query(default=None, description="ISO 8601 datetime, inclusive lower bound"),
|
||||
until: str | None = Query(default=None, description="ISO 8601 datetime, inclusive upper bound"),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> LlmMetricsListResponse:
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
# Username unknown → no rows; short-circuit instead of a global scan.
|
||||
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
|
||||
user_id = resolved
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
model_name=model_name,
|
||||
status=status,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
rows = await store.list(query)
|
||||
total = await store.count(query)
|
||||
await _attach_usernames(rows)
|
||||
for row in rows:
|
||||
_derive_tokens_per_sec(row)
|
||||
return LlmMetricsListResponse(
|
||||
items=[LlmMetricResponse(**row) for row in rows],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _format_cell(value: Any) -> Any:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None) if value.tzinfo else value
|
||||
return value
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_llm_metrics(
|
||||
request: Request,
|
||||
user_id: str | None = Query(default=None),
|
||||
user_name: str | None = Query(default=None),
|
||||
model_name: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None, pattern="^(success|error)$"),
|
||||
since: str | None = Query(default=None),
|
||||
until: str | None = Query(default=None),
|
||||
):
|
||||
"""Stream the full filtered metric set as an ``.xlsx`` file."""
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
# No matching user — return an empty xlsx so the user still gets a
|
||||
# file (matching the list view's empty state).
|
||||
user_id = "__no_such_user__"
|
||||
else:
|
||||
user_id = resolved
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
|
||||
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
model_name=model_name,
|
||||
status=status,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=1, # unused: iter_all paginates internally
|
||||
offset=0,
|
||||
)
|
||||
|
||||
workbook = Workbook(write_only=True)
|
||||
sheet = workbook.create_sheet("llm_call_metrics")
|
||||
columns = [
|
||||
"id",
|
||||
"created_at",
|
||||
"status",
|
||||
"user_name",
|
||||
"user_id",
|
||||
"thread_id",
|
||||
"run_id",
|
||||
"agent_name",
|
||||
"model_name",
|
||||
"duration_ms",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"tokens_per_sec",
|
||||
"error_type",
|
||||
"error_message",
|
||||
]
|
||||
sheet.append(columns)
|
||||
|
||||
# Pre-resolve all distinct user_ids in chunks so the export doesn't pay one
|
||||
# round trip per row.
|
||||
buffered: list[dict[str, Any]] = []
|
||||
FLUSH_AT = 200
|
||||
|
||||
async def flush() -> int:
|
||||
if not buffered:
|
||||
return 0
|
||||
await _attach_usernames(buffered)
|
||||
for row in buffered:
|
||||
_derive_tokens_per_sec(row)
|
||||
sheet.append([_format_cell(row.get(col)) for col in columns])
|
||||
n = len(buffered)
|
||||
buffered.clear()
|
||||
return n
|
||||
|
||||
count = 0
|
||||
async for row in store.iter_all(query):
|
||||
buffered.append(row)
|
||||
if len(buffered) >= FLUSH_AT:
|
||||
count += await flush()
|
||||
count += await flush()
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
buffer.seek(0)
|
||||
logger.info("Exported %d llm_call_metrics rows", count)
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
filename = f"llm_call_metrics-{timestamp}.xlsx"
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quote(filename)}",
|
||||
}
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers=headers,
|
||||
)
|
||||
@@ -3,15 +3,31 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
"""Reject the request unless the caller is an authenticated admin user.
|
||||
|
||||
Allows the call through silently when auth is disabled (no user attached),
|
||||
so existing single-user / no-auth deployments keep working.
|
||||
"""
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
# No authenticated user — auth might be disabled or middleware bypassed.
|
||||
# Defer to the AuthMiddleware decision and let the call through.
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="MCP configuration requires admin privileges")
|
||||
|
||||
|
||||
class McpOAuthConfigResponse(BaseModel):
|
||||
"""OAuth configuration for an MCP server."""
|
||||
|
||||
@@ -43,6 +59,10 @@ class McpServerConfigResponse(BaseModel):
|
||||
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
||||
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
|
||||
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
||||
disabled_tools: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Tool names (without server-name prefix) to hide from the agent",
|
||||
)
|
||||
|
||||
|
||||
class McpConfigResponse(BaseModel):
|
||||
@@ -101,7 +121,7 @@ async def get_mcp_configuration() -> McpConfigResponse:
|
||||
summary="Update MCP Configuration",
|
||||
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
||||
)
|
||||
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
|
||||
async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request: Request) -> McpConfigResponse:
|
||||
"""Update the MCP configuration.
|
||||
|
||||
This will:
|
||||
@@ -133,6 +153,22 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
|
||||
}
|
||||
```
|
||||
"""
|
||||
await _require_admin(http_request)
|
||||
|
||||
# Light validation: each server must have the fields required by its transport.
|
||||
for server_name, server in request.mcp_servers.items():
|
||||
if not server_name.strip():
|
||||
raise HTTPException(status_code=400, detail="MCP server name cannot be empty")
|
||||
transport = (server.type or "stdio").lower()
|
||||
if transport == "stdio":
|
||||
if not (server.command or "").strip():
|
||||
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' uses stdio transport but command is empty")
|
||||
elif transport in ("http", "sse"):
|
||||
if not (server.url or "").strip():
|
||||
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' uses {transport} transport but url is empty")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' has unsupported transport: {transport}")
|
||||
|
||||
try:
|
||||
# Get the current config path (or determine where to save it)
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user
|
||||
from app.gateway.deps import get_current_user, get_local_provider
|
||||
from deerflow.runtime.scheduler import get_scheduled_task_service
|
||||
|
||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||
@@ -61,6 +61,50 @@ async def _user_id(request: Request) -> str:
|
||||
return user_id
|
||||
|
||||
|
||||
async def _enrich_with_creator(tasks: list[dict[str, Any]] | dict[str, Any] | None) -> Any:
|
||||
"""Attach ``creator_email`` / ``creator_user_id`` to one or many task dicts.
|
||||
|
||||
Looks up the auth user once per distinct ``user_id`` in the batch so we do
|
||||
not pay N+1 round trips when rendering a long task list. Tolerates missing
|
||||
user records (deleted accounts) by leaving the email blank.
|
||||
"""
|
||||
if tasks is None:
|
||||
return tasks
|
||||
items = tasks if isinstance(tasks, list) else [tasks]
|
||||
|
||||
user_ids = {str(t.get("user_id")) for t in items if isinstance(t, dict) and t.get("user_id")}
|
||||
if not user_ids:
|
||||
return tasks
|
||||
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
except Exception:
|
||||
# Auth provider not initialised (e.g. memory-mode test harness): keep
|
||||
# the bare user_id and let the frontend render the raw id.
|
||||
provider = None
|
||||
|
||||
email_by_id: dict[str, str] = {}
|
||||
if provider is not None:
|
||||
for uid in user_ids:
|
||||
try:
|
||||
user = await provider.get_user(uid)
|
||||
except Exception:
|
||||
user = None
|
||||
if user is not None:
|
||||
email_by_id[uid] = user.email
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
uid = item.get("user_id")
|
||||
if uid is None:
|
||||
continue
|
||||
uid_str = str(uid)
|
||||
item["creator_user_id"] = uid_str
|
||||
item["creator_email"] = email_by_id.get(uid_str, "")
|
||||
return tasks
|
||||
|
||||
|
||||
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
|
||||
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
|
||||
return name or default
|
||||
@@ -133,7 +177,9 @@ async def get_scheduler_thread(request: Request) -> dict:
|
||||
async def list_scheduled_tasks(request: Request) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.list_tasks(user_id)
|
||||
tasks = await service.list_tasks(user_id)
|
||||
await _enrich_with_creator(tasks)
|
||||
return tasks
|
||||
|
||||
|
||||
@router.get("/{task_id}/runs")
|
||||
@@ -158,7 +204,10 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.delete_run(user_id, run_id)
|
||||
try:
|
||||
deleted = await service.delete_run(user_id, run_id)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return {"success": True}
|
||||
@@ -298,7 +347,7 @@ async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateReques
|
||||
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.create_task(
|
||||
task = await service.create_task(
|
||||
user_id=user_id,
|
||||
name=body.name,
|
||||
prompt=body.prompt,
|
||||
@@ -307,6 +356,8 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
|
||||
execution_agent_name=body.execution_agent_name,
|
||||
execution_context=body.execution_context,
|
||||
)
|
||||
await _enrich_with_creator(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
@@ -316,6 +367,7 @@ async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest,
|
||||
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
await _enrich_with_creator(task)
|
||||
return task
|
||||
|
||||
|
||||
@@ -366,7 +418,17 @@ async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
await _user_id(request)
|
||||
return await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
|
||||
tasks = await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
|
||||
# published list uses owner_user_id; resolve it into a creator_email here too
|
||||
# so the discovery view can show the author's account.
|
||||
for task in tasks:
|
||||
if isinstance(task, dict) and task.get("owner_user_id"):
|
||||
task["user_id"] = task["owner_user_id"]
|
||||
await _enrich_with_creator(tasks)
|
||||
for task in tasks:
|
||||
if isinstance(task, dict):
|
||||
task.pop("user_id", None)
|
||||
return tasks
|
||||
|
||||
|
||||
@router.post("/{task_id}/publish")
|
||||
|
||||
@@ -816,6 +816,36 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
||||
|
||||
|
||||
class SkillContentResponse(BaseModel):
|
||||
"""SKILL.md 原文内容,适用于公共和自定义技能。"""
|
||||
|
||||
name: str
|
||||
category: SkillCategory
|
||||
content: str
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_name}/content",
|
||||
response_model=SkillContentResponse,
|
||||
summary="Get Skill Markdown Content",
|
||||
description="Retrieve the raw SKILL.md content of a specific skill (public or custom).",
|
||||
)
|
||||
async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
skill = next((s for s in skills if s.name == skill_name), None)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
content = skill.skill_file.read_text(encoding="utf-8")
|
||||
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Admin-configurable system-wide runtime settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.config.system_settings import (
|
||||
PromptPrefixSettings,
|
||||
SystemSettings,
|
||||
load_system_settings,
|
||||
save_system_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
|
||||
|
||||
|
||||
class PromptPrefixResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
|
||||
switch_label: str = Field(..., description="Toggle label shown in the chat input")
|
||||
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
|
||||
|
||||
|
||||
class PromptPrefixUpdateRequest(BaseModel):
|
||||
enabled: bool | None = None
|
||||
switch_label: str | None = None
|
||||
prompt_prefix: str | None = None
|
||||
|
||||
|
||||
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||
return PromptPrefixResponse(
|
||||
enabled=settings.enabled,
|
||||
switch_label=settings.switch_label,
|
||||
prompt_prefix=settings.prompt_prefix,
|
||||
)
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="System settings can only be modified by an admin")
|
||||
|
||||
|
||||
@router.get("/prompt-prefix", response_model=PromptPrefixResponse)
|
||||
async def get_prompt_prefix() -> PromptPrefixResponse:
|
||||
"""Return the current prompt-prefix configuration (readable by any user)."""
|
||||
return _to_response(load_system_settings().prompt_prefix)
|
||||
|
||||
|
||||
@router.put("/prompt-prefix", response_model=PromptPrefixResponse)
|
||||
async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request) -> PromptPrefixResponse:
|
||||
"""Update the prompt-prefix configuration. Admin only."""
|
||||
await _require_admin(request)
|
||||
|
||||
current = load_system_settings()
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
||||
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
|
||||
return _to_response(saved.prompt_prefix)
|
||||
@@ -19,6 +19,7 @@ from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.runtime import (
|
||||
END_SENTINEL,
|
||||
HEARTBEAT_SENTINEL,
|
||||
@@ -98,6 +99,45 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
||||
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||
|
||||
|
||||
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
|
||||
"""Return ``graph_input`` with the prefix prepended to its first HumanMessage.
|
||||
|
||||
Leaves the input unchanged when there are no messages or the first message
|
||||
is not a human turn. Idempotent in the sense that it only touches the first
|
||||
human message, so re-running this with the same prefix would double-prepend
|
||||
— callers must gate it on first-turn detection.
|
||||
"""
|
||||
if not prefix:
|
||||
return graph_input
|
||||
messages = graph_input.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return graph_input
|
||||
new_messages = list(messages)
|
||||
for idx, msg in enumerate(new_messages):
|
||||
if isinstance(msg, HumanMessage):
|
||||
existing = msg.content
|
||||
if isinstance(existing, str):
|
||||
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing}".strip("\n"))
|
||||
elif isinstance(existing, list):
|
||||
# Structured content (text blocks + media). Prepend a new text block.
|
||||
new_messages[idx] = HumanMessage(content=[{"type": "text", "text": prefix}, *existing])
|
||||
else:
|
||||
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing!s}")
|
||||
break
|
||||
return {**graph_input, "messages": new_messages}
|
||||
|
||||
|
||||
def _prompt_prefix_opted_in(body: Any) -> bool:
|
||||
"""Detect whether the caller asked for the prompt prefix to be applied."""
|
||||
context = getattr(body, "context", None)
|
||||
if isinstance(context, Mapping) and context.get("prompt_prefix_enabled"):
|
||||
return True
|
||||
metadata = getattr(body, "metadata", None)
|
||||
if isinstance(metadata, Mapping) and metadata.get("prompt_prefix_enabled"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
||||
# ``body.context`` into the run config. ``config["context"]`` exists in
|
||||
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
||||
@@ -340,6 +380,7 @@ async def start_run(
|
||||
# Upsert thread metadata so the thread appears in /threads/search,
|
||||
# even for threads that were never explicitly created via POST /threads
|
||||
# (e.g. stateless runs).
|
||||
thread_existed = False
|
||||
try:
|
||||
existing = await run_ctx.thread_store.get(thread_id)
|
||||
if existing is None:
|
||||
@@ -349,12 +390,24 @@ async def start_run(
|
||||
metadata=body.metadata,
|
||||
)
|
||||
else:
|
||||
thread_existed = True
|
||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
||||
except Exception:
|
||||
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
||||
|
||||
agent_factory = resolve_agent_factory(body.assistant_id)
|
||||
graph_input = normalize_input(body.input)
|
||||
|
||||
# Server-side safety net for the admin-configurable prompt prefix: when the
|
||||
# caller opted in via context.prompt_prefix_enabled AND this is the very
|
||||
# first turn of the thread, prepend the configured prefix to the human
|
||||
# message. We deliberately skip already-running threads so the prefix is
|
||||
# never inserted twice — the frontend toggle is a per-message hint, but
|
||||
# this check is what makes the feature safe against client bugs / replays.
|
||||
if not thread_existed and _prompt_prefix_opted_in(body):
|
||||
prefix_settings = load_system_settings().prompt_prefix
|
||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
||||
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
||||
|
||||
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
||||
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
"""Middleware that records per-LLM-call metrics for the admin audit page.
|
||||
|
||||
Captures **every** provider invocation regardless of the frontend speed-meter
|
||||
toggle: the goal is a complete admin-side audit log of LLM usage, including
|
||||
failures. The user-facing speed indicator (controlled by
|
||||
``LocalSettings.display.streamSpeedMeterEnabled``) is a separate, purely
|
||||
cosmetic feature — turning it off does not stop this recording.
|
||||
|
||||
Each call ends with one row in ``llm_call_metrics``:
|
||||
- Successes record duration, token counts, and ``tokens_per_sec``.
|
||||
- Errors record the duration spent before the failure, the exception class as
|
||||
``error_type`` and a one-line ``error_message``.
|
||||
|
||||
Writes go through a best-effort store: a DB outage logs a warning and the
|
||||
agent runtime keeps streaming. We intentionally place this middleware
|
||||
*inside* :class:`LLMErrorHandlingMiddleware` (i.e. nested deeper in the
|
||||
``wrap_model_call`` chain) so we observe the **real** provider call timings —
|
||||
not the retry-wrapped successful response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import (
|
||||
ModelCallResult,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.llm_metrics import LlmMetricRecord, make_llm_metrics_store
|
||||
from deerflow.persistence.llm_metrics.base import LlmMetricsStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _safe_get_thread_id(request: ModelRequest) -> str | None:
|
||||
state = getattr(request, "state", None)
|
||||
if isinstance(state, dict):
|
||||
thread_id = state.get("thread_id")
|
||||
if isinstance(thread_id, str) and thread_id:
|
||||
return thread_id
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is None:
|
||||
return None
|
||||
config = getattr(runtime, "config", None)
|
||||
configurable = (config or {}).get("configurable", {}) if isinstance(config, dict) else {}
|
||||
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
|
||||
if isinstance(thread_id, str) and thread_id:
|
||||
return thread_id
|
||||
return None
|
||||
|
||||
|
||||
def _safe_get_run_id(request: ModelRequest) -> str | None:
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is None:
|
||||
return None
|
||||
context = getattr(runtime, "context", None)
|
||||
if isinstance(context, dict):
|
||||
for key in ("run_id", "langgraph_run_id"):
|
||||
value = context.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
config = getattr(runtime, "config", None)
|
||||
if isinstance(config, dict):
|
||||
metadata = config.get("metadata") if isinstance(config.get("metadata"), dict) else {}
|
||||
if isinstance(metadata, dict):
|
||||
for key in ("run_id", "langgraph_run_id"):
|
||||
value = metadata.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _safe_get_model_name(request: ModelRequest) -> str | None:
|
||||
model = getattr(request, "model", None)
|
||||
for attr in ("model_name", "model", "name"):
|
||||
value = getattr(model, attr, None)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _safe_get_agent_name(request: ModelRequest) -> str | None:
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is None:
|
||||
return None
|
||||
context = getattr(runtime, "context", None)
|
||||
if isinstance(context, dict):
|
||||
for key in ("agent_name", "agent_id"):
|
||||
value = context.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_usage(response: Any) -> dict[str, int]:
|
||||
"""Pull token counts out of an AIMessage, falling back to provider-native shapes.
|
||||
|
||||
The handler in ``wrap_model_call`` actually hands us a
|
||||
:class:`langchain.agents.middleware.types.ModelResponse` dataclass whose
|
||||
``.result`` is a ``list[BaseMessage]``. Earlier versions of this helper
|
||||
only matched ``AIMessage`` and ``list``, which meant we silently dropped
|
||||
every successful call and stored None for input/output/total. To stay
|
||||
robust we now also handle ``ModelResponse`` (and ``ExtendedModelResponse``,
|
||||
which wraps a ModelResponse), and we walk the messages list for the first
|
||||
``AIMessage``.
|
||||
|
||||
LangChain normalises usage into ``message.usage_metadata`` with the keys
|
||||
``input_tokens`` / ``output_tokens`` / ``total_tokens``. Several
|
||||
OpenAI-compatible providers (DeepSeek, vLLM, some Azure deployments) leave
|
||||
that dict partial — typically only ``total_tokens`` is populated — and the
|
||||
real breakdown sits under ``response_metadata.token_usage`` with the
|
||||
provider-native key names ``prompt_tokens`` / ``completion_tokens``. We
|
||||
merge both so the admin audit view always has the most complete numbers
|
||||
available.
|
||||
"""
|
||||
message: AIMessage | None = None
|
||||
|
||||
def _find_ai(candidate: Any) -> AIMessage | None:
|
||||
if isinstance(candidate, AIMessage):
|
||||
return candidate
|
||||
if isinstance(candidate, list):
|
||||
for item in candidate:
|
||||
found = _find_ai(item)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
message = _find_ai(response)
|
||||
if message is None:
|
||||
# ModelResponse / ExtendedModelResponse — defined as dataclasses with a
|
||||
# ``result`` field holding ``list[BaseMessage]``. Recurse via attribute
|
||||
# access so we don't tightly couple to the dataclass class itself.
|
||||
for attr in ("result", "model_response"):
|
||||
inner = getattr(response, attr, None)
|
||||
if inner is None:
|
||||
continue
|
||||
message = _find_ai(inner)
|
||||
if message is None and attr == "model_response":
|
||||
inner_result = getattr(inner, "result", None)
|
||||
message = _find_ai(inner_result)
|
||||
if message is not None:
|
||||
break
|
||||
if message is None:
|
||||
return {}
|
||||
|
||||
out: dict[str, int] = {}
|
||||
|
||||
usage = getattr(message, "usage_metadata", None) or {}
|
||||
if isinstance(usage, dict):
|
||||
for key in ("input_tokens", "output_tokens", "total_tokens"):
|
||||
coerced = _coerce_int(usage.get(key))
|
||||
if coerced is not None:
|
||||
out[key] = coerced
|
||||
|
||||
response_metadata = getattr(message, "response_metadata", None) or {}
|
||||
token_usage = response_metadata.get("token_usage") if isinstance(response_metadata, dict) else None
|
||||
if isinstance(token_usage, dict):
|
||||
# Map both the LangChain-canonical names and OpenAI-native names; later
|
||||
# writes win so a more-precise provider-native value can override a
|
||||
# zero placeholder from usage_metadata.
|
||||
candidates = {
|
||||
"input_tokens": ("input_tokens", "prompt_tokens"),
|
||||
"output_tokens": ("output_tokens", "completion_tokens"),
|
||||
"total_tokens": ("total_tokens",),
|
||||
}
|
||||
for canonical, keys in candidates.items():
|
||||
for key in keys:
|
||||
coerced = _coerce_int(token_usage.get(key))
|
||||
if coerced is not None and coerced > 0:
|
||||
out[canonical] = coerced
|
||||
break
|
||||
|
||||
# Derive a missing or implausibly-zero field if the other two are present.
|
||||
# Some providers report ``input_tokens=0`` / ``output_tokens=0`` next to a
|
||||
# real ``total_tokens`` — treat those zeros as missing so we don't silently
|
||||
# store nonsense breakdowns.
|
||||
def _missing_or_zero(key: str) -> bool:
|
||||
return out.get(key) in (None, 0)
|
||||
|
||||
if "total_tokens" not in out and out.get("input_tokens") and out.get("output_tokens"):
|
||||
out["total_tokens"] = out["input_tokens"] + out["output_tokens"]
|
||||
if _missing_or_zero("output_tokens") and out.get("total_tokens") and out.get("input_tokens"):
|
||||
derived = out["total_tokens"] - out["input_tokens"]
|
||||
if derived > 0:
|
||||
out["output_tokens"] = derived
|
||||
if _missing_or_zero("input_tokens") and out.get("total_tokens") and out.get("output_tokens"):
|
||||
derived = out["total_tokens"] - out["output_tokens"]
|
||||
if derived > 0:
|
||||
out["input_tokens"] = derived
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _classify_exception(exc: BaseException) -> tuple[str, str]:
|
||||
"""Return (error_type, error_message). Message is trimmed to 500 chars."""
|
||||
name = exc.__class__.__name__
|
||||
raw = (str(exc) or name).strip()
|
||||
return name, (raw[:500] if len(raw) > 500 else raw)
|
||||
|
||||
|
||||
class LlmMetricsMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Wrap each model call to time it and persist a metric row."""
|
||||
|
||||
def __init__(self, *, store: LlmMetricsStore | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._store_override = store
|
||||
|
||||
def _store(self) -> LlmMetricsStore | None:
|
||||
if self._store_override is not None:
|
||||
return self._store_override
|
||||
try:
|
||||
sf = get_session_factory()
|
||||
except Exception:
|
||||
return None
|
||||
return make_llm_metrics_store(sf)
|
||||
|
||||
def _build_base_record(self, request: ModelRequest) -> LlmMetricRecord:
|
||||
return LlmMetricRecord(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=_now(),
|
||||
user_id=get_effective_user_id(),
|
||||
thread_id=_safe_get_thread_id(request),
|
||||
run_id=_safe_get_run_id(request),
|
||||
agent_name=_safe_get_agent_name(request),
|
||||
model_name=_safe_get_model_name(request),
|
||||
)
|
||||
|
||||
def _finalize_success(self, record: LlmMetricRecord, response: ModelResponse, started: float) -> None:
|
||||
elapsed = max(0.0, time.monotonic() - started)
|
||||
record.duration_ms = int(elapsed * 1000)
|
||||
usage = _extract_usage(response)
|
||||
record.input_tokens = usage.get("input_tokens")
|
||||
record.output_tokens = usage.get("output_tokens")
|
||||
record.total_tokens = usage.get("total_tokens") or (
|
||||
(record.input_tokens or 0) + (record.output_tokens or 0) or None
|
||||
)
|
||||
if record.output_tokens is not None and elapsed > 0:
|
||||
record.tokens_per_sec = round(record.output_tokens / elapsed, 3)
|
||||
record.status = "success"
|
||||
|
||||
def _finalize_error(self, record: LlmMetricRecord, exc: BaseException, started: float) -> None:
|
||||
record.duration_ms = int(max(0.0, time.monotonic() - started) * 1000)
|
||||
record.status = "error"
|
||||
record.error_type, record.error_message = _classify_exception(exc)
|
||||
|
||||
def _flush(self, record: LlmMetricRecord) -> None:
|
||||
store = self._store()
|
||||
if store is None:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if loop is not None and loop.is_running():
|
||||
# Fire-and-forget — never let metric IO block the request path.
|
||||
loop.create_task(store.record(record))
|
||||
else:
|
||||
asyncio.run(store.record(record))
|
||||
except Exception:
|
||||
logger.warning("LlmMetricsMiddleware: failed to flush metric %s", record.id, exc_info=True)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
record = self._build_base_record(request)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = handler(request)
|
||||
except GraphBubbleUp:
|
||||
# LangGraph control flow — not a real LLM failure.
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._finalize_error(record, exc, started)
|
||||
self._flush(record)
|
||||
raise
|
||||
self._finalize_success(record, response, started)
|
||||
self._flush(record)
|
||||
return response
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
record = self._build_base_record(request)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = await handler(request)
|
||||
except GraphBubbleUp:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._finalize_error(record, exc, started)
|
||||
self._flush(record)
|
||||
raise
|
||||
self._finalize_success(record, response, started)
|
||||
self._flush(record)
|
||||
return response
|
||||
+8
@@ -96,6 +96,14 @@ def _build_runtime_middlewares(
|
||||
|
||||
middlewares.append(LLMErrorHandlingMiddleware(app_config=app_config))
|
||||
|
||||
# LLM call metrics — always on, even when the user-facing speed indicator
|
||||
# is disabled. We append *after* the retry middleware so the metrics
|
||||
# middleware sits deeper in the wrap chain and observes individual provider
|
||||
# attempts (including the failed ones).
|
||||
from deerflow.agents.middlewares.llm_metrics_middleware import LlmMetricsMiddleware
|
||||
|
||||
middlewares.append(LlmMetricsMiddleware())
|
||||
|
||||
# Guardrail middleware (if configured)
|
||||
guardrails_config = app_config.guardrails
|
||||
if guardrails_config.enabled and guardrails_config.provider:
|
||||
|
||||
+6
-1
@@ -45,7 +45,12 @@ class McpServerConfig(BaseModel):
|
||||
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
||||
oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)")
|
||||
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
disabled_tools: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of tool names (without server-name prefix) to hide from the agent",
|
||||
alias="disabledTools",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillStateConfig(BaseModel):
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""System-wide runtime settings editable from the admin UI.
|
||||
|
||||
Backed by a single JSON file under the DeerFlow runtime home (default
|
||||
``.deer-flow/system_settings.json``). Unlike :mod:`extensions_config` this
|
||||
file is **not** part of the project source tree — it is per-deployment
|
||||
state, and it stays writable at runtime.
|
||||
|
||||
The only feature stored here at the moment is the **prompt prefix toggle**
|
||||
shown in the chat input: an administrator can configure a label and a
|
||||
prefix string, and any user can flip the toggle on/off; when enabled, the
|
||||
prefix is prepended to the first human message of a new thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SETTINGS_FILE = "system_settings.json"
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class PromptPrefixSettings(BaseModel):
|
||||
"""Admin-configurable first-message prompt prefix."""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all")
|
||||
switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input")
|
||||
prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
def _settings_path() -> Path:
|
||||
home = runtime_home()
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
return home / _SETTINGS_FILE
|
||||
|
||||
|
||||
def load_system_settings() -> SystemSettings:
|
||||
"""Read the on-disk system settings, returning defaults when missing."""
|
||||
path = _settings_path()
|
||||
if not path.is_file():
|
||||
return SystemSettings()
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("System settings file %s is not a JSON object; ignoring", path)
|
||||
return SystemSettings()
|
||||
return SystemSettings.model_validate(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read system settings from %s: %s", path, exc)
|
||||
return SystemSettings()
|
||||
|
||||
|
||||
def save_system_settings(settings: SystemSettings) -> SystemSettings:
|
||||
"""Persist the settings document atomically, returning the saved value."""
|
||||
path = _settings_path()
|
||||
payload = settings.model_dump(mode="json")
|
||||
with _lock:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
return settings
|
||||
@@ -121,6 +121,25 @@ async def get_mcp_tools() -> list[BaseTool]:
|
||||
|
||||
# Get all tools from all servers
|
||||
tools = await client.get_tools()
|
||||
|
||||
# Filter out tools listed in each server's `disabled_tools`. Tool names from
|
||||
# MultiServerMCPClient are prefixed with the server name (because we pass
|
||||
# `tool_name_prefix=True`), so we compare against `<server>_<tool>` here.
|
||||
disabled_full_names: set[str] = set()
|
||||
for server_name, server_cfg in extensions_config.get_enabled_mcp_servers().items():
|
||||
for tool_name in server_cfg.disabled_tools or []:
|
||||
name = (tool_name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
disabled_full_names.add(f"{server_name}_{name}")
|
||||
disabled_full_names.add(name)
|
||||
if disabled_full_names:
|
||||
kept = [t for t in tools if t.name not in disabled_full_names]
|
||||
removed = len(tools) - len(kept)
|
||||
if removed:
|
||||
logger.info(f"Filtered out {removed} disabled MCP tool(s)")
|
||||
tools = kept
|
||||
|
||||
logger.info(f"Successfully loaded {len(tools)} tool(s) from MCP servers")
|
||||
|
||||
# Patch tools to support sync invocation, as deerflow client streams synchronously
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""Persistence layer for per-LLM-call metrics.
|
||||
|
||||
Each completed LLM invocation (success or failure) writes a single row so
|
||||
administrators can audit usage and speed. Designed to be cheap to write —
|
||||
the middleware fires once per provider call and never blocks streaming.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.llm_metrics.base import LlmMetricRecord, LlmMetricsStore
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.llm_metrics.sql import SqlLlmMetricsStore
|
||||
|
||||
__all__ = [
|
||||
"LlmCallMetricRow",
|
||||
"LlmMetricRecord",
|
||||
"LlmMetricsStore",
|
||||
"SqlLlmMetricsStore",
|
||||
"make_llm_metrics_store",
|
||||
]
|
||||
|
||||
|
||||
def make_llm_metrics_store(session_factory) -> LlmMetricsStore | None:
|
||||
"""Return a SQL-backed metrics store, or ``None`` when no DB is configured."""
|
||||
if session_factory is None:
|
||||
return None
|
||||
return SqlLlmMetricsStore(session_factory)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Abstract repository contract for LLM call metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmMetricRecord:
|
||||
"""In-memory shape of a metric row before persistence."""
|
||||
|
||||
id: str
|
||||
created_at: datetime
|
||||
user_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
run_id: str | None = None
|
||||
agent_name: str | None = None
|
||||
model_name: str | None = None
|
||||
duration_ms: int = 0
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
tokens_per_sec: float | None = None
|
||||
status: str = "success"
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmMetricsQuery:
|
||||
"""Filter set for the admin list / export endpoints."""
|
||||
|
||||
user_id: str | None = None
|
||||
model_name: str | None = None
|
||||
status: str | None = None
|
||||
since: datetime | None = None
|
||||
until: datetime | None = None
|
||||
limit: int = 100
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class LlmMetricsStore(ABC):
|
||||
"""Persistence contract for the metrics middleware + admin API."""
|
||||
|
||||
@abstractmethod
|
||||
async def record(self, metric: LlmMetricRecord) -> None:
|
||||
"""Persist one metric row. Must never raise on best-effort writes."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list(self, query: LlmMetricsQuery) -> list[dict[str, Any]]:
|
||||
"""Return the matching rows (newest first), paginated by ``query``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count(self, query: LlmMetricsQuery) -> int:
|
||||
"""Return the unpaginated row count for ``query``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def iter_all(self, query: LlmMetricsQuery):
|
||||
"""Yield rows matching ``query`` without pagination — used by export."""
|
||||
raise NotImplementedError
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""ORM model for per-LLM-call metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class LlmCallMetricRow(Base):
|
||||
"""One row per completed (or failed) LLM provider invocation."""
|
||||
|
||||
__tablename__ = "llm_call_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# Wall-clock time the LLM call ended. Indexed because admin views always
|
||||
# sort/filter by recency.
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True)
|
||||
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
model_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
|
||||
# Wall-clock duration of the provider invocation in milliseconds. Always set.
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
input_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
output_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
total_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Output tokens / seconds; null when there is no completion (errors).
|
||||
tokens_per_sec: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
# "success" or "error". Errors store the classification and original message
|
||||
# so admins can scan for transient vs auth/quota issues.
|
||||
status: Mapped[str] = mapped_column(String(16), default="success", index=True)
|
||||
error_type: Mapped[str | None] = mapped_column(String(64))
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
||||
"user_id": self.user_id,
|
||||
"thread_id": self.thread_id,
|
||||
"run_id": self.run_id,
|
||||
"agent_name": self.agent_name,
|
||||
"model_name": self.model_name,
|
||||
"duration_ms": self.duration_ms,
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
"tokens_per_sec": self.tokens_per_sec,
|
||||
"status": self.status,
|
||||
"error_type": self.error_type,
|
||||
"error_message": self.error_message,
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""SQLAlchemy-backed LLM metrics repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.llm_metrics.base import LlmMetricRecord, LlmMetricsQuery, LlmMetricsStore
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _row_to_dict(row: LlmCallMetricRow) -> dict[str, Any]:
|
||||
return row.to_dict()
|
||||
|
||||
|
||||
def _normalize_created_at(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
class SqlLlmMetricsStore(LlmMetricsStore):
|
||||
"""SQLite/Postgres-backed implementation; writes are intentionally fire-and-forget."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def record(self, metric: LlmMetricRecord) -> None:
|
||||
row = LlmCallMetricRow(
|
||||
id=metric.id,
|
||||
created_at=_normalize_created_at(metric.created_at),
|
||||
user_id=metric.user_id,
|
||||
thread_id=metric.thread_id,
|
||||
run_id=metric.run_id,
|
||||
agent_name=metric.agent_name,
|
||||
model_name=metric.model_name,
|
||||
duration_ms=metric.duration_ms,
|
||||
input_tokens=metric.input_tokens,
|
||||
output_tokens=metric.output_tokens,
|
||||
total_tokens=metric.total_tokens,
|
||||
tokens_per_sec=metric.tokens_per_sec,
|
||||
status=metric.status,
|
||||
error_type=metric.error_type,
|
||||
error_message=metric.error_message,
|
||||
)
|
||||
try:
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# Metrics are best-effort. Never let a persistence failure surface
|
||||
# to the agent runtime — log and move on.
|
||||
logger.warning("Failed to persist llm_call_metrics row %s", metric.id, exc_info=True)
|
||||
|
||||
def _apply_filters(self, stmt, query: LlmMetricsQuery):
|
||||
if query.user_id:
|
||||
stmt = stmt.where(LlmCallMetricRow.user_id == query.user_id)
|
||||
if query.model_name:
|
||||
stmt = stmt.where(LlmCallMetricRow.model_name == query.model_name)
|
||||
if query.status:
|
||||
stmt = stmt.where(LlmCallMetricRow.status == query.status)
|
||||
if query.since is not None:
|
||||
stmt = stmt.where(LlmCallMetricRow.created_at >= _normalize_created_at(query.since))
|
||||
if query.until is not None:
|
||||
stmt = stmt.where(LlmCallMetricRow.created_at <= _normalize_created_at(query.until))
|
||||
return stmt
|
||||
|
||||
async def list(self, query: LlmMetricsQuery) -> list[dict[str, Any]]:
|
||||
stmt = select(LlmCallMetricRow)
|
||||
stmt = self._apply_filters(stmt, query)
|
||||
stmt = stmt.order_by(desc(LlmCallMetricRow.created_at)).limit(max(1, min(query.limit, 1000))).offset(max(0, query.offset))
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def count(self, query: LlmMetricsQuery) -> int:
|
||||
stmt = select(func.count()).select_from(LlmCallMetricRow)
|
||||
stmt = self._apply_filters(stmt, query)
|
||||
async with self._sf() as session:
|
||||
return (await session.scalar(stmt)) or 0
|
||||
|
||||
async def iter_all(self, query: LlmMetricsQuery) -> AsyncIterator[dict[str, Any]]:
|
||||
# Page through the result so the Excel export stays bounded in memory
|
||||
# for large date windows.
|
||||
page_size = 500
|
||||
offset = 0
|
||||
while True:
|
||||
page_query = LlmMetricsQuery(
|
||||
user_id=query.user_id,
|
||||
model_name=query.model_name,
|
||||
status=query.status,
|
||||
since=query.since,
|
||||
until=query.until,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
)
|
||||
page = await self.list(page_query)
|
||||
if not page:
|
||||
return
|
||||
for item in page:
|
||||
yield item
|
||||
if len(page) < page_size:
|
||||
return
|
||||
offset += page_size
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
"""Create llm_call_metrics table.
|
||||
|
||||
Revision ID: 20260514_02
|
||||
Revises: 20260514_01
|
||||
Create Date: 2026-05-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260514_02"
|
||||
down_revision = "20260514_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("llm_call_metrics"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"llm_call_metrics",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.String(64), nullable=True),
|
||||
sa.Column("thread_id", sa.String(64), nullable=True),
|
||||
sa.Column("run_id", sa.String(64), nullable=True),
|
||||
sa.Column("agent_name", sa.String(128), nullable=True),
|
||||
sa.Column("model_name", sa.String(128), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("total_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("tokens_per_sec", sa.Float(), nullable=True),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="success"),
|
||||
sa.Column("error_type", sa.String(64), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_llm_call_metrics_created_at", "llm_call_metrics", ["created_at"])
|
||||
op.create_index("ix_llm_call_metrics_user_id", "llm_call_metrics", ["user_id"])
|
||||
op.create_index("ix_llm_call_metrics_model_name", "llm_call_metrics", ["model_name"])
|
||||
op.create_index("ix_llm_call_metrics_status", "llm_call_metrics", ["status"])
|
||||
op.create_index("ix_llm_call_metrics_thread_id", "llm_call_metrics", ["thread_id"])
|
||||
op.create_index("ix_llm_call_metrics_run_id", "llm_call_metrics", ["run_id"])
|
||||
op.create_index("ix_llm_call_metrics_agent_name", "llm_call_metrics", ["agent_name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("llm_call_metrics"):
|
||||
op.drop_table("llm_call_metrics")
|
||||
+4
@@ -17,8 +17,10 @@ there is no matching entity directory.
|
||||
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskDeliveryProfileRow,
|
||||
@@ -34,7 +36,9 @@ from deerflow.persistence.user.model import UserRow
|
||||
__all__ = [
|
||||
"FeedbackRow",
|
||||
"AgentRow",
|
||||
"LlmCallMetricRow",
|
||||
"NotificationRow",
|
||||
"RecommendedQuestionRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
"ScheduledTaskDeliveryProfileRow",
|
||||
|
||||
+10
-2
@@ -247,7 +247,11 @@ class ScheduledTaskService:
|
||||
accessible = await self._accessible_task(user_id, task_id)
|
||||
if accessible is None:
|
||||
return []
|
||||
return await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
|
||||
runs = await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
|
||||
can_edit = accessible.get("role") == "owner"
|
||||
for run in runs:
|
||||
run["can_edit"] = can_edit
|
||||
return runs
|
||||
|
||||
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
run = await self.store.get_run(run_id, user_id)
|
||||
@@ -316,7 +320,7 @@ class ScheduledTaskService:
|
||||
except ValueError as exc:
|
||||
raise PermissionError("Refusing to edit file outside the run archive") from exc
|
||||
|
||||
archive_path.write_text(new_content, encoding="utf-8")
|
||||
archive_path.write_text(new_content, encoding="utf-8", newline="")
|
||||
new_size = archive_path.stat().st_size
|
||||
|
||||
# Reflect the new size in the run row so subsequent list/preview calls
|
||||
@@ -342,6 +346,10 @@ class ScheduledTaskService:
|
||||
shutil.rmtree(archive_dir)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
|
||||
return True
|
||||
candidate = await self.store.get_run_any(run_id)
|
||||
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
|
||||
raise PermissionError("Only the run owner may delete scheduled task runs")
|
||||
return deleted
|
||||
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Tests for the LLM call metrics middleware + repository.
|
||||
|
||||
Covers:
|
||||
- Successful calls emit a row with duration, token counts and tokens_per_sec.
|
||||
- Failed calls record the exception class as ``error_type`` plus a trimmed
|
||||
``error_message``.
|
||||
- The middleware never blocks on a backend exception (best-effort writes).
|
||||
- The SQL store applies filters and pagination for the admin list endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from langchain_core.messages import AIMessage
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.agents.middlewares.llm_metrics_middleware import (
|
||||
LlmMetricsMiddleware,
|
||||
_extract_usage,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.llm_metrics import SqlLlmMetricsStore
|
||||
from deerflow.persistence.llm_metrics.base import LlmMetricRecord, LlmMetricsQuery
|
||||
|
||||
|
||||
class _RecordingStore:
|
||||
"""Minimal in-memory store used to assert what the middleware persists."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: list[LlmMetricRecord] = []
|
||||
|
||||
async def record(self, metric: LlmMetricRecord) -> None:
|
||||
self.records.append(metric)
|
||||
|
||||
async def list(self, query: LlmMetricsQuery):
|
||||
return [r.__dict__ for r in self.records]
|
||||
|
||||
async def count(self, query: LlmMetricsQuery) -> int:
|
||||
return len(self.records)
|
||||
|
||||
async def iter_all(self, query: LlmMetricsQuery):
|
||||
for record in self.records:
|
||||
yield record.__dict__
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sql_store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'metrics.db'}")
|
||||
async with engine.begin() as conn:
|
||||
# Importing the package registers the LlmCallMetricRow with Base.metadata.
|
||||
import deerflow.persistence.llm_metrics.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
store = SqlLlmMetricsStore(sf)
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _make_request():
|
||||
return type(
|
||||
"FakeRequest",
|
||||
(),
|
||||
{
|
||||
"model": type("FakeModel", (), {"model_name": "gpt-test"}),
|
||||
"runtime": type(
|
||||
"FakeRuntime",
|
||||
(),
|
||||
{
|
||||
"config": {"configurable": {"thread_id": "t1"}},
|
||||
"context": {"agent_name": "lead_agent", "run_id": "r1"},
|
||||
},
|
||||
),
|
||||
"state": {"thread_id": "t1"},
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def test_middleware_records_success_with_speed():
|
||||
import time as _time
|
||||
|
||||
store = _RecordingStore()
|
||||
mw = LlmMetricsMiddleware(store=store)
|
||||
response = AIMessage(
|
||||
content="hi",
|
||||
usage_metadata={"input_tokens": 5, "output_tokens": 20, "total_tokens": 25},
|
||||
)
|
||||
|
||||
def slow_handler(_req):
|
||||
# Force a measurable elapsed window so tokens_per_sec is populated.
|
||||
# 50ms is comfortably above Windows monotonic-clock granularity.
|
||||
_time.sleep(0.05)
|
||||
return response
|
||||
|
||||
result = mw.wrap_model_call(_make_request(), slow_handler)
|
||||
|
||||
assert result is response
|
||||
# awrap_model_call uses a fire-and-forget task; here the sync path runs
|
||||
# asyncio.run synchronously, so the record landed already.
|
||||
assert len(store.records) == 1
|
||||
record = store.records[0]
|
||||
assert record.status == "success"
|
||||
assert record.model_name == "gpt-test"
|
||||
assert record.thread_id == "t1"
|
||||
assert record.run_id == "r1"
|
||||
assert record.input_tokens == 5
|
||||
assert record.output_tokens == 20
|
||||
assert record.total_tokens == 25
|
||||
# tokens_per_sec may be very high in tests because the handler is
|
||||
# ~instant; we only assert that the field is populated.
|
||||
assert record.tokens_per_sec is not None
|
||||
assert record.duration_ms >= 0
|
||||
|
||||
|
||||
def test_middleware_records_error_with_classification():
|
||||
store = _RecordingStore()
|
||||
mw = LlmMetricsMiddleware(store=store)
|
||||
|
||||
class BoomError(RuntimeError):
|
||||
pass
|
||||
|
||||
def explode(_req):
|
||||
raise BoomError("provider exploded")
|
||||
|
||||
with pytest.raises(BoomError):
|
||||
mw.wrap_model_call(_make_request(), explode)
|
||||
|
||||
assert len(store.records) == 1
|
||||
record = store.records[0]
|
||||
assert record.status == "error"
|
||||
assert record.error_type == "BoomError"
|
||||
assert record.error_message == "provider exploded"
|
||||
assert record.tokens_per_sec is None
|
||||
|
||||
|
||||
def test_middleware_swallows_store_errors():
|
||||
"""A persistence outage must not break the agent runtime."""
|
||||
|
||||
class FaultyStore:
|
||||
async def record(self, metric):
|
||||
raise RuntimeError("disk gone")
|
||||
|
||||
async def list(self, q):
|
||||
return []
|
||||
|
||||
async def count(self, q):
|
||||
return 0
|
||||
|
||||
async def iter_all(self, q):
|
||||
if False:
|
||||
yield {}
|
||||
|
||||
mw = LlmMetricsMiddleware(store=FaultyStore())
|
||||
# The wrap returns normally even though _flush would raise; the call below
|
||||
# must not raise.
|
||||
response = AIMessage(
|
||||
content="ok",
|
||||
usage_metadata={"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
)
|
||||
out = mw.wrap_model_call(_make_request(), lambda req: response)
|
||||
assert out is response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_store_round_trip(sql_store):
|
||||
base = datetime.now(UTC)
|
||||
for i in range(3):
|
||||
await sql_store.record(
|
||||
LlmMetricRecord(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=base - timedelta(minutes=i),
|
||||
user_id="alice",
|
||||
model_name="gpt-test" if i % 2 == 0 else "gpt-other",
|
||||
duration_ms=100 * (i + 1),
|
||||
output_tokens=10 * (i + 1),
|
||||
tokens_per_sec=10.0 * (i + 1),
|
||||
status="success",
|
||||
)
|
||||
)
|
||||
rows = await sql_store.list(LlmMetricsQuery(limit=10))
|
||||
assert len(rows) == 3
|
||||
# Newest first.
|
||||
assert rows[0]["duration_ms"] == 100
|
||||
|
||||
filtered = await sql_store.list(LlmMetricsQuery(model_name="gpt-test", limit=10))
|
||||
assert all(r["model_name"] == "gpt-test" for r in filtered)
|
||||
assert len(filtered) == 2
|
||||
|
||||
assert await sql_store.count(LlmMetricsQuery(model_name="gpt-test")) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_store_filters_errors_only(sql_store):
|
||||
base = datetime.now(UTC)
|
||||
await sql_store.record(
|
||||
LlmMetricRecord(id=uuid.uuid4().hex, created_at=base, status="success", duration_ms=10, output_tokens=1)
|
||||
)
|
||||
await sql_store.record(
|
||||
LlmMetricRecord(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=base,
|
||||
status="error",
|
||||
duration_ms=5,
|
||||
error_type="APITimeoutError",
|
||||
error_message="timed out",
|
||||
)
|
||||
)
|
||||
only_errors = await sql_store.list(LlmMetricsQuery(status="error", limit=10))
|
||||
assert len(only_errors) == 1
|
||||
assert only_errors[0]["error_type"] == "APITimeoutError"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_store_iter_all_paginates(sql_store):
|
||||
base = datetime.now(UTC)
|
||||
for i in range(1200):
|
||||
await sql_store.record(
|
||||
LlmMetricRecord(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=base - timedelta(seconds=i),
|
||||
duration_ms=1,
|
||||
status="success",
|
||||
)
|
||||
)
|
||||
seen = 0
|
||||
async for _ in sql_store.iter_all(LlmMetricsQuery()):
|
||||
seen += 1
|
||||
assert seen == 1200
|
||||
|
||||
|
||||
def test_extract_usage_prefers_usage_metadata():
|
||||
response = AIMessage(
|
||||
content="x",
|
||||
usage_metadata={"input_tokens": 3, "output_tokens": 7, "total_tokens": 10},
|
||||
)
|
||||
out = _extract_usage(response)
|
||||
assert out == {"input_tokens": 3, "output_tokens": 7, "total_tokens": 10}
|
||||
|
||||
|
||||
def test_extract_usage_falls_back_to_response_metadata_token_usage():
|
||||
"""DeepSeek / vLLM-style: only total in usage_metadata, breakdown elsewhere."""
|
||||
response = AIMessage(
|
||||
content="x",
|
||||
usage_metadata={"input_tokens": 0, "output_tokens": 0, "total_tokens": 11093},
|
||||
response_metadata={
|
||||
"token_usage": {
|
||||
"prompt_tokens": 9000,
|
||||
"completion_tokens": 2093,
|
||||
"total_tokens": 11093,
|
||||
},
|
||||
},
|
||||
)
|
||||
out = _extract_usage(response)
|
||||
assert out["input_tokens"] == 9000
|
||||
assert out["output_tokens"] == 2093
|
||||
assert out["total_tokens"] == 11093
|
||||
|
||||
|
||||
def test_extract_usage_unwraps_model_response():
|
||||
"""The handler hands us a ModelResponse dataclass, not a bare AIMessage."""
|
||||
from langchain.agents.middleware.types import ModelResponse
|
||||
|
||||
inner = AIMessage(
|
||||
content="hi",
|
||||
usage_metadata={"input_tokens": 7, "output_tokens": 9, "total_tokens": 16},
|
||||
)
|
||||
response = ModelResponse(result=[inner])
|
||||
out = _extract_usage(response)
|
||||
assert out == {"input_tokens": 7, "output_tokens": 9, "total_tokens": 16}
|
||||
|
||||
|
||||
def test_extract_usage_unwraps_extended_model_response():
|
||||
"""ExtendedModelResponse wraps a ModelResponse in a ``model_response`` field."""
|
||||
from langchain.agents.middleware.types import ExtendedModelResponse, ModelResponse
|
||||
|
||||
inner = AIMessage(
|
||||
content="hi",
|
||||
usage_metadata={"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
|
||||
)
|
||||
extended = ExtendedModelResponse(model_response=ModelResponse(result=[inner]))
|
||||
out = _extract_usage(extended)
|
||||
assert out["output_tokens"] == 2
|
||||
|
||||
|
||||
def test_extract_usage_derives_missing_field_when_two_present():
|
||||
response = AIMessage(
|
||||
content="x",
|
||||
usage_metadata={"input_tokens": 9000, "output_tokens": 0, "total_tokens": 11093},
|
||||
)
|
||||
out = _extract_usage(response)
|
||||
# output should be derived as total - input = 2093.
|
||||
assert out["output_tokens"] == 2093
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_middleware_records_success():
|
||||
store = _RecordingStore()
|
||||
mw = LlmMetricsMiddleware(store=store)
|
||||
|
||||
async def handler(_req):
|
||||
return AIMessage(
|
||||
content="hi",
|
||||
usage_metadata={"input_tokens": 1, "output_tokens": 4, "total_tokens": 5},
|
||||
)
|
||||
|
||||
response = await mw.awrap_model_call(_make_request(), handler)
|
||||
assert isinstance(response, AIMessage)
|
||||
|
||||
# awrap_model_call schedules the write as an asyncio.task; give the loop a
|
||||
# chance to drain.
|
||||
await asyncio.sleep(0)
|
||||
assert len(store.records) == 1
|
||||
assert store.records[0].status == "success"
|
||||
assert store.records[0].output_tokens == 4
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for the admin-configurable prompt-prefix toggle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from app.gateway.services import _prepend_prompt_prefix, _prompt_prefix_opted_in
|
||||
from deerflow.config import system_settings as ss
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Redirect ``runtime_home()`` to a per-test tmp dir."""
|
||||
|
||||
def fake_home() -> Path:
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ss, "runtime_home", fake_home)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_load_returns_defaults_when_missing(tmp_runtime_home: Path) -> None:
|
||||
settings = ss.load_system_settings()
|
||||
assert settings.prompt_prefix.enabled is False
|
||||
assert settings.prompt_prefix.prompt_prefix == ""
|
||||
|
||||
|
||||
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
|
||||
settings = ss.SystemSettings(
|
||||
prompt_prefix=ss.PromptPrefixSettings(
|
||||
enabled=True,
|
||||
switch_label="加入立场前缀",
|
||||
prompt_prefix="你需要站在中方角度",
|
||||
)
|
||||
)
|
||||
ss.save_system_settings(settings)
|
||||
|
||||
file_path = tmp_runtime_home / "system_settings.json"
|
||||
assert file_path.is_file()
|
||||
saved = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
assert saved["prompt_prefix"]["prompt_prefix"] == "你需要站在中方角度"
|
||||
|
||||
reloaded = ss.load_system_settings()
|
||||
assert reloaded.prompt_prefix.enabled is True
|
||||
assert reloaded.prompt_prefix.switch_label == "加入立场前缀"
|
||||
assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度"
|
||||
|
||||
|
||||
def test_prepend_prefix_on_string_content() -> None:
|
||||
graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]}
|
||||
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
|
||||
msg = out["messages"][0]
|
||||
assert isinstance(msg, HumanMessage)
|
||||
assert msg.content == "你需要站在中方角度\n\n评价一下中美贸易"
|
||||
|
||||
|
||||
def test_prepend_prefix_on_block_content() -> None:
|
||||
graph_input = {
|
||||
"messages": [
|
||||
HumanMessage(content=[{"type": "text", "text": "评价一下中美贸易"}])
|
||||
]
|
||||
}
|
||||
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
|
||||
blocks = out["messages"][0].content
|
||||
assert isinstance(blocks, list)
|
||||
assert blocks[0] == {"type": "text", "text": "你需要站在中方角度"}
|
||||
assert blocks[1] == {"type": "text", "text": "评价一下中美贸易"}
|
||||
|
||||
|
||||
def test_prepend_prefix_only_touches_first_human() -> None:
|
||||
graph_input = {
|
||||
"messages": [
|
||||
AIMessage(content="hi"),
|
||||
HumanMessage(content="q1"),
|
||||
HumanMessage(content="q2"),
|
||||
]
|
||||
}
|
||||
out = _prepend_prompt_prefix(graph_input, "PREFIX")
|
||||
# first human is index 1; index 2 must remain untouched
|
||||
assert out["messages"][1].content == "PREFIX\n\nq1"
|
||||
assert out["messages"][2].content == "q2"
|
||||
|
||||
|
||||
def test_prepend_prefix_no_messages_is_noop() -> None:
|
||||
assert _prepend_prompt_prefix({"messages": []}, "X") == {"messages": []}
|
||||
assert _prepend_prompt_prefix({}, "X") == {}
|
||||
|
||||
|
||||
def test_opted_in_via_context() -> None:
|
||||
body = SimpleNamespace(context={"prompt_prefix_enabled": True}, metadata=None)
|
||||
assert _prompt_prefix_opted_in(body) is True
|
||||
|
||||
|
||||
def test_opted_in_via_metadata() -> None:
|
||||
body = SimpleNamespace(context=None, metadata={"prompt_prefix_enabled": True})
|
||||
assert _prompt_prefix_opted_in(body) is True
|
||||
|
||||
|
||||
def test_not_opted_in_by_default() -> None:
|
||||
body = SimpleNamespace(context=None, metadata=None)
|
||||
assert _prompt_prefix_opted_in(body) is False
|
||||
body2 = SimpleNamespace(context={"mode": "ultra"}, metadata={})
|
||||
assert _prompt_prefix_opted_in(body2) is False
|
||||
@@ -147,13 +147,23 @@ async def test_subscriber_can_read_runs_and_get_run(store):
|
||||
)
|
||||
assert run is not None
|
||||
|
||||
alice_runs = await service.list_runs("alice", "t1", limit=10)
|
||||
assert alice_runs[0]["can_edit"] is True
|
||||
|
||||
# Bob (subscriber) sees runs on the task.
|
||||
bob_runs = await service.list_runs("bob", "t1", limit=10)
|
||||
assert [r["id"] for r in bob_runs] == ["run-1"]
|
||||
assert bob_runs[0]["can_edit"] is False
|
||||
|
||||
# Bob can fetch the run by id.
|
||||
fetched = await service.get_run("bob", "run-1")
|
||||
assert fetched is not None and fetched["id"] == "run-1"
|
||||
assert fetched["can_edit"] is False
|
||||
|
||||
# Bob can read subscribed records, but cannot delete Alice's record.
|
||||
with pytest.raises(PermissionError):
|
||||
await service.delete_run("bob", "run-1")
|
||||
assert await store.get_run("run-1", "alice") is not None
|
||||
|
||||
# Carol (no subscription) cannot.
|
||||
assert await service.list_runs("carol", "t1", limit=10) == []
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for the username-based login domain handling.
|
||||
|
||||
Verifies that:
|
||||
- The default mail domain is ``cm.com`` (with env-var override still honoured).
|
||||
- ``_normalize_username_local_part`` distinguishes raw-email vs username input.
|
||||
- The login flow falls back to a legacy-domain lookup so the same username
|
||||
keeps mapping to the same account after the default domain changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.auth.repositories.base import UserRepository
|
||||
from app.gateway.routers.auth import (
|
||||
_normalize_username_local_part,
|
||||
_username_to_email,
|
||||
)
|
||||
|
||||
|
||||
class FakeUserRepository(UserRepository):
|
||||
"""In-memory repo just enough to exercise the local-part lookup."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[str, User] = {}
|
||||
self.create_calls: list[User] = []
|
||||
|
||||
async def create_user(self, user: User) -> User:
|
||||
for existing in self.rows.values():
|
||||
if existing.email == user.email:
|
||||
raise ValueError(f"Email already registered: {user.email}")
|
||||
u = User(
|
||||
id=user.id or uuid4(),
|
||||
email=user.email,
|
||||
password_hash=user.password_hash,
|
||||
system_role=user.system_role,
|
||||
created_at=user.created_at or datetime.now(UTC),
|
||||
oauth_provider=user.oauth_provider,
|
||||
oauth_id=user.oauth_id,
|
||||
needs_setup=user.needs_setup,
|
||||
token_version=user.token_version,
|
||||
)
|
||||
self.rows[str(u.id)] = u
|
||||
self.create_calls.append(u)
|
||||
return u
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> User | None:
|
||||
return self.rows.get(user_id)
|
||||
|
||||
async def get_user_by_email(self, email: str) -> User | None:
|
||||
for user in self.rows.values():
|
||||
if user.email == email:
|
||||
return user
|
||||
return None
|
||||
|
||||
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
|
||||
prefix = f"{(local_part or '').strip().lower()}@"
|
||||
candidates = [u for u in self.rows.values() if u.email.lower().startswith(prefix)]
|
||||
candidates.sort(key=lambda u: u.created_at)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
async def update_user(self, user: User) -> User:
|
||||
self.rows[str(user.id)] = user
|
||||
return user
|
||||
|
||||
async def count_users(self) -> int:
|
||||
return len(self.rows)
|
||||
|
||||
async def count_admin_users(self) -> int:
|
||||
return sum(1 for u in self.rows.values() if u.system_role == "admin")
|
||||
|
||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
||||
return None
|
||||
|
||||
|
||||
def _seed(repo: FakeUserRepository, email: str, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email=email,
|
||||
password_hash=None,
|
||||
system_role=role, # type: ignore[arg-type]
|
||||
created_at=datetime.now(UTC),
|
||||
needs_setup=False,
|
||||
)
|
||||
repo.rows[str(user.id)] = user
|
||||
return user
|
||||
|
||||
|
||||
def test_normalize_returns_none_for_explicit_email() -> None:
|
||||
assert _normalize_username_local_part("alice@old.com") is None
|
||||
|
||||
|
||||
def test_normalize_lowercases_and_keeps_safe_chars() -> None:
|
||||
assert _normalize_username_local_part("Alice_Smith.1") == "alice_smith.1"
|
||||
|
||||
|
||||
def test_normalize_strips_unsafe_chars() -> None:
|
||||
assert _normalize_username_local_part("hi there!") == "hi-there"
|
||||
|
||||
|
||||
def test_username_to_email_uses_cm_com_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", raising=False)
|
||||
assert _username_to_email("alice") == "alice@cm.com"
|
||||
|
||||
|
||||
def test_username_to_email_honours_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "example.org")
|
||||
assert _username_to_email("alice") == "alice@example.org"
|
||||
|
||||
|
||||
def test_username_to_email_passes_explicit_email_through() -> None:
|
||||
assert _username_to_email("bob@legacy.io") == "bob@legacy.io"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_part_lookup_finds_legacy_domain_user() -> None:
|
||||
"""Same username under legacy domain should match the new lookup."""
|
||||
repo = FakeUserRepository()
|
||||
_seed(repo, "alice@local.deerflow")
|
||||
legacy = await repo.find_user_by_email_local_part("alice")
|
||||
assert legacy is not None
|
||||
assert legacy.email == "alice@local.deerflow"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_part_lookup_returns_oldest_when_collision() -> None:
|
||||
"""When the same local part exists under multiple domains, prefer the
|
||||
oldest row so re-logins land on the original account."""
|
||||
repo = FakeUserRepository()
|
||||
older = _seed(repo, "alice@local.deerflow")
|
||||
older.created_at = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
newer = _seed(repo, "alice@cm.com")
|
||||
newer.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
match = await repo.find_user_by_email_local_part("alice")
|
||||
assert match is not None
|
||||
assert match.email == "alice@local.deerflow"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_part_lookup_misses_unknown_username() -> None:
|
||||
repo = FakeUserRepository()
|
||||
_seed(repo, "alice@cm.com")
|
||||
assert await repo.find_user_by_email_local_part("bob") is None
|
||||
Reference in new issue
Block a user