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({
|
const BreadcrumbLink = React.forwardRef<
|
||||||
asChild,
|
HTMLAnchorElement,
|
||||||
className,
|
React.ComponentProps<"a"> & {
|
||||||
...props
|
asChild?: boolean;
|
||||||
}: React.ComponentProps<"a"> & {
|
}
|
||||||
asChild?: boolean;
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
}) {
|
const Comp = asChild ? Slot : "a";
|
||||||
const Comp = asChild ? Slot : "a";
|
|
||||||
|
return (
|
||||||
return (
|
<Comp
|
||||||
<Comp
|
ref={ref}
|
||||||
data-slot="breadcrumb-link"
|
data-slot="breadcrumb-link"
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -37,27 +37,27 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
const Button = React.forwardRef<
|
||||||
className,
|
HTMLButtonElement,
|
||||||
variant = "default",
|
React.ComponentProps<"button"> &
|
||||||
size = "default",
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild = false,
|
asChild?: boolean;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<"button"> &
|
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||||
VariantProps<typeof buttonVariants> & {
|
const Comp = asChild ? Slot : "button";
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
return (
|
||||||
const Comp = asChild ? Slot : "button";
|
<Comp
|
||||||
|
ref={ref}
|
||||||
return (
|
data-slot="button"
|
||||||
<Comp
|
{...(variant !== undefined && { "data-variant": variant })}
|
||||||
data-slot="button"
|
{...(size !== undefined && { "data-size": size })}
|
||||||
{...(variant !== undefined && { "data-variant": variant })}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...(size !== undefined && { "data-size": size })}
|
{...props}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
/>
|
||||||
{...props}
|
);
|
||||||
/>
|
});
|
||||||
);
|
|
||||||
}
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
import * as React from "react";
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -10,28 +11,37 @@ function Collapsible({
|
|||||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleTrigger({
|
const CollapsibleTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<CollapsiblePrimitive.CollapsibleTrigger
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
data-slot="collapsible-trigger"
|
ref={ref}
|
||||||
className={cn("cursor-pointer", className)}
|
data-slot="collapsible-trigger"
|
||||||
{...props}
|
className={cn("cursor-pointer", className)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
function CollapsibleContent({
|
|
||||||
...props
|
CollapsibleTrigger.displayName =
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
CollapsiblePrimitive.CollapsibleTrigger.displayName;
|
||||||
return (
|
|
||||||
<CollapsiblePrimitive.CollapsibleContent
|
const CollapsibleContent = React.forwardRef<
|
||||||
data-slot="collapsible-content"
|
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||||
{...props}
|
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||||
/>
|
>(({ ...props }, ref) => {
|
||||||
);
|
return (
|
||||||
}
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
ref={ref}
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CollapsibleContent.displayName =
|
||||||
|
CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
@@ -12,11 +12,20 @@ function Dialog({
|
|||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
const DialogTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return (
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
@@ -24,61 +33,68 @@ function DialogPortal({
|
|||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
const DialogClose = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||||
|
|
||||||
function DialogOverlay({
|
const DialogOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="dialog-overlay"
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
className={cn(
|
||||||
className,
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function DialogContent({
|
const DialogContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton = true,
|
showCloseButton?: boolean;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||||
showCloseButton?: boolean;
|
return (
|
||||||
}) {
|
<DialogPortal data-slot="dialog-portal">
|
||||||
return (
|
<DialogOverlay />
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPrimitive.Content
|
||||||
<DialogOverlay />
|
ref={ref}
|
||||||
<DialogPrimitive.Content
|
data-slot="dialog-content"
|
||||||
data-slot="dialog-content"
|
className={cn(
|
||||||
className={cn(
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
{showCloseButton && (
|
||||||
{showCloseButton && (
|
<DialogPrimitive.Close
|
||||||
<DialogPrimitive.Close
|
data-slot="dialog-close"
|
||||||
data-slot="dialog-close"
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
>
|
||||||
>
|
<XIcon />
|
||||||
<XIcon />
|
<span className="sr-only">Close</span>
|
||||||
<span className="sr-only">Close</span>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Close>
|
)}
|
||||||
)}
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Content>
|
</DialogPortal>
|
||||||
</DialogPortal>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
const DropdownMenuTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||||
return (
|
>(({ ...props }, ref) => {
|
||||||
<DropdownMenuPrimitive.Trigger
|
return (
|
||||||
data-slot="dropdown-menu-trigger"
|
<DropdownMenuPrimitive.Trigger
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
data-slot="dropdown-menu-trigger"
|
||||||
);
|
{...props}
|
||||||
}
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function DropdownMenuContent({
|
const DropdownMenuContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
sideOffset = 4,
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
...props
|
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
return (
|
||||||
return (
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Content
|
||||||
<DropdownMenuPrimitive.Content
|
ref={ref}
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
...props
|
...props
|
||||||
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
const DropdownMenuItem = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
inset,
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
variant = "default",
|
inset?: boolean;
|
||||||
...props
|
variant?: "default" | "destructive";
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}
|
||||||
inset?: boolean;
|
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||||
variant?: "default" | "destructive";
|
return (
|
||||||
}) {
|
<DropdownMenuPrimitive.Item
|
||||||
return (
|
ref={ref}
|
||||||
<DropdownMenuPrimitive.Item
|
data-slot="dropdown-menu-item"
|
||||||
data-slot="dropdown-menu-item"
|
data-inset={inset}
|
||||||
data-inset={inset}
|
data-variant={variant}
|
||||||
data-variant={variant}
|
className={cn(
|
||||||
className={cn(
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
@@ -198,45 +205,51 @@ function DropdownMenuSub({
|
|||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
inset,
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
children,
|
inset?: boolean;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
>(({ className, inset, children, ...props }, ref) => {
|
||||||
inset?: boolean;
|
return (
|
||||||
}) {
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
return (
|
ref={ref}
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-inset={inset}
|
||||||
data-inset={inset}
|
className={cn(
|
||||||
className={cn(
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="dropdown-menu-sub-content"
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
className={cn(
|
||||||
className,
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -11,34 +11,42 @@ function HoverCard({
|
|||||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoverCardTrigger({
|
const HoverCardTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
|
||||||
return (
|
>(({ ...props }, ref) => {
|
||||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
return (
|
||||||
);
|
<HoverCardPrimitive.Trigger
|
||||||
}
|
ref={ref}
|
||||||
|
data-slot="hover-card-trigger"
|
||||||
function HoverCardContent({
|
{...props}
|
||||||
className,
|
/>
|
||||||
align = "center",
|
);
|
||||||
sideOffset = 4,
|
});
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
const HoverCardContent = React.forwardRef<
|
||||||
<HoverCardPrimitive.Content
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
data-slot="hover-card-content"
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
align={align}
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||||
sideOffset={sideOffset}
|
return (
|
||||||
className={cn(
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
<HoverCardPrimitive.Content
|
||||||
className,
|
ref={ref}
|
||||||
)}
|
data-slot="hover-card-content"
|
||||||
{...props}
|
align={align}
|
||||||
/>
|
sideOffset={sideOffset}
|
||||||
</HoverCardPrimitive.Portal>
|
className={cn(
|
||||||
);
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
}
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
@@ -24,68 +24,69 @@ function SelectValue({
|
|||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
const SelectTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
size = "default",
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||||
children,
|
size?: "sm" | "default";
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
>(({ className, size = "default", children, ...props }, ref) => {
|
||||||
size?: "sm" | "default";
|
return (
|
||||||
}) {
|
<SelectPrimitive.Trigger
|
||||||
return (
|
ref={ref}
|
||||||
<SelectPrimitive.Trigger
|
data-slot="select-trigger"
|
||||||
data-slot="select-trigger"
|
data-size={size}
|
||||||
data-size={size}
|
className={cn(
|
||||||
className={cn(
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
<SelectPrimitive.Icon asChild>
|
||||||
<SelectPrimitive.Icon asChild>
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Trigger>
|
||||||
</SelectPrimitive.Trigger>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function SelectContent({
|
const SelectContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
position = "item-aligned",
|
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||||
align = "center",
|
return (
|
||||||
...props
|
<SelectPrimitive.Portal>
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
<SelectPrimitive.Content
|
||||||
return (
|
ref={ref}
|
||||||
<SelectPrimitive.Portal>
|
data-slot="select-content"
|
||||||
<SelectPrimitive.Content
|
className={cn(
|
||||||
data-slot="select-content"
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
className={cn(
|
position === "popper" &&
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
position === "popper" &&
|
className,
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
)}
|
||||||
className,
|
position={position}
|
||||||
)}
|
align={align}
|
||||||
position={position}
|
{...props}
|
||||||
align={align}
|
>
|
||||||
{...props}
|
<SelectScrollUpButton />
|
||||||
>
|
<SelectPrimitive.Viewport
|
||||||
<SelectScrollUpButton />
|
className={cn(
|
||||||
<SelectPrimitive.Viewport
|
"p-1",
|
||||||
className={cn(
|
position === "popper" &&
|
||||||
"p-1",
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
position === "popper" &&
|
)}
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
>
|
||||||
)}
|
{children}
|
||||||
>
|
</SelectPrimitive.Viewport>
|
||||||
{children}
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Content>
|
||||||
<SelectScrollDownButton />
|
</SelectPrimitive.Portal>
|
||||||
</SelectPrimitive.Content>
|
);
|
||||||
</SelectPrimitive.Portal>
|
});
|
||||||
);
|
|
||||||
}
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
className,
|
className,
|
||||||
@@ -100,32 +101,34 @@ function SelectLabel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
const SelectItem = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
...props
|
>(({ className, children, ...props }, ref) => {
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
return (
|
||||||
return (
|
<SelectPrimitive.Item
|
||||||
<SelectPrimitive.Item
|
ref={ref}
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-slot="select-item-indicator"
|
data-slot="select-item-indicator"
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
>
|
>
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
const SheetTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof SheetPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function SheetClose({
|
const SheetClose = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof SheetPrimitive.Close>,
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
SheetClose.displayName = SheetPrimitive.Close.displayName;
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
@@ -28,58 +34,62 @@ function SheetPortal({
|
|||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
const SheetOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
data-slot="sheet-overlay"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="sheet-overlay"
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
className={cn(
|
||||||
className,
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function SheetContent({
|
const SheetContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||||
side = "right",
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
>(({ className, children, side = "right", ...props }, ref) => {
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
return (
|
||||||
}) {
|
<SheetPortal>
|
||||||
return (
|
<SheetOverlay />
|
||||||
<SheetPortal>
|
<SheetPrimitive.Content
|
||||||
<SheetOverlay />
|
ref={ref}
|
||||||
<SheetPrimitive.Content
|
data-slot="sheet-content"
|
||||||
data-slot="sheet-content"
|
className={cn(
|
||||||
className={cn(
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
side === "right" &&
|
||||||
side === "right" &&
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
side === "left" &&
|
||||||
side === "left" &&
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
side === "top" &&
|
||||||
side === "top" &&
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
side === "bottom" &&
|
||||||
side === "bottom" &&
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
<XIcon className="size-4" />
|
||||||
<XIcon className="size-4" />
|
<span className="sr-only">Close</span>
|
||||||
<span className="sr-only">Close</span>
|
</SheetPrimitive.Close>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Content>
|
||||||
</SheetPrimitive.Content>
|
</SheetPortal>
|
||||||
</SheetPortal>
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupLabel({
|
const SidebarGroupLabel = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
asChild = false,
|
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||||
...props
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
const Comp = asChild ? Slot : "div";
|
||||||
const Comp = asChild ? Slot : "div";
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||||
|
|
||||||
return (
|
const SidebarGroupAction = React.forwardRef<
|
||||||
<Comp
|
HTMLButtonElement,
|
||||||
data-slot="sidebar-group-label"
|
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||||
data-sidebar="group-label"
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
className={cn(
|
const Comp = asChild ? Slot : "button";
|
||||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
return (
|
||||||
className,
|
<Comp
|
||||||
)}
|
ref={ref}
|
||||||
{...props}
|
data-slot="sidebar-group-action"
|
||||||
/>
|
data-sidebar="group-action"
|
||||||
);
|
className={cn(
|
||||||
}
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
function SidebarGroupAction({
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
className,
|
"group-data-[collapsible=icon]:hidden",
|
||||||
asChild = false,
|
className,
|
||||||
...props
|
)}
|
||||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
{...props}
|
||||||
const Comp = asChild ? Slot : "button";
|
/>
|
||||||
|
);
|
||||||
return (
|
});
|
||||||
<Comp
|
|
||||||
data-slot="sidebar-group-action"
|
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||||
data-sidebar="group-action"
|
|
||||||
className={cn(
|
|
||||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
// Increases the hit area of the button on mobile.
|
|
||||||
"after:absolute after:-inset-2 md:after:hidden",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarGroupContent({
|
function SidebarGroupContent({
|
||||||
className,
|
className,
|
||||||
@@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function SidebarMenuButton({
|
const SidebarMenuButton = React.forwardRef<
|
||||||
asChild = false,
|
HTMLButtonElement,
|
||||||
isActive = false,
|
React.ComponentProps<"button"> & {
|
||||||
variant = "default",
|
asChild?: boolean;
|
||||||
size = "default",
|
isActive?: boolean;
|
||||||
tooltip,
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
className,
|
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
...props
|
>(
|
||||||
}: React.ComponentProps<"button"> & {
|
(
|
||||||
asChild?: boolean;
|
{
|
||||||
isActive?: boolean;
|
asChild = false,
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
isActive = false,
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
variant = "default",
|
||||||
const Comp = asChild ? Slot : "button";
|
size = "default",
|
||||||
const { isMobile, state } = useSidebar();
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||||
|
|
||||||
const button = (
|
const SidebarMenuAction = React.forwardRef<
|
||||||
<Comp
|
HTMLButtonElement,
|
||||||
data-slot="sidebar-menu-button"
|
React.ComponentProps<"button"> & {
|
||||||
data-sidebar="menu-button"
|
asChild?: boolean;
|
||||||
data-size={size}
|
showOnHover?: boolean;
|
||||||
data-active={isActive}
|
}
|
||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||||
{...props}
|
const Comp = asChild ? Slot : "button";
|
||||||
/>
|
|
||||||
);
|
return (
|
||||||
|
<Comp
|
||||||
if (!tooltip) {
|
ref={ref}
|
||||||
return button;
|
data-slot="sidebar-menu-action"
|
||||||
}
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
if (typeof tooltip === "string") {
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
tooltip = {
|
// Increases the hit area of the button on mobile.
|
||||||
children: tooltip,
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
};
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
}
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
return (
|
"group-data-[collapsible=icon]:hidden",
|
||||||
<Tooltip>
|
showOnHover &&
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
<TooltipContent
|
className,
|
||||||
side="right"
|
)}
|
||||||
align="center"
|
{...props}
|
||||||
hidden={state !== "collapsed" || isMobile}
|
/>
|
||||||
{...tooltip}
|
);
|
||||||
/>
|
});
|
||||||
</Tooltip>
|
|
||||||
);
|
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarMenuAction({
|
|
||||||
className,
|
|
||||||
asChild = false,
|
|
||||||
showOnHover = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
showOnHover?: boolean;
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="sidebar-menu-action"
|
|
||||||
data-sidebar="menu-action"
|
|
||||||
className={cn(
|
|
||||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
// Increases the hit area of the button on mobile.
|
|
||||||
"after:absolute after:-inset-2 md:after:hidden",
|
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
|
||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
showOnHover &&
|
|
||||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({
|
||||||
className,
|
className,
|
||||||
@@ -670,37 +686,37 @@ function SidebarMenuSubItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubButton({
|
const SidebarMenuSubButton = React.forwardRef<
|
||||||
asChild = false,
|
HTMLAnchorElement,
|
||||||
size = "md",
|
React.ComponentProps<"a"> & {
|
||||||
isActive = false,
|
asChild?: boolean;
|
||||||
className,
|
size?: "sm" | "md";
|
||||||
...props
|
isActive?: boolean;
|
||||||
}: React.ComponentProps<"a"> & {
|
}
|
||||||
asChild?: boolean;
|
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||||
size?: "sm" | "md";
|
const Comp = asChild ? Slot : "a";
|
||||||
isActive?: boolean;
|
|
||||||
}) {
|
return (
|
||||||
const Comp = asChild ? Slot : "a";
|
<Comp
|
||||||
|
ref={ref}
|
||||||
return (
|
data-slot="sidebar-menu-sub-button"
|
||||||
<Comp
|
data-sidebar="menu-sub-button"
|
||||||
data-slot="sidebar-menu-sub-button"
|
data-size={size}
|
||||||
data-sidebar="menu-sub-button"
|
data-active={isActive}
|
||||||
data-size={size}
|
className={cn(
|
||||||
data-active={isActive}
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
className={cn(
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
size === "sm" && "text-xs",
|
||||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
size === "md" && "text-sm",
|
||||||
size === "sm" && "text-xs",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
size === "md" && "text-sm",
|
className,
|
||||||
"group-data-[collapsible=icon]:hidden",
|
)}
|
||||||
className,
|
{...props}
|
||||||
)}
|
/>
|
||||||
{...props}
|
);
|
||||||
/>
|
});
|
||||||
);
|
|
||||||
}
|
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
|||||||
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function TabsList({
|
const TabsList = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
variant = "default",
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||||
...props
|
VariantProps<typeof tabsListVariants>
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
>(({ className, variant = "default", ...props }, ref) => {
|
||||||
VariantProps<typeof tabsListVariants>) {
|
return (
|
||||||
return (
|
<TabsPrimitive.List
|
||||||
<TabsPrimitive.List
|
ref={ref}
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(tabsListVariants({ variant }), className)}
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
function TabsTrigger({
|
const TabsTrigger = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
ref={ref}
|
||||||
className={cn(
|
data-slot="tabs-trigger"
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
className={cn(
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||||
className,
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function TabsContent({
|
const TabsContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
data-slot="tabs-content"
|
ref={ref}
|
||||||
className={cn("flex-1 outline-none", className)}
|
data-slot="tabs-content"
|
||||||
{...props}
|
className={cn("flex-1 outline-none", className)}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||||
@@ -28,33 +28,43 @@ function Tooltip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
const TooltipTrigger = React.forwardRef<
|
||||||
...props
|
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
>(({ ...props }, ref) => {
|
||||||
}
|
return (
|
||||||
|
<TooltipPrimitive.Trigger
|
||||||
function TooltipContent({
|
ref={ref}
|
||||||
className,
|
data-slot="tooltip-trigger"
|
||||||
sideOffset,
|
{...props}
|
||||||
children,
|
/>
|
||||||
...props
|
);
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
});
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Portal>
|
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
|
||||||
<TooltipPrimitive.Content
|
|
||||||
data-slot="tooltip-content"
|
const TooltipContent = React.forwardRef<
|
||||||
sideOffset={sideOffset ?? 4}
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
className={cn(
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
>(({ className, sideOffset, children, ...props }, ref) => {
|
||||||
className,
|
return (
|
||||||
)}
|
<TooltipPrimitive.Portal>
|
||||||
{...props}
|
<TooltipPrimitive.Content
|
||||||
>
|
ref={ref}
|
||||||
{children}
|
data-slot="tooltip-content"
|
||||||
</TooltipPrimitive.Content>
|
sideOffset={sideOffset ?? 4}
|
||||||
</TooltipPrimitive.Portal>
|
className={cn(
|
||||||
);
|
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||||
}
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -16,7 +16,7 @@ export function AgentGallery() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { agents, isLoading } = useAgents();
|
const { agents, isLoading } = useAgents();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
|
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||||
|
|
||||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
@@ -84,8 +84,8 @@ export function AgentGallery() {
|
|||||||
className="flex flex-1 flex-col overflow-hidden"
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<TabsList className="mx-6 mt-4 w-auto self-start">
|
<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>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export function InputBox({
|
|||||||
onFollowupsVisibilityChange,
|
onFollowupsVisibilityChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
|
promptPrefix,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -145,6 +146,16 @@ export function InputBox({
|
|||||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||||
onSubmit?: (message: PromptInputMessage) => void;
|
onSubmit?: (message: PromptInputMessage) => void;
|
||||||
onStop?: () => 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 { t } = useI18n();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -819,6 +830,34 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className="gap-1.5">
|
<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
|
<ModelSelector
|
||||||
open={modelDialogOpen}
|
open={modelDialogOpen}
|
||||||
onOpenChange={setModelDialogOpen}
|
onOpenChange={setModelDialogOpen}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
|
|||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
import { MessageTokenUsageList } from "./message-token-usage";
|
import {
|
||||||
|
MessageMetricsProvider,
|
||||||
|
MessageTokenUsageList,
|
||||||
|
} from "./message-token-usage";
|
||||||
import { MessageListSkeleton } from "./skeleton";
|
import { MessageListSkeleton } from "./skeleton";
|
||||||
import { SubtaskCard } from "./subtask-card";
|
import { SubtaskCard } from "./subtask-card";
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ export function MessageList({
|
|||||||
className={cn("flex size-full flex-col justify-center", className)}
|
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">
|
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
|
||||||
|
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||||
{groupMessages(messages, (group) => {
|
{groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return group.messages.map((msg) => {
|
return group.messages.map((msg) => {
|
||||||
@@ -265,6 +269,7 @@ export function MessageList({
|
|||||||
})}
|
})}
|
||||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||||
<div style={{ height: `${paddingBottom}px` }} />
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
|
</MessageMetricsProvider>
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
</Conversation>
|
</Conversation>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,98 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
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 { useI18n } from "@/core/i18n/hooks";
|
||||||
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
|
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
|
||||||
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function MessageTokenUsage({
|
||||||
className,
|
className,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
message,
|
message,
|
||||||
|
metric,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
message: Message;
|
message: Message;
|
||||||
|
metric?: MessageMetric;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
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;
|
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 usage = getUsageMetadata(message);
|
||||||
|
const showUsage = enabled && !isLoading && Boolean(usage);
|
||||||
|
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
|
||||||
|
|
||||||
|
if (!showUsage && !showSpeed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1 font-medium">
|
{showUsage && usage ? (
|
||||||
<CoinsIcon className="size-3" />
|
|
||||||
{t.tokenUsage.label}
|
|
||||||
</span>
|
|
||||||
{usage ? (
|
|
||||||
<>
|
<>
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium">
|
||||||
|
<CoinsIcon className="size-3" />
|
||||||
|
{t.tokenUsage.label}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
|
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
|
||||||
</span>
|
</span>
|
||||||
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
|
|||||||
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
|
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<span>{t.tokenUsage.unavailableShort}</span>
|
{showSpeed && resolvedMetric ? (
|
||||||
)}
|
<>
|
||||||
|
<SpeedPill metric={resolvedMetric} />
|
||||||
|
{resolvedMetric.durationMs > 0 && (
|
||||||
|
<span className="font-mono tabular-nums">
|
||||||
|
{formatDuration(resolvedMetric.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
}) {
|
}) {
|
||||||
if (!enabled || isLoading) {
|
const [settings] = useLocalSettings();
|
||||||
|
const speedEnabled = settings.display.streamSpeedMeterEnabled;
|
||||||
|
|
||||||
|
if (!enabled && !speedEnabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiMessages = messages.filter((message) => message.type === "ai");
|
const aiMessages = messages.filter((message) => message.type === "ai");
|
||||||
|
|
||||||
if (aiMessages.length === 0) {
|
if (aiMessages.length === 0) {
|
||||||
return null;
|
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">
|
<div className="flex w-full flex-col gap-1">
|
||||||
{visibleThreads.map((thread) => {
|
{visibleThreads.map((thread) => {
|
||||||
const isActive = pathOfThread(thread) === pathname;
|
const isActive = pathOfThread(thread) === pathname;
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
className="group/side-menu-item"
|
className="group/side-menu-item"
|
||||||
>
|
>
|
||||||
<SidebarMenuButton isActive={isActive} asChild>
|
<SidebarMenuButton isActive={isActive} asChild>
|
||||||
<div>
|
<Link
|
||||||
<Link
|
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
href={pathOfThread(thread)}
|
||||||
href={pathOfThread(thread)}
|
>
|
||||||
>
|
{titleOfThread(thread)}
|
||||||
{titleOfThread(thread)}
|
</Link>
|
||||||
</Link>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuAction
|
<SidebarMenuAction
|
||||||
showOnHover
|
showOnHover
|
||||||
className="bg-background/50 hover:bg-background"
|
className="bg-background/50 hover:bg-background"
|
||||||
>
|
>
|
||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
<span className="sr-only">{t.common.more}</span>
|
<span className="sr-only">{t.common.more}</span>
|
||||||
</SidebarMenuAction>
|
</SidebarMenuAction>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-48 rounded-lg"
|
className="w-48 rounded-lg"
|
||||||
side={"right"}
|
side={"right"}
|
||||||
align={"start"}
|
align={"start"}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleRenameClick(
|
handleRenameClick(
|
||||||
thread.thread_id,
|
thread.thread_id,
|
||||||
titleOfThread(thread),
|
titleOfThread(thread),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Pencil className="text-muted-foreground" />
|
<Pencil className="text-muted-foreground" />
|
||||||
<span>{t.common.rename}</span>
|
<span>{t.common.rename}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleShare(thread)}
|
onSelect={() => handleShare(thread)}
|
||||||
>
|
>
|
||||||
<Share2 className="text-muted-foreground" />
|
<Share2 className="text-muted-foreground" />
|
||||||
<span>{t.common.share}</span>
|
<span>{t.common.share}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<Download className="text-muted-foreground" />
|
<Download className="text-muted-foreground" />
|
||||||
<span>{t.common.export}</span>
|
<span>{t.common.export}</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleExport(thread, "markdown")
|
handleExport(thread, "markdown")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileText className="text-muted-foreground" />
|
<FileText className="text-muted-foreground" />
|
||||||
<span>{t.common.exportAsMarkdown}</span>
|
<span>{t.common.exportAsMarkdown}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleExport(thread, "json")
|
handleExport(thread, "json")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileJson className="text-muted-foreground" />
|
<FileJson className="text-muted-foreground" />
|
||||||
<span>{t.common.exportAsJSON}</span>
|
<span>{t.common.exportAsJSON}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleDelete(thread.thread_id)}
|
onSelect={() => handleDelete(thread.thread_id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="text-muted-foreground" />
|
<Trash2 className="text-muted-foreground" />
|
||||||
<span>{t.common.delete}</span>
|
<span>{t.common.delete}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SidebarMenuItem>
|
||||||
</SidebarMenuButton>
|
);
|
||||||
</SidebarMenuItem>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
|||||||
@@ -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,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
|
|||||||
const { t, locale, changeLocale } = useI18n();
|
const { t, locale, changeLocale } = useI18n();
|
||||||
const { theme, setTheme, systemTheme } = useTheme();
|
const { theme, setTheme, systemTheme } = useTheme();
|
||||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
||||||
|
const [settings, setSettings] = useLocalSettings();
|
||||||
|
|
||||||
const themeOptions = useMemo(
|
const themeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</SettingsSection>
|
</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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { BellIcon, BrainIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||||
BellIcon,
|
|
||||||
BrainIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,20 +11,18 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
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 { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-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 { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SettingsSection =
|
type SettingsSection =
|
||||||
|
| "account"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "tools"
|
| "tools"
|
||||||
| "skills"
|
|
||||||
| "notification"
|
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||||
@@ -70,14 +62,18 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
label: t.settings.sections.memory,
|
label: t.settings.sections.memory,
|
||||||
icon: BrainIcon,
|
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 },
|
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
t.settings.sections.appearance,
|
t.settings.sections.appearance,
|
||||||
t.settings.sections.memory,
|
t.settings.sections.memory,
|
||||||
t.settings.sections.tools,
|
t.settings.sections.tools,
|
||||||
t.settings.sections.skills,
|
|
||||||
t.settings.sections.notification,
|
|
||||||
t.settings.sections.about,
|
t.settings.sections.about,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -123,11 +119,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||||
<div className="min-w-0 space-y-8 p-6">
|
<div className="min-w-0 space-y-8 p-6">
|
||||||
|
{activeSection === "account" && <AccountSettingsPage />}
|
||||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
|
||||||
{activeSection === "skills" && <SkillSettingsPage />}
|
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
useSetSkillPinned,
|
useSetSkillPinned,
|
||||||
useSetSkillPublished,
|
useSetSkillPublished,
|
||||||
|
useSkillContent,
|
||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
@@ -92,7 +93,7 @@ function SkillSettingsList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("mine");
|
||||||
const [installOpen, setInstallOpen] = useState(false);
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||||
@@ -101,9 +102,9 @@ function SkillSettingsList({
|
|||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
const isAdmin = storedAuth?.system_role === "admin";
|
const isAdmin = storedAuth?.system_role === "admin";
|
||||||
|
|
||||||
// Visible-to-me skills, classified into the three buckets the UI cares about.
|
// 把可见技能分类成 UI 关心的几个桶。
|
||||||
// "All" = built-in + anyone-published-custom + mine (active state only).
|
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||||
// "Mine" = strictly my own custom skills (active state only).
|
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||||
const all: Skill[] = [];
|
const all: Skill[] = [];
|
||||||
const mine: Skill[] = [];
|
const mine: Skill[] = [];
|
||||||
@@ -115,8 +116,6 @@ function SkillSettingsList({
|
|||||||
const isOwnedByMe =
|
const isOwnedByMe =
|
||||||
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||||
const isPublishedByOthers =
|
|
||||||
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
|
|
||||||
|
|
||||||
if (state === "stale") {
|
if (state === "stale") {
|
||||||
stale.push(skill);
|
stale.push(skill);
|
||||||
@@ -127,7 +126,7 @@ function SkillSettingsList({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// state === active
|
// state === active
|
||||||
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
|
if (isBuiltin || !!skill.published) {
|
||||||
all.push(skill);
|
all.push(skill);
|
||||||
}
|
}
|
||||||
if (isOwnedByMe) {
|
if (isOwnedByMe) {
|
||||||
@@ -156,8 +155,8 @@ function SkillSettingsList({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tabs value={filter} onValueChange={setFilter}>
|
<Tabs value={filter} onValueChange={setFilter}>
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
|
||||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||||
|
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button
|
<Button
|
||||||
@@ -258,6 +257,7 @@ function SkillRow({
|
|||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||||
@@ -308,7 +308,10 @@ function SkillRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full" variant="outline">
|
<Item className="w-full" variant="outline">
|
||||||
<ItemContent>
|
<ItemContent
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailOpen(true)}
|
||||||
|
>
|
||||||
<ItemTitle>
|
<ItemTitle>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span>{skill.name}</span>
|
<span>{skill.name}</span>
|
||||||
@@ -473,10 +476,55 @@ function SkillRow({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 }) {
|
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemActions,
|
ItemActions,
|
||||||
@@ -8,16 +21,54 @@ import {
|
|||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 type { MCPServerConfig } from "@/core/mcp/types";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
|
||||||
export function ToolSettingsPage() {
|
export function ToolSettingsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { config, isLoading, error } = useMCPConfig();
|
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 (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.tools.title}
|
title={t.settings.tools.title}
|
||||||
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||||
) : error ? (
|
) : 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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MCPServerList({
|
function MCPServerList({
|
||||||
servers,
|
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();
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<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}>
|
<Item className="w-full" variant="outline" key={name}>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>
|
<ItemTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div>{name}</div>
|
<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>
|
</div>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="line-clamp-4">
|
<ItemDescription className="line-clamp-4">
|
||||||
{config.description}
|
{config.description ||
|
||||||
|
(config.type === "stdio"
|
||||||
|
? config.command
|
||||||
|
: config.url) ||
|
||||||
|
"暂无描述"}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</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
|
<Switch
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
disabled={isStaticMode || !isAdmin}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
enableMCPServer({ serverName: name, enabled: checked })
|
enableMCPServer({ serverName: name, enabled: checked })
|
||||||
}
|
}
|
||||||
@@ -68,3 +209,49 @@ function MCPServerList({
|
|||||||
</div>
|
</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";
|
"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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +27,9 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { SettingsDialog } from "./settings";
|
import { SettingsDialog } from "./settings";
|
||||||
@@ -50,11 +59,13 @@ export function WorkspaceNavMenu() {
|
|||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||||
"appearance" | "memory" | "tools" | "notification" | "about"
|
"appearance" | "memory" | "tools" | "about"
|
||||||
>("appearance");
|
>("appearance");
|
||||||
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { data: unreadCount = 0 } = useUnreadCount();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -67,10 +78,11 @@ export function WorkspaceNavMenu() {
|
|||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={setSettingsOpen}
|
||||||
defaultSection={settingsDefaultSection}
|
defaultSection={settingsDefaultSection}
|
||||||
/>
|
/>
|
||||||
|
<NotificationDialog
|
||||||
|
open={notificationOpen}
|
||||||
|
onOpenChange={setNotificationOpen}
|
||||||
|
/>
|
||||||
<SidebarMenu className="w-full">
|
<SidebarMenu className="w-full">
|
||||||
<SidebarMenuItem>
|
|
||||||
<NotificationBell />
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -88,6 +100,15 @@ export function WorkspaceNavMenu() {
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuGroup>
|
<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
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultSection("appearance");
|
setSettingsDefaultSection("appearance");
|
||||||
@@ -107,6 +128,26 @@ export function WorkspaceNavMenu() {
|
|||||||
推荐问题配置
|
推荐问题配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/workspace/settings/prompt-prefix")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessageSquareCodeIcon />
|
||||||
|
问答前缀配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/workspace/settings/llm-metrics")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GaugeIcon />
|
||||||
|
大模型调用统计
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -177,10 +177,10 @@ export const enUS: Translations = {
|
|||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: {
|
agents: {
|
||||||
title: "Agents",
|
title: "Experts",
|
||||||
description:
|
description:
|
||||||
"Create and manage custom agents with specialized prompts and capabilities.",
|
"Create and manage custom experts with specialized prompts and capabilities.",
|
||||||
newAgent: "New Agent",
|
newAgent: "New Expert",
|
||||||
emptyTitle: "No custom agents yet",
|
emptyTitle: "No custom agents yet",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Create your first custom agent with a specialized system prompt.",
|
"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",
|
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||||
badgeSourceUpload: "Uploaded",
|
badgeSourceUpload: "Uploaded",
|
||||||
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||||
tabAll: "All",
|
tabAll: "Square",
|
||||||
tabMine: "Mine",
|
tabMine: "Mine",
|
||||||
entryStale: "Stale {count}",
|
entryStale: "Stale {count}",
|
||||||
entryArchived: "Archived {count}",
|
entryArchived: "Archived {count}",
|
||||||
@@ -613,5 +613,8 @@ export const enUS: Translations = {
|
|||||||
markAllRead: "Mark all read",
|
markAllRead: "Mark all read",
|
||||||
empty: "No notifications",
|
empty: "No notifications",
|
||||||
deleteAria: "Delete notification",
|
deleteAria: "Delete notification",
|
||||||
|
settingsAria: "Notification settings",
|
||||||
|
backAria: "Back",
|
||||||
|
settingsTitle: "Notification settings",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -7,6 +7,9 @@ export interface NotificationsTranslations {
|
|||||||
markAllRead: string;
|
markAllRead: string;
|
||||||
empty: string;
|
empty: string;
|
||||||
deleteAria: string;
|
deleteAria: string;
|
||||||
|
settingsAria: string;
|
||||||
|
backAria: string;
|
||||||
|
settingsTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -89,7 +89,7 @@ export const zhCN: Translations = {
|
|||||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||||
proMode: "专业问答",
|
proMode: "专业问答",
|
||||||
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
||||||
ultraMode: "多智能体问答",
|
ultraMode: "多专家问答",
|
||||||
ultraModeDescription:
|
ultraModeDescription:
|
||||||
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
||||||
reasoningEffort: "推理深度",
|
reasoningEffort: "推理深度",
|
||||||
@@ -165,47 +165,47 @@ export const zhCN: Translations = {
|
|||||||
chats: "对话",
|
chats: "对话",
|
||||||
recentChats: "最近的对话",
|
recentChats: "最近的对话",
|
||||||
demoChats: "演示对话",
|
demoChats: "演示对话",
|
||||||
agents: "智能体",
|
agents: "专家",
|
||||||
users: "用户管理",
|
users: "用户管理",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: {
|
agents: {
|
||||||
title: "智能体",
|
title: "专家",
|
||||||
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
description: "创建和管理具有专属 Prompt 与能力的自定义专家。",
|
||||||
newAgent: "新建智能体",
|
newAgent: "新建专家",
|
||||||
emptyTitle: "还没有自定义智能体",
|
emptyTitle: "还没有自定义专家",
|
||||||
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
|
emptyDescription: "创建你的第一个自定义专家,设置专属系统提示词。",
|
||||||
chat: "对话",
|
chat: "对话",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
|
deleteConfirm: "确定要删除该专家吗?此操作不可撤销。",
|
||||||
deleteSuccess: "智能体已删除",
|
deleteSuccess: "专家已删除",
|
||||||
newChat: "新对话",
|
newChat: "新对话",
|
||||||
createPageTitle: "设计你的智能体",
|
createPageTitle: "设计你的专家",
|
||||||
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
|
createPageSubtitle: "描述你想要的专家,我来帮你通过对话创建。",
|
||||||
nameStepTitle: "给新智能体起个名字",
|
nameStepTitle: "给新专家起个名字",
|
||||||
nameStepHint:
|
nameStepHint:
|
||||||
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
||||||
nameStepPlaceholder: "例如 code-reviewer",
|
nameStepPlaceholder: "例如 code-reviewer",
|
||||||
nameStepContinue: "继续",
|
nameStepContinue: "继续",
|
||||||
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
||||||
nameStepAlreadyExistsError: "已存在同名智能体",
|
nameStepAlreadyExistsError: "已存在同名专家",
|
||||||
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
|
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
|
||||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||||
nameStepBootstrapMessage:
|
nameStepBootstrapMessage:
|
||||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
"新专家的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||||
save: "保存智能体",
|
save: "保存专家",
|
||||||
saving: "正在保存智能体...",
|
saving: "正在保存专家...",
|
||||||
saveRequested:
|
saveRequested:
|
||||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版专家。",
|
||||||
saveHint:
|
saveHint:
|
||||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
"你可以在右上角的菜单里随时保存这个专家,就算目前还只是初稿也可以。",
|
||||||
saveCommandMessage:
|
saveCommandMessage:
|
||||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
"请现在根据我们目前已经讨论的全部内容保存这个自定义专家。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||||
agentCreatedPendingRefresh:
|
agentCreatedPendingRefresh:
|
||||||
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
"专家已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||||
more: "更多操作",
|
more: "更多操作",
|
||||||
agentCreated: "智能体已创建!",
|
agentCreated: "专家已创建!",
|
||||||
startChatting: "开始对话",
|
startChatting: "开始对话",
|
||||||
backToGallery: "返回 Gallery",
|
backToGallery: "返回 Gallery",
|
||||||
},
|
},
|
||||||
@@ -420,7 +420,7 @@ export const zhCN: Translations = {
|
|||||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||||
badgeSourceUpload: "用户上传",
|
badgeSourceUpload: "用户上传",
|
||||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||||
tabAll: "全部",
|
tabAll: "广场",
|
||||||
tabMine: "我的",
|
tabMine: "我的",
|
||||||
entryStale: "过时 {count}",
|
entryStale: "过时 {count}",
|
||||||
entryArchived: "归档 {count}",
|
entryArchived: "归档 {count}",
|
||||||
@@ -566,12 +566,12 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
title: "用户管理",
|
title: "用户管理",
|
||||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
description: "查看所有注册用户及其关联的技能和专家数量。",
|
||||||
email: "邮箱",
|
email: "邮箱",
|
||||||
role: "角色",
|
role: "角色",
|
||||||
userId: "用户 ID",
|
userId: "用户 ID",
|
||||||
skillCount: "技能数",
|
skillCount: "技能数",
|
||||||
agentCount: "智能体数",
|
agentCount: "专家数",
|
||||||
roleAdmin: "管理员",
|
roleAdmin: "管理员",
|
||||||
roleUser: "普通用户",
|
roleUser: "普通用户",
|
||||||
empty: "暂无用户",
|
empty: "暂无用户",
|
||||||
@@ -584,5 +584,8 @@ export const zhCN: Translations = {
|
|||||||
markAllRead: "全部已读",
|
markAllRead: "全部已读",
|
||||||
empty: "暂无通知",
|
empty: "暂无通知",
|
||||||
deleteAria: "删除通知",
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { loadMCPConfig, updateMCPConfig } from "./api";
|
import { loadMCPConfig, updateMCPConfig } from "./api";
|
||||||
|
import type { MCPServerConfig } from "./types";
|
||||||
|
|
||||||
export function useMCPConfig() {
|
export function useMCPConfig() {
|
||||||
const { data, isLoading, error } = useQuery({
|
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;
|
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;
|
description: string;
|
||||||
|
disabled_tools?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPConfig {
|
export interface MCPConfig {
|
||||||
|
|||||||
@@ -1,104 +1,181 @@
|
|||||||
/**
|
/**
|
||||||
* Convert markdown text to a Word (.docx) Blob in the browser.
|
* Convert markdown text to a Word (.docx) Blob in the browser.
|
||||||
*
|
*
|
||||||
* Implementation notes:
|
* Designed for scheduled-task reports that often contain Chinese text,
|
||||||
* - Uses the `docx` npm package to construct an OOXML document client-side.
|
* markdown tables, and emojis. Compared to Word's stock heading styles we
|
||||||
* - Parser is intentionally lightweight: it covers the markdown features that
|
* deliberately:
|
||||||
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
|
* - Force every run color to pure black (`#000000`) so headings, links,
|
||||||
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
|
* and inline code never render as Word's default blue / hyperlink theme.
|
||||||
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
|
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
|
||||||
* - We deliberately do NOT reach for a full markdown AST library (remark/
|
* instead of falling back to plain text rows that overflow the page.
|
||||||
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
|
* - Strip variation selectors, zero-width joiners, and other invisible
|
||||||
* degrade to plain text rather than failing loudly.
|
* 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 {
|
import {
|
||||||
AlignmentType,
|
AlignmentType,
|
||||||
|
BorderStyle,
|
||||||
Document,
|
Document,
|
||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
Packer,
|
Packer,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
TextRun,
|
TextRun,
|
||||||
|
WidthType,
|
||||||
type IRunOptions,
|
type IRunOptions,
|
||||||
} from "docx";
|
} from "docx";
|
||||||
|
|
||||||
const HEADING_LEVELS = [
|
const BLACK = "000000";
|
||||||
HeadingLevel.HEADING_1,
|
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
|
||||||
HeadingLevel.HEADING_2,
|
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
|
||||||
HeadingLevel.HEADING_3,
|
|
||||||
HeadingLevel.HEADING_4,
|
|
||||||
HeadingLevel.HEADING_5,
|
|
||||||
HeadingLevel.HEADING_6,
|
|
||||||
];
|
|
||||||
|
|
||||||
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.
|
* Tokenise a single line of markdown into formatted segments.
|
||||||
*
|
*
|
||||||
* Recognised inline syntax:
|
* Recognised inline syntax:
|
||||||
* `code`, **bold**, __bold__, *italic*, _italic_
|
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
|
||||||
* Order matters: code spans win over emphasis so backticks don't get parsed
|
* Markdown links are flattened to their text content — we deliberately do not
|
||||||
* as italic delimiters.
|
* emit hyperlink runs because they pick up Word's blue theme color.
|
||||||
*/
|
*/
|
||||||
function parseInlineSegments(text: string): InlineSegment[] {
|
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 tokens: InlineSegment[] = [];
|
||||||
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
const pattern =
|
||||||
|
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = pattern.exec(text)) !== null) {
|
while ((match = pattern.exec(withoutLinks)) !== null) {
|
||||||
if (match.index > cursor) {
|
if (match.index > cursor) {
|
||||||
tokens.push({ text: text.slice(cursor, match.index) });
|
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
|
||||||
}
|
}
|
||||||
if (match[2] !== undefined) {
|
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
|
||||||
tokens.push({ text: match[2], code: true });
|
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
|
||||||
} else if (match[3] !== undefined) {
|
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
|
||||||
tokens.push({ text: match[3], bold: true });
|
else if (match[5] !== undefined)
|
||||||
} else if (match[4] !== undefined) {
|
|
||||||
tokens.push({ text: match[4], bold: true });
|
|
||||||
} else if (match[5] !== undefined) {
|
|
||||||
tokens.push({ text: match[5], italic: true });
|
tokens.push({ text: match[5], italic: true });
|
||||||
} else if (match[6] !== undefined) {
|
else if (match[6] !== undefined)
|
||||||
tokens.push({ text: match[6], italic: true });
|
tokens.push({ text: match[6], italic: true });
|
||||||
}
|
|
||||||
cursor = pattern.lastIndex;
|
cursor = pattern.lastIndex;
|
||||||
}
|
}
|
||||||
if (cursor < text.length) {
|
if (cursor < withoutLinks.length) {
|
||||||
tokens.push({ text: text.slice(cursor) });
|
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) => {
|
return segments.map((seg) => {
|
||||||
const opts: IRunOptions = {
|
const opts: IRunOptions = {
|
||||||
text: seg.text,
|
text: seg.text,
|
||||||
bold: seg.bold,
|
bold: seg.bold,
|
||||||
italics: seg.italic,
|
italics: seg.italic,
|
||||||
|
color: BLACK,
|
||||||
|
font: seg.code ? MONO_FONT : BODY_FONT,
|
||||||
|
size: extra?.size ?? BODY_SIZE_HALF_PT,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
if (seg.code) {
|
|
||||||
opts.font = { name: "Consolas" };
|
|
||||||
}
|
|
||||||
return new TextRun(opts);
|
return new TextRun(opts);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeParagraphFromInline(text: string): Paragraph {
|
function makeParagraphFromInline(text: string): Paragraph {
|
||||||
return new Paragraph({
|
return new Paragraph({
|
||||||
|
spacing: { after: 120 },
|
||||||
children: segmentsToRuns(parseInlineSegments(text)),
|
children: segmentsToRuns(parseInlineSegments(text)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHeadingParagraph(level: number, text: string): Paragraph {
|
function makeHeadingParagraph(level: number, text: string): Paragraph {
|
||||||
|
const clamped = Math.max(1, Math.min(level, 6));
|
||||||
return new Paragraph({
|
return new Paragraph({
|
||||||
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
|
// We attach a heading level so the table-of-contents still works, but the
|
||||||
children: segmentsToRuns(parseInlineSegments(text)),
|
// 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({
|
return new Paragraph({
|
||||||
|
spacing: { after: 80 },
|
||||||
children: segmentsToRuns(parseInlineSegments(text)),
|
children: segmentsToRuns(parseInlineSegments(text)),
|
||||||
numbering: ordered
|
numbering: ordered
|
||||||
? { reference: "ordered-list", level: Math.min(level, 8) }
|
? { 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 {
|
function makeCodeParagraph(line: string): Paragraph {
|
||||||
return new Paragraph({
|
return new Paragraph({
|
||||||
|
spacing: { after: 0 },
|
||||||
children: [
|
children: [
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: line || " ",
|
text: sanitizeText(line) || " ",
|
||||||
font: { name: "Consolas" },
|
font: MONO_FONT,
|
||||||
|
color: BLACK,
|
||||||
|
size: BODY_SIZE_HALF_PT,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -120,45 +200,124 @@ function makeCodeParagraph(line: string): Paragraph {
|
|||||||
|
|
||||||
function makeQuoteParagraph(text: string): Paragraph {
|
function makeQuoteParagraph(text: string): Paragraph {
|
||||||
return new Paragraph({
|
return new Paragraph({
|
||||||
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
|
||||||
indent: { left: 720 },
|
indent: { left: 720 },
|
||||||
|
spacing: { after: 120 },
|
||||||
|
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHorizontalRule(): Paragraph {
|
function makeHorizontalRule(): Paragraph {
|
||||||
return new Paragraph({
|
return new Paragraph({
|
||||||
alignment: AlignmentType.CENTER,
|
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.
|
* Convert markdown text into an array of docx block elements (Paragraph | Table).
|
||||||
*
|
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
function markdownToParagraphs(markdown: string): Paragraph[] {
|
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
|
||||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||||
const paragraphs: Paragraph[] = [];
|
const blocks: Array<Paragraph | Table> = [];
|
||||||
|
|
||||||
let inFence = false;
|
let inFence = false;
|
||||||
let paragraphBuffer: string[] = [];
|
let paragraphBuffer: string[] = [];
|
||||||
|
|
||||||
const flushParagraph = () => {
|
const flushParagraph = () => {
|
||||||
if (paragraphBuffer.length === 0) return;
|
if (paragraphBuffer.length === 0) return;
|
||||||
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
||||||
paragraphBuffer = [];
|
paragraphBuffer = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const rawLine = lines[i] ?? "";
|
||||||
const line = rawLine.replace(/\t/g, " ");
|
const line = rawLine.replace(/\t/g, " ");
|
||||||
|
|
||||||
if (line.trim().startsWith("```")) {
|
if (line.trim().startsWith("```")) {
|
||||||
@@ -167,7 +326,7 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (inFence) {
|
if (inFence) {
|
||||||
paragraphs.push(makeCodeParagraph(line));
|
blocks.push(makeCodeParagraph(line));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,36 +338,63 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
|||||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||||
if (heading) {
|
if (heading) {
|
||||||
flushParagraph();
|
flushParagraph();
|
||||||
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
|
blocks.push(
|
||||||
|
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
|
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
|
||||||
flushParagraph();
|
flushParagraph();
|
||||||
paragraphs.push(makeHorizontalRule());
|
blocks.push(makeHorizontalRule());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockquote = /^>\s?(.*)$/.exec(line);
|
const blockquote = /^>\s?(.*)$/.exec(line);
|
||||||
if (blockquote) {
|
if (blockquote) {
|
||||||
flushParagraph();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
|
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
|
||||||
if (orderedItem) {
|
if (orderedItem) {
|
||||||
flushParagraph();
|
flushParagraph();
|
||||||
const indent = Math.floor(orderedItem[1].length / 2);
|
const indent = Math.floor(orderedItem[1]!.length / 2);
|
||||||
paragraphs.push(makeListItem(orderedItem[3], true, indent));
|
blocks.push(makeListItem(orderedItem[3]!, true, indent));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||||
if (unorderedItem) {
|
if (unorderedItem) {
|
||||||
flushParagraph();
|
flushParagraph();
|
||||||
const indent = Math.floor(unorderedItem[1].length / 2);
|
const indent = Math.floor(unorderedItem[1]!.length / 2);
|
||||||
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
|
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,33 +402,70 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushParagraph();
|
flushParagraph();
|
||||||
return paragraphs;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a markdown string into a .docx Blob suitable for download.
|
* Convert a markdown string into a .docx Blob suitable for download.
|
||||||
*/
|
*/
|
||||||
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
|
export async function markdownToDocxBlob(
|
||||||
const children: Paragraph[] = [];
|
markdown: string,
|
||||||
|
title?: string,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const children: Array<Paragraph | Table> = [];
|
||||||
if (title?.trim()) {
|
if (title?.trim()) {
|
||||||
children.push(
|
children.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
heading: HeadingLevel.TITLE,
|
spacing: { before: 0, after: 240 },
|
||||||
children: [new TextRun({ text: title.trim(), bold: true })],
|
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({
|
const doc = new Document({
|
||||||
|
styles: {
|
||||||
|
default: {
|
||||||
|
document: {
|
||||||
|
run: {
|
||||||
|
color: BLACK,
|
||||||
|
font: BODY_FONT,
|
||||||
|
size: BODY_SIZE_HALF_PT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
numbering: {
|
numbering: {
|
||||||
config: [
|
config: [
|
||||||
{
|
{
|
||||||
reference: "ordered-list",
|
reference: "ordered-list",
|
||||||
levels: [
|
levels: [
|
||||||
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
|
{
|
||||||
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
|
level: 0,
|
||||||
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
|
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;
|
published_at?: string | null;
|
||||||
role?: ScheduledTaskRole;
|
role?: ScheduledTaskRole;
|
||||||
_subscription?: ScheduledTaskSubscription | null;
|
_subscription?: ScheduledTaskSubscription | null;
|
||||||
|
creator_user_id?: string | null;
|
||||||
|
creator_email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublishedScheduledTask = {
|
export type PublishedScheduledTask = {
|
||||||
@@ -37,6 +39,8 @@ export type PublishedScheduledTask = {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
published_at?: string | null;
|
published_at?: string | null;
|
||||||
owner_user_id: string;
|
owner_user_id: string;
|
||||||
|
creator_user_id?: string | null;
|
||||||
|
creator_email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SchedulerThread = {
|
export type SchedulerThread = {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
notification: {
|
notification: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
display: {
|
||||||
|
streamSpeedMeterEnabled: false,
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
mode: undefined,
|
mode: undefined,
|
||||||
@@ -22,6 +25,14 @@ export interface LocalSettings {
|
|||||||
notification: {
|
notification: {
|
||||||
enabled: boolean;
|
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<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
| "thread_id"
|
| "thread_id"
|
||||||
@@ -48,6 +59,10 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
|
|||||||
...DEFAULT_LOCAL_SETTINGS.notification,
|
...DEFAULT_LOCAL_SETTINGS.notification,
|
||||||
...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>;
|
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> {
|
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
|
getSkillContent,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
@@ -21,6 +22,14 @@ export function useSkills() {
|
|||||||
return { skills: data ?? [], isLoading, error };
|
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() {
|
export function useEnableSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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";
|
"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 { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
|
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
ChatBox,
|
ChatBox,
|
||||||
@@ -41,6 +42,8 @@ export default function ChatPage() {
|
|||||||
const [settings, setSettings] = useThreadSettings(threadId);
|
const [settings, setSettings] = useThreadSettings(threadId);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { tokenUsageEnabled } = useModels();
|
const { tokenUsageEnabled } = useModels();
|
||||||
|
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||||
|
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +53,7 @@ export default function ChatPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
setHasStartedConversation(false);
|
setHasStartedConversation(false);
|
||||||
|
setPromptPrefixOn(false);
|
||||||
}
|
}
|
||||||
}, [isNewThread, threadId]);
|
}, [isNewThread, threadId]);
|
||||||
|
|
||||||
@@ -89,13 +93,27 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
|
const shouldApplyPrefix =
|
||||||
|
!hasStartedConversation &&
|
||||||
|
promptPrefixOn &&
|
||||||
|
promptPrefixSettings?.enabled === true;
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
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(
|
const handleClarificationSubmit = useCallback(
|
||||||
@@ -109,6 +127,16 @@ export default function ChatPage() {
|
|||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [thread]);
|
}, [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
|
const messageListPaddingBottom = showFollowups
|
||||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||||
@@ -195,6 +223,7 @@ export default function ChatPage() {
|
|||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
|
promptPrefix={promptPrefixProp}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
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 {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@@ -17,13 +17,6 @@ import {
|
|||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
@@ -120,7 +113,7 @@ export default function NewAgentPage() {
|
|||||||
model_name: chatModelInput || undefined,
|
model_name: chatModelInput || undefined,
|
||||||
},
|
},
|
||||||
onToolEnd() {
|
onToolEnd() {
|
||||||
// setup_agent never actually executes in this flow — it gets
|
// setup_agent never actually executes in this flow 鈥?it gets
|
||||||
// intercepted by SetupAgentApprovalMiddleware and the form is filled
|
// intercepted by SetupAgentApprovalMiddleware and the form is filled
|
||||||
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
||||||
// No agent record is fetched/refreshed during chat.
|
// No agent record is fetched/refreshed during chat.
|
||||||
@@ -156,7 +149,7 @@ export default function NewAgentPage() {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
// Names are display-only and may collide, so a timestamped placeholder
|
// Names are display-only and may collide, so a timestamped placeholder
|
||||||
// is safe to send to the backend just to satisfy the required-field
|
// is safe to send to the backend just to satisfy the required-field
|
||||||
// validation. Intentionally NOT written back into the visible input —
|
// validation. Intentionally NOT written back into the visible input 鈥?
|
||||||
// the user wants the name box to stay blank while they chat, and we
|
// the user wants the name box to stay blank while they chat, and we
|
||||||
// refill it later (with the model's proposed name) on setup_agent
|
// refill it later (with the model's proposed name) on setup_agent
|
||||||
// approval.
|
// approval.
|
||||||
@@ -245,7 +238,7 @@ export default function NewAgentPage() {
|
|||||||
// Chat is purely a discussion channel. The agent record is only
|
// Chat is purely a discussion channel. The agent record is only
|
||||||
// created when the user explicitly clicks Save (top-right). If the
|
// created when the user explicitly clicks Save (top-right). If the
|
||||||
// model proposes a config via setup_agent, our approval handler
|
// model proposes a config via setup_agent, our approval handler
|
||||||
// prefills the form locally — it does NOT persist anything.
|
// prefills the form locally 鈥?it does NOT persist anything.
|
||||||
await ensureThreadExists();
|
await ensureThreadExists();
|
||||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||||
},
|
},
|
||||||
@@ -255,7 +248,7 @@ export default function NewAgentPage() {
|
|||||||
const handleApproveSetupAgent = useCallback(
|
const handleApproveSetupAgent = useCallback(
|
||||||
async (payload: SetupAgentApprovalPayload) => {
|
async (payload: SetupAgentApprovalPayload) => {
|
||||||
// Approving the model's setup_agent proposal **does not** persist
|
// Approving the model's setup_agent proposal **does not** persist
|
||||||
// anything — it only mirrors the proposal into the form so the user
|
// anything 鈥?it only mirrors the proposal into the form so the user
|
||||||
// can review it. Creation/update happens later when the user clicks
|
// can review it. Creation/update happens later when the user clicks
|
||||||
// the top-right Save button.
|
// the top-right Save button.
|
||||||
const proposedName = payload.name?.trim() ?? "";
|
const proposedName = payload.name?.trim() ?? "";
|
||||||
@@ -338,22 +331,23 @@ export default function NewAgentPage() {
|
|||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
当前对话模型
|
当前对话模型
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<select
|
||||||
value={chatModelInput}
|
value={chatModelInput}
|
||||||
onValueChange={setChatModelInput}
|
onChange={(event) =>
|
||||||
|
setChatModelInput(event.currentTarget.value)
|
||||||
|
}
|
||||||
disabled={isModelsLoading || thread.isLoading}
|
disabled={isModelsLoading || thread.isLoading}
|
||||||
|
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none"
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-auto min-w-[10rem] gap-2 px-2 text-xs">
|
<option value="" disabled>
|
||||||
<SelectValue placeholder="选择模型" />
|
选择模型
|
||||||
</SelectTrigger>
|
</option>
|
||||||
<SelectContent>
|
{models.map((model) => (
|
||||||
{models.map((model) => (
|
<option key={model.name} value={model.name}>
|
||||||
<SelectItem key={model.name} value={model.name}>
|
{model.display_name || model.name}
|
||||||
{model.display_name || model.name}
|
</option>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||||
@@ -405,22 +399,21 @@ export default function NewAgentPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">模型</label>
|
<label className="text-sm font-medium">模型</label>
|
||||||
<Select
|
<select
|
||||||
value={modelInput}
|
value={modelInput}
|
||||||
onValueChange={setModelInput}
|
onChange={(event) => setModelInput(event.currentTarget.value)}
|
||||||
disabled={isModelsLoading}
|
disabled={isModelsLoading}
|
||||||
|
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none"
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<option value="" disabled>
|
||||||
<SelectValue placeholder="选择模型" />
|
选择模型
|
||||||
</SelectTrigger>
|
</option>
|
||||||
<SelectContent>
|
{models.map((model) => (
|
||||||
{models.map((model) => (
|
<option key={model.name} value={model.name}>
|
||||||
<SelectItem key={model.name} value={model.name}>
|
{model.display_name || model.name}
|
||||||
{model.display_name || model.name}
|
</option>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
该专家被使用时调用的模型。
|
该专家被使用时调用的模型。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
|
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
WorkspaceContainer,
|
WorkspaceContainer,
|
||||||
@@ -23,9 +24,11 @@ import {
|
|||||||
getScheduledTaskRunFileContent,
|
getScheduledTaskRunFileContent,
|
||||||
type ScheduledTaskResultFileContent,
|
type ScheduledTaskResultFileContent,
|
||||||
type ScheduledTaskRun,
|
type ScheduledTaskRun,
|
||||||
|
updateScheduledTaskRunFile,
|
||||||
useScheduledTaskActions,
|
useScheduledTaskActions,
|
||||||
useScheduledTaskRun,
|
useScheduledTaskRun,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
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>;
|
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditState = { draft: string; saving: boolean };
|
||||||
|
|
||||||
export default function ScheduledTaskRunDetailPage() {
|
export default function ScheduledTaskRunDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { run_id } = useParams();
|
const { run_id } = useParams();
|
||||||
@@ -101,6 +106,9 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
const [showMeta, setShowMeta] = 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 resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||||
const fileCount = resultFiles.length;
|
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 () => {
|
const handleDeleteRun = async () => {
|
||||||
if (!run) return;
|
if (!run || !canDeleteRun) return;
|
||||||
try {
|
try {
|
||||||
await actions.removeRun.mutateAsync(run.id);
|
await actions.removeRun.mutateAsync(run.id);
|
||||||
toast.success("执行记录已删除");
|
toast.success("执行记录已删除");
|
||||||
@@ -207,10 +289,23 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
<Download className="size-3" />
|
<Download className="size-3" />
|
||||||
下载全部
|
下载全部
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
|
<Button
|
||||||
<Trash2 className="size-3" />
|
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>
|
</Button>
|
||||||
|
{canDeleteRun && (
|
||||||
|
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun}>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 执行信息(可折叠) */}
|
{/* 执行信息(可折叠) */}
|
||||||
{showMeta && run && (
|
{showMeta && run && (
|
||||||
@@ -281,26 +376,94 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{fileContents.map((file) => (
|
{fileContents.map((file) => {
|
||||||
<div key={file.index} className="rounded-md border">
|
const isMarkdown = isMarkdownFile(file);
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
const editable = canEditRun && file.previewable;
|
||||||
<div className="min-w-0">
|
const editState = edits[file.index] ?? null;
|
||||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
const isEditing = editState !== null;
|
||||||
<div className="text-muted-foreground text-xs">
|
return (
|
||||||
{file.mime_type || file.kind || "附件"}
|
<div key={file.index} className="rounded-md border">
|
||||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
<div className="p-3">
|
||||||
<Download className="size-4" />
|
{isEditing ? (
|
||||||
下载
|
<Textarea
|
||||||
</Button>
|
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>
|
||||||
<div className="p-3">
|
);
|
||||||
<RenderPreview file={file} />
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="meta" className="m-0 p-3">
|
<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);
|
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||||
|
|
||||||
function buildCron(cfg: ScheduleConfig): string {
|
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) {
|
switch (cfg.freq) {
|
||||||
case "hourly":
|
case "hourly":
|
||||||
return `${cfg.minute} * * * *`;
|
return `${m} * * * *`;
|
||||||
case "daily":
|
case "daily":
|
||||||
return `0 ${cfg.hour} * * *`;
|
return `${m} ${cfg.hour} * * *`;
|
||||||
case "weekly":
|
case "weekly":
|
||||||
return `0 ${cfg.hour} * * ${cfg.weekday}`;
|
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
|
||||||
case "monthly":
|
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+/);
|
const parts = cron.trim().split(/\s+/);
|
||||||
if (parts.length !== 5) return null;
|
if (parts.length !== 5) return null;
|
||||||
const [min, hr, date, , wd] = parts;
|
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 === "*") {
|
if (hr === "*" && date === "*" && wd === "*") {
|
||||||
const minute = parseInt(min);
|
if (!isNaN(minute)) {
|
||||||
if (!isNaN(minute)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", 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 === "*") {
|
if (date === "*" && wd === "*") {
|
||||||
const hour = parseInt(hr);
|
if (!isNaN(hour)) {
|
||||||
if (!isNaN(hour)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "daily", hour };
|
return {
|
||||||
|
...DEFAULT_SCHEDULE_CONFIG,
|
||||||
|
freq: "daily",
|
||||||
|
hour,
|
||||||
|
minute: isNaN(minute) ? 0 : minute,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (date === "*" && wd !== "*") {
|
if (date === "*" && wd !== "*") {
|
||||||
const hour = parseInt(hr);
|
const weekday = parseInt(wd ?? "", 10);
|
||||||
const weekday = parseInt(wd);
|
if (!isNaN(hour) && !isNaN(weekday)) {
|
||||||
if (!isNaN(hour) && !isNaN(weekday)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "weekly", hour, weekday };
|
return {
|
||||||
|
...DEFAULT_SCHEDULE_CONFIG,
|
||||||
|
freq: "weekly",
|
||||||
|
hour,
|
||||||
|
weekday,
|
||||||
|
minute: isNaN(minute) ? 0 : minute,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (wd === "*" && date !== "*") {
|
if (wd === "*" && date !== "*") {
|
||||||
const hour = parseInt(hr);
|
const d = parseInt(date ?? "", 10);
|
||||||
const d = parseInt(date);
|
if (!isNaN(hour) && !isNaN(d)) {
|
||||||
if (!isNaN(hour) && !isNaN(d)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "monthly", hour, date: d };
|
return {
|
||||||
|
...DEFAULT_SCHEDULE_CONFIG,
|
||||||
|
freq: "monthly",
|
||||||
|
hour,
|
||||||
|
date: d,
|
||||||
|
minute: isNaN(minute) ? 0 : minute,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -191,6 +243,14 @@ function formatDate(value?: string | null) {
|
|||||||
return value ? new Date(value).toLocaleString() : "暂无";
|
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) {
|
function statusLabel(status?: string | null) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "succeeded":
|
case "succeeded":
|
||||||
@@ -248,6 +308,7 @@ export default function ScheduledTasksPage() {
|
|||||||
[tasks],
|
[tasks],
|
||||||
);
|
);
|
||||||
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||||
|
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
|
||||||
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
|
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
|
||||||
|
|
||||||
const filteredRuns = useMemo(() => {
|
const filteredRuns = useMemo(() => {
|
||||||
@@ -292,7 +353,14 @@ export default function ScheduledTasksPage() {
|
|||||||
prompt: task.prompt,
|
prompt: task.prompt,
|
||||||
execution_agent_name: task.execution_agent_name ?? "",
|
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 = () => {
|
const closeDialog = () => {
|
||||||
@@ -451,6 +519,13 @@ export default function ScheduledTasksPage() {
|
|||||||
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
||||||
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
||||||
<div>最近状态:{statusLabel(task.last_status)}</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>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
@@ -641,6 +716,13 @@ export default function ScheduledTasksPage() {
|
|||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
||||||
</p>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="min-h-0 flex-1 p-0">
|
<CardContent className="min-h-0 flex-1 p-0">
|
||||||
<ScrollArea className="size-full">
|
<ScrollArea className="size-full">
|
||||||
@@ -700,14 +782,16 @@ export default function ScheduledTasksPage() {
|
|||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
下载结果
|
下载结果
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{selectedTaskCanEditRuns && run.can_edit !== false && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="destructive"
|
size="sm"
|
||||||
onClick={() => handleDeleteRun(run.id)}
|
variant="destructive"
|
||||||
>
|
onClick={() => handleDeleteRun(run.id)}
|
||||||
<Trash2 className="size-4" />
|
>
|
||||||
删除
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { RouteBaseProvider } from "@/core/route-base";
|
import { RouteBaseProvider } from "@/core/route-base";
|
||||||
import { SubtasksProvider } from "@/core/tasks/context";
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
|
||||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
|
||||||
|
|
||||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||||
|
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
import ChatsPage from "./ChatsPage";
|
import ChatsPage from "./ChatsPage";
|
||||||
import ChatPage from "./ChatPage";
|
import ChatPage from "./ChatPage";
|
||||||
@@ -18,19 +16,10 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
|||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
|
||||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<SubtasksProvider>
|
|
||||||
<ArtifactsProvider>
|
|
||||||
<PromptInputProvider>{children}</PromptInputProvider>
|
|
||||||
</ArtifactsProvider>
|
|
||||||
</SubtasksProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StrategyRoutes() {
|
export default function StrategyRoutes() {
|
||||||
return (
|
return (
|
||||||
<RouteBaseProvider value="/page/strategy">
|
<RouteBaseProvider value="/page/strategy">
|
||||||
|
<SidebarProvider>
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="qa/general" replace />} />
|
<Route index element={<Navigate to="qa/general" replace />} />
|
||||||
@@ -89,6 +78,7 @@ export default function StrategyRoutes() {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</RouteBaseProvider>
|
</RouteBaseProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import AgentChatPage from "./AgentChatPage";
|
|||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
import SkillsPage from "./SkillsPage";
|
import SkillsPage from "./SkillsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
@@ -64,7 +66,7 @@ export function WorkspaceLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SubtasksProvider>
|
<SubtasksProvider>
|
||||||
<ArtifactsProvider>
|
<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>
|
</Routes>
|
||||||
</RouteBaseProvider>
|
</RouteBaseProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.gateway.routers import (
|
|||||||
auth,
|
auth,
|
||||||
channels,
|
channels,
|
||||||
feedback,
|
feedback,
|
||||||
|
llm_metrics,
|
||||||
mcp,
|
mcp,
|
||||||
memory,
|
memory,
|
||||||
models,
|
models,
|
||||||
@@ -36,6 +37,7 @@ from app.gateway.routers import (
|
|||||||
scheduled_tasks,
|
scheduled_tasks,
|
||||||
skills,
|
skills,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
system_settings,
|
||||||
thread_runs,
|
thread_runs,
|
||||||
threads,
|
threads,
|
||||||
uploads,
|
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
|
# Scheduled task API is mounted at /api/scheduled-tasks
|
||||||
app.include_router(scheduled_tasks.router)
|
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
|
# Channels API is mounted at /api/channels
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ class LocalAuthProvider(AuthProvider):
|
|||||||
"""Get user by ID."""
|
"""Get user by ID."""
|
||||||
return await self._repo.get_user_by_id(user_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:
|
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.
|
"""Create a new local user.
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,26 @@ class UserRepository(ABC):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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
|
@abstractmethod
|
||||||
async def update_user(self, user: User) -> User:
|
async def update_user(self, user: User) -> User:
|
||||||
"""Update an existing user.
|
"""Update an existing user.
|
||||||
|
|||||||
@@ -88,6 +88,27 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
row = result.scalar_one_or_none()
|
row = result.scalar_one_or_none()
|
||||||
return self._row_to_user(row) if row is not None else 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 def update_user(self, user: User) -> User:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(UserRow, str(user.id))
|
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)
|
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
|
from deerflow.persistence.agents import make_agent_store
|
||||||
|
|
||||||
app.state.agent_store = make_agent_store(sf)
|
app.state.agent_store = make_agent_store(sf)
|
||||||
@@ -176,6 +180,11 @@ def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
|
|||||||
return val
|
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:
|
def get_agent_store(request: Request) -> AgentStore:
|
||||||
"""Return the custom-agent store."""
|
"""Return the custom-agent store."""
|
||||||
val = getattr(request.app.state, "agent_store", None)
|
val = getattr(request.app.state, "agent_store", None)
|
||||||
|
|||||||
@@ -164,25 +164,40 @@ class MessageResponse(BaseModel):
|
|||||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
# ── 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:
|
def _username_to_email(username: str) -> str:
|
||||||
"""Map a frontend username to a valid email used by user storage.
|
"""Map a frontend username to a valid email used by user storage.
|
||||||
|
|
||||||
- If username already looks like an email (`contains @`), use it directly.
|
- If username already looks like an email (`contains @`), use it directly.
|
||||||
- Otherwise generate `<normalized-username>@<domain>`.
|
- Otherwise generate `<normalized-username>@<domain>`.
|
||||||
"""
|
"""
|
||||||
raw = username.strip()
|
local = _normalize_username_local_part(username)
|
||||||
if not raw:
|
if local is None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
|
return username.strip().lower()
|
||||||
|
|
||||||
if "@" in raw:
|
# Default mail domain for passwordless / username-only registrations.
|
||||||
return raw.lower()
|
# Kept overridable via env so individual deployments can swap it out, but the
|
||||||
|
# built-in default targets the cm.com tenant.
|
||||||
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
|
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "cm.com").strip().lower() or "cm.com"
|
||||||
if not normalized:
|
return f"{local}@{domain}"
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
|
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()
|
provider = get_local_provider()
|
||||||
email = _username_to_email(body.username)
|
email = _username_to_email(body.username)
|
||||||
|
local_part = _normalize_username_local_part(body.username)
|
||||||
|
|
||||||
created = False
|
created = False
|
||||||
user = await provider.get_user_by_email(email)
|
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:
|
if user is None:
|
||||||
try:
|
try:
|
||||||
role = "admin" if body.username == "admin" else "user"
|
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:
|
except ValueError:
|
||||||
# Concurrent first login may create the same user in parallel.
|
# Concurrent first login may create the same user in parallel.
|
||||||
user = await provider.get_user_by_email(email)
|
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:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user")
|
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":
|
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 pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
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
|
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
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):
|
class McpOAuthConfigResponse(BaseModel):
|
||||||
"""OAuth configuration for an MCP server."""
|
"""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)")
|
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")
|
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")
|
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):
|
class McpConfigResponse(BaseModel):
|
||||||
@@ -101,7 +121,7 @@ async def get_mcp_configuration() -> McpConfigResponse:
|
|||||||
summary="Update MCP Configuration",
|
summary="Update MCP Configuration",
|
||||||
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
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.
|
"""Update the MCP configuration.
|
||||||
|
|
||||||
This will:
|
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:
|
try:
|
||||||
# Get the current config path (or determine where to save it)
|
# Get the current config path (or determine where to save it)
|
||||||
config_path = ExtensionsConfig.resolve_config_path()
|
config_path = ExtensionsConfig.resolve_config_path()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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
|
from deerflow.runtime.scheduler import get_scheduled_task_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||||
@@ -61,6 +61,50 @@ async def _user_id(request: Request) -> str:
|
|||||||
return user_id
|
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:
|
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
|
||||||
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
|
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
|
||||||
return name or default
|
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]:
|
async def list_scheduled_tasks(request: Request) -> list[dict]:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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")
|
@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:
|
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
@@ -298,7 +347,7 @@ async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateReques
|
|||||||
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
|
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
user_id = await _user_id(request)
|
||||||
return await service.create_task(
|
task = await service.create_task(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
prompt=body.prompt,
|
prompt=body.prompt,
|
||||||
@@ -307,6 +356,8 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
|
|||||||
execution_agent_name=body.execution_agent_name,
|
execution_agent_name=body.execution_agent_name,
|
||||||
execution_context=body.execution_context,
|
execution_context=body.execution_context,
|
||||||
)
|
)
|
||||||
|
await _enrich_with_creator(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{task_id}")
|
@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))
|
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
|
await _enrich_with_creator(task)
|
||||||
return 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]:
|
async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
await _user_id(request)
|
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")
|
@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)}")
|
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(
|
@router.put(
|
||||||
"/skills/{skill_name}",
|
"/skills/{skill_name}",
|
||||||
response_model=SkillResponse,
|
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.deps import get_run_context, get_run_manager, get_stream_bridge
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.runtime import (
|
from deerflow.runtime import (
|
||||||
END_SENTINEL,
|
END_SENTINEL,
|
||||||
HEARTBEAT_SENTINEL,
|
HEARTBEAT_SENTINEL,
|
||||||
@@ -98,6 +99,45 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
_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
|
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
||||||
# ``body.context`` into the run config. ``config["context"]`` exists in
|
# ``body.context`` into the run config. ``config["context"]`` exists in
|
||||||
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
# 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,
|
# Upsert thread metadata so the thread appears in /threads/search,
|
||||||
# even for threads that were never explicitly created via POST /threads
|
# even for threads that were never explicitly created via POST /threads
|
||||||
# (e.g. stateless runs).
|
# (e.g. stateless runs).
|
||||||
|
thread_existed = False
|
||||||
try:
|
try:
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
existing = await run_ctx.thread_store.get(thread_id)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
@@ -349,12 +390,24 @@ async def start_run(
|
|||||||
metadata=body.metadata,
|
metadata=body.metadata,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
thread_existed = True
|
||||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
await run_ctx.thread_store.update_status(thread_id, "running")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
||||||
|
|
||||||
agent_factory = resolve_agent_factory(body.assistant_id)
|
agent_factory = resolve_agent_factory(body.assistant_id)
|
||||||
graph_input = normalize_input(body.input)
|
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)
|
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``.
|
# 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))
|
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)
|
# Guardrail middleware (if configured)
|
||||||
guardrails_config = app_config.guardrails
|
guardrails_config = app_config.guardrails
|
||||||
if guardrails_config.enabled and guardrails_config.provider:
|
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)")
|
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)")
|
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")
|
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):
|
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
|
# Get all tools from all servers
|
||||||
tools = await client.get_tools()
|
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")
|
logger.info(f"Successfully loaded {len(tools)} tool(s) from MCP servers")
|
||||||
|
|
||||||
# Patch tools to support sync invocation, as deerflow client streams synchronously
|
# 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.agents.model import AgentRow
|
||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
from deerflow.persistence.feedback.model import FeedbackRow
|
||||||
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.notifications.model import NotificationRow
|
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.run.model import RunRow
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import (
|
||||||
ScheduledTaskDeliveryProfileRow,
|
ScheduledTaskDeliveryProfileRow,
|
||||||
@@ -34,7 +36,9 @@ from deerflow.persistence.user.model import UserRow
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"FeedbackRow",
|
"FeedbackRow",
|
||||||
"AgentRow",
|
"AgentRow",
|
||||||
|
"LlmCallMetricRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
|
"RecommendedQuestionRow",
|
||||||
"RunEventRow",
|
"RunEventRow",
|
||||||
"RunRow",
|
"RunRow",
|
||||||
"ScheduledTaskDeliveryProfileRow",
|
"ScheduledTaskDeliveryProfileRow",
|
||||||
|
|||||||
+10
-2
@@ -247,7 +247,11 @@ class ScheduledTaskService:
|
|||||||
accessible = await self._accessible_task(user_id, task_id)
|
accessible = await self._accessible_task(user_id, task_id)
|
||||||
if accessible is None:
|
if accessible is None:
|
||||||
return []
|
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:
|
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)
|
run = await self.store.get_run(run_id, user_id)
|
||||||
@@ -316,7 +320,7 @@ class ScheduledTaskService:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise PermissionError("Refusing to edit file outside the run archive") from 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
|
new_size = archive_path.stat().st_size
|
||||||
|
|
||||||
# Reflect the new size in the run row so subsequent list/preview calls
|
# Reflect the new size in the run row so subsequent list/preview calls
|
||||||
@@ -342,6 +346,10 @@ class ScheduledTaskService:
|
|||||||
shutil.rmtree(archive_dir)
|
shutil.rmtree(archive_dir)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
|
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
|
return deleted
|
||||||
|
|
||||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
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
|
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 (subscriber) sees runs on the task.
|
||||||
bob_runs = await service.list_runs("bob", "t1", limit=10)
|
bob_runs = await service.list_runs("bob", "t1", limit=10)
|
||||||
assert [r["id"] for r in bob_runs] == ["run-1"]
|
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.
|
# Bob can fetch the run by id.
|
||||||
fetched = await service.get_run("bob", "run-1")
|
fetched = await service.get_run("bob", "run-1")
|
||||||
assert fetched is not None and fetched["id"] == "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.
|
# Carol (no subscription) cannot.
|
||||||
assert await service.list_runs("carol", "t1", limit=10) == []
|
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