Feat/frontend phase2 themes trace mermaid #26

Merged
Kronecker merged 4 commits from feat/frontend-phase2-themes-trace-mermaid into main 2026-09-05 17:47:09 +08:00
117 changed files with 4138 additions and 1051 deletions
Showing only changes of commit 0f08cd051b - Show all commits
+1
View File
@@ -7,6 +7,7 @@ frontend/*.tsbuildinfo
# Backend
backend/.venv/
backend/.venv-models/
backend/.venv-models-cuda/
backend/data/models/
backend/data/attachments/
backend/.uv-cache/
+85 -102
View File
@@ -1,153 +1,136 @@
# Notes Agent(暂命名) 团队开发说明
> 本文件用于团队开发期间快速配置环境启动项目,不是正式的项目 README。
> 本文件用于团队开发期间快速配置环境启动项目并了解当前实现状态,不是正式的项目 README。
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用
## 当前目录
截至 2026-09-05,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
## 目录
```text
NotesAgent/
├── frontend/ Vue 3 + TypeScript + Vite 前端
├── backend/ FastAPI + Pydantic 后端
├── backend/ FastAPI AI Core、SQLite 与本地模型运行管理
├── docs/ 架构、契约、开发说明、协作规范与问题复盘
└── server sync/ 云同步服务预留目录,当前未实现
```
## 当前能力
- 工作区:打开一个后端配置的真实 Vault,编辑 Markdown,管理文件与目录。
- 检索与问答:FTS5、sqlite-vec、RRF 与轻量词面精排;搜索历史持久化到后端 SQLite;AI 对话自动检索知识库并返回 Citation。
- Agent 与扩展:持久化 Trace、可恢复 SSE、Tool/Permission、Skill、Plugin Command/Settings/Secret、隔离 Plugin Host。
- MCP:独立配置 stdio、Streamable HTTP 和旧 SSE Server,发现并调用工具;生产 stdio 沙箱等待 Tauri Host。
- 模型服务:OpenAI Chat/Compatible、OpenAI Responses、Anthropic Messages、Ollama;国内常用提供商 logo 预设、独立凭据、模型发现和自定义请求 JSON。
- 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。
- 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。
- 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。
- 界面偏好:设置页可即时切换全局中文/英文界面,并控制由系统词典提供的编辑器拼写检查;偏好目前保存于 Web 端设备配置,后续由 Tauri 配置存储接管。
## 本地模型
| 能力 | 当前模型 | 许可 | 说明 |
| --- | --- | --- | --- |
| 默认 Embedding | `hotchpotch/bekko-embedding-v1-a8m` | MIT | 384 维,中文检索默认选择 |
| 可选 Embedding | `ibm-granite/granite-embedding-97m-multilingual-r2` | Apache-2.0 | 384 维,多语言备选 |
| 音频转写与语言识别 | `Qwen/Qwen3-ASR-0.6B` | Apache-2.0 | 返回片段级时间边界 |
| 声纹提取与匹配 | `iic/speech_eres2netv2_sv_zh-cn_16k-common` | Apache-2.0 | 192 维声纹,供相似度和片段聚类使用 |
模型权重按代码中的固定 revision 下载并校验,推理阶段离线读取。当前说话人处理是能量分段、ASR 片段与 ERes2NetV2 聚类,不包含逐字强制对齐、同段多人或重叠语音分离。`HashEmbeddingProvider` 只用于确定性测试注入。
## 开发环境
当前开发版需要:
| 环境 | 要求 |
| --- | --- |
| Git | 较新稳定版 |
| Node.js | 22+,推荐 24 |
| pnpm | 10+ |
| Python | 3.11+,推荐 3.12 |
| uv | 较新稳定版 |
| 环境 | 要求 | 说明 |
| --- | --- | --- |
| Git | 较新稳定版 | 代码版本管理 |
| Node.js | 22 或更高版本 | 推荐使用 Node.js 24 |
| pnpm | 10 或更高版本 | 前端依赖与脚本管理 |
| Python | 3.11 或更高版本 | 推荐使用 Python 3.12 |
| uv | 较新稳定版 | 后端依赖和虚拟环境管理 |
当前 Web 联调不需要 Rust 和 Tauri。桌面端集成时再安装 Rust Toolchain 与 Tauri CLI。
检查本机环境:
## 初始化与启动
```powershell
git --version
node --version
pnpm --version
python --version
uv --version
```
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md` 安装 Rust Toolchain 与 Tauri CLI。
## 首次初始化
### 后端
安装 API 与前端依赖:
```powershell
cd backend
uv sync
cd ..
```
`uv sync` 会根据 `backend/pyproject.toml` 安装依赖,并自动创建和管理 `backend/.venv`,不需要手动创建或激活虚拟环境。
### 前端
```powershell
cd frontend
cd ../frontend
pnpm install
cd ..
```
## 启动开发环境
前端和后端需要在两个终端中分别启动。
### 终端一:启动后端
在两个终端分别启动:
```powershell
# 终端一
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
后端地址:
- 健康检查:<http://127.0.0.1:8000/health>
- 服务状态:<http://127.0.0.1:8000/api/status>
- API 文档:<http://127.0.0.1:8000/docs>
- OpenAPI JSON<http://127.0.0.1:8000/openapi.json>
#### 开发环境使用外部模型
在“设置 → 模型提供商”中选择 DeepSeek 或 OpenAI 预设后,直接在密码输入框填写 API Key。前端只在提交期间持有该值,不写入 Pinia 或 localStorageAI Core 将其加密保存到本机 `backend/data/credentials/`Provider 配置只保留内部 Credential ID。
该目录同时包含本地开发用主密钥和密文,并已加入 `.gitignore`。这提供本地静态加密和完整性校验,但不能替代操作系统凭据库。开始 Tauri 桌面集成后,应将存储实现迁移到 Stronghold,保留现有 Credential API 与 Provider 接口边界。
无界面或自动化环境仍可使用 `DEEPSEEK_API_KEY``OPENAI_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;设置页保存的本地密钥优先,环境变量仅在本地未保存对应 Credential ID 时作为回退。密钥不得写入仓库文件、README、Issue、提交信息或聊天记录。
### 终端二:启动前端
```powershell
# 终端二
cd frontend
pnpm dev
```
前端地址<http://127.0.0.1:5173>
前端地址<http://127.0.0.1:5173>Vite 将 `/api``/health` 代理到 <http://127.0.0.1:8000>。后端提供健康检查 `/health`、服务状态 `/api/status`、API 文档 `/docs` 和机器可读契约 `/openapi.json`
开发环境中,Vite 会将 `/api``/health` 请求代理到 `http://127.0.0.1:8000`。联调时应先启动后端,再启动或刷新前端。
## 安装本地模型运行组件
API 环境保留在 `backend/.venv`,模型依赖安装到独立环境。默认安装 CPU:
```powershell
./backend/scripts/install-model-runtime.ps1
```
CUDA 为 Windows 可选组件,可在“设置 → 模型提供商 → 本地模型”中安装,也可保留 CPU 环境并创建独立 CUDA 环境:
```powershell
./backend/scripts/install-model-runtime.ps1 -Device cuda -RuntimeDirectory ./backend/.venv-models-cuda
$env:APP_MODEL_PYTHON = (Resolve-Path ./backend/.venv-models-cuda/Scripts/python.exe).Path
```
脚本固定 `torch`/`torchaudio` 2.9.1CPU 使用官方 CPU wheelCUDA 使用 cu128 wheel;脚本不会安装或修改 NVIDIA 驱动。模型权重需要在设置页显式下载,不会在推理时自动下载。
## 模型提供商与凭据
在“设置 → 模型提供商”中选择预设或创建自定义提供商。API Key 只在前端提交期间存在,不写入 Pinia 或 `localStorage`;后端将密文和开发主密钥保存到已忽略的 `backend/data/credentials/`Provider 配置只保存 Credential ID。
无界面环境可使用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>`。当前 Fernet 存储用于 Web 联调,桌面端将沿用 Credential API 边界迁移到 Stronghold。
## 测试与构建
后端测试:
```powershell
cd backend
uv run pytest
```
前端类型检查及生产构建:
```powershell
cd frontend
cd ../frontend
pnpm test
pnpm build
```
前端单元与组件测试:
当前回归基线为后端 559 项、前端 106 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。
```powershell
cd frontend
pnpm test
```
当前回归基线为后端 218 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
## 文档导航
## 文档
| 文档 | 用途 |
| --- | --- |
| [文档总索引](docs/README.md) | 文档分类、阅读顺序和维护规则 |
| [技术栈说明](docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md) | 目标架构、第二阶段技术边界与模块依赖 |
| [第二阶段分工表](docs/architecture/第二阶段团队分工表.md) | 第二阶段人员职责、任务顺序、协作关系与验收项 |
| [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
| [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 |
| [Plugin Command 与 Settings](docs/development/Plugin-Command与Settings开发说明.md) | Command Registry、Settings Schema、Secret 引用与联调边界 |
| [Plugin Command 与 Settings 复盘](docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md) | 阶段 D 连续审阅发现的安全、事务、Schema 与运行时契约问题 |
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
| [文档总索引](docs/README.md) | 全部架构、契约、开发说明和复盘入口 |
| [前端 README](frontend/README.md) | 前端结构、运行方式和数据边界 |
| [后端 README](backend/README.md) | API Core、模型运行与配置 |
| [技术栈说明](docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md) | 当前技术基线、目标桌面架构与模块边界 |
| [多模态与模型运行](docs/development/多模态管线与模型运行开发说明.md) | 模型 revision、CPU/CUDA、路由、用量和接口 |
| [阶段 F 收尾验收](docs/development/阶段F收尾验收记录.md) | 自动化、CPU/CUDA 真实闭环和未关闭专项 |
| [后端接口契约](docs/contracts/后端接口契约-开发版.md) | 当前 HTTP/SSE 接口说明 |
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO 与行为边界 |
## 日常开发注意事项
## 开发约定
- Python 依赖统一修改 `backend/pyproject.toml`,修改后执行 `uv sync`
- 前端依赖统一使用 pnpm 安装,不混用 npm 或 yarn。
- `backend/.venv``frontend/node_modules``frontend/dist` 均为本地生成目录,不提交 Git。
- API 默认监听 `127.0.0.1:8000`,前端默认监听 `127.0.0.1:5173`
- 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理
- 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明
- 当前已实现接口见 `docs/contracts/后端接口契约-开发版.md`,第二阶段规划接口见 `docs/contracts/第二阶段接口契约-开发版.md`;已实现能力以 `/openapi.json` 为准。
- 前端页面、交互、状态管理及当前阶段后续页面需求见 `docs/contracts/前端页面需求说明-开发版.md`
- 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/guides/Git使用细则-团队开发版.md`
- CI 检查、产物、发布和回滚规范见 `docs/guides/CI-CD细则-团队开发版.md`
- 后端依赖统一修改 `backend/pyproject.toml`执行 `uv sync`;模型依赖由 `backend/scripts/model-requirements.lock` 锁定
- 前端依赖统一使用 pnpm,不混用 npm 或 yarn。
- `backend/.venv*`模型权重、`frontend/node_modules``frontend/dist` 都是本地产物,不提交 Git。
- 前端不直接访问 SQLite 或厂商模型协议;持久数据通过 FastAPI 服务读写
- 接口或数据结构变化时,同一提交同步更新前后端类型、契约和开发说明
- 当前行为以代码、测试和运行中的 `/openapi.json` 为准;规划能力必须在文档中明确标注
+81 -12
View File
@@ -1,34 +1,103 @@
# Notes Agent Backend
# NotesAgent Backend
FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。
NotesAgent Backend 是基于 Python 3.11+、FastAPIPydantic v2 和 SQLite 的本地 AI Core / Agent Core使用 uv 管理 API 依赖和虚拟环境。
当前实现包含 Knowledge/Retrieval、Chat、Agent、Tool/Permission、Skill/Plugin、MCP、模型提供商与多模态任务。支持 OpenAI Chat/Compatible、Responses、Anthropic Messages 和 Ollama;真实本地 Embedding、ASR、声纹模型默认 CPUCUDA 显式选装。操作系统级 Plugin 沙箱属于后续阶段。
当前实现包含 Knowledge/Retrieval、Chat、Agent、Tool/Permission、Skill/Plugin、MCP、模型提供商、RAG Benchmark、多模态任务、本地模型调度、Token/音频用量和运行诊断。数据持久化位于后端 SQLite 与 VaultTauri Sidecar 生命周期、Stronghold 和操作系统级 Plugin 沙箱属于后续桌面阶段。
## 初始化与运行
```powershell
uv sync
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
`uv sync` 首次运行时会自动创建由 uv 管理 `.venv`,无需手动执行 `python -m venv`激活环境。
启动后可访问:
`uv sync` 会创建并管理 `backend/.venv`,无需手动激活环境。启动后可访问:
- 健康检查:<http://127.0.0.1:8000/health>
- 服务状态:<http://127.0.0.1:8000/api/status>
- API 文档:<http://127.0.0.1:8000/docs>
- OpenAPI<http://127.0.0.1:8000/openapi.json>
运行回归测试:
## 核心模块
| 目录 | 职责 |
| --- | --- |
| `app/knowledge``app/retrieval` | Markdown 解析、FTS5、sqlite-vec、RRF、真实 Embedding 路由和 Citation |
| `app/agent` | Agent Runtime、Tool 调用、权限与持久化 Trace |
| `app/extensions` | Skill、Plugin Host、MCP Registry 与 stdio/HTTP/SSE Bridge |
| `app/providers` | OpenAI Chat/Compatible、Responses、Anthropic Messages、Ollama 与能力路由 |
| `app/local_models` | 模型目录、固定 revision 下载、独立进程、设备回退和队列调度 |
| `app/services` | 索引、知识库上下文、聊天记录、转写、搜索历史、用量和诊断等应用服务 |
| `app/benchmarks` | 版本化 RAG Dataset、异步评测、指标与报告 |
## 模型路由
Embedding、音频转写和声纹匹配遵循同一规则:
1. 配置可用 API 时先调用 API;
2. API 失败或返回无效结果时回退本地模型;
3. 未配置 API 时直接使用本地模型;
4. `local_only` 请求只允许本地模型;
5. 响应和诊断记录实际来源、设备及回退原因。
生产向量按 Provider、模型、revision、接口和维度隔离,切换空间后需要重建索引。Markdown 和 FTS 在模型不可用时仍可保存与查询;`HashEmbeddingProvider` 仅供测试显式注入。
## 本地模型运行环境
API 的 `backend/.venv` 与模型环境分离。默认安装 CPU 运行组件:
```powershell
./scripts/install-model-runtime.ps1
```
可选 CUDA 环境:
```powershell
./scripts/install-model-runtime.ps1 -Device cuda -RuntimeDirectory ./.venv-models-cuda
$env:APP_MODEL_PYTHON = (Resolve-Path ./.venv-models-cuda/Scripts/python.exe).Path
```
脚本固定 `torch`/`torchaudio` 2.9.1CUDA 使用 cu128 wheel,不安装驱动。其余模型依赖由 `scripts/model-requirements.lock` 锁定,包含 `qwen-asr``sentence-transformers`、ModelScope 和 PyAV。
| 能力 | 模型 | 固定 revision | 许可 |
| --- | --- | --- | --- |
| 默认 Embedding | `hotchpotch/bekko-embedding-v1-a8m` | `c721113d59a1d91b447450324f51c4b3332c924a` | MIT |
| 可选 Embedding | `ibm-granite/granite-embedding-97m-multilingual-r2` | `835ad14087e140460703cf0fae09f97d469d65c2` | Apache-2.0 |
| 音频转写 | `Qwen/Qwen3-ASR-0.6B` | `5eb144179a02acc5e5ba31e748d22b0cf3e303b0` | Apache-2.0 |
| 声纹匹配 | `iic/speech_eres2netv2_sv_zh-cn_16k-common` | `3317286545c587ae682dbc166831d9448780eebb` | Apache-2.0 |
模型运行时默认 CPU。任务在独立子进程中按需加载并在结束后释放;队列中查询 Embedding、媒体任务、后台索引的优先级依次降低。CUDA 不可用、初始化失败或显存不足时,系统清理失败进程并以同一冻结配置在 CPU 重试一次。
音频由 PyAV 解码为 16 kHz 单声道,经过能量分段、Qwen3-ASR 和 ERes2NetV2 片段聚类。当前只提供片段级时间戳,不支持逐字对齐、同段多人和重叠语音分离。
## Provider 与凭据
支持 OpenAI Chat/Compatible、OpenAI Responses、Anthropic Messages 和 Ollama。Provider 配置可分别绑定聊天、Embedding、转写和声纹能力,并通过受限的自定义请求 JSON 合并厂商扩展字段。
API Key 可由前端设置页写入,也可通过 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入。开发环境使用 Fernet 密文存储,接口不返回明文;`plugin.*` 是 Plugin Settings 的保留凭据命名空间。
## 测试
```powershell
uv run pytest
```
阶段 F 后端基线为 472 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。
当前基线为 562 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本:
本地模型 CPU/CUDA 安装、多模态任务、Token 用量与自定义 JSON 见 [多模态管线与模型运行开发说明](../docs/development/多模态管线与模型运行开发说明.md)。
```powershell
.venv/Scripts/python scripts/local-model-smoke.py bekko --download
.venv/Scripts/python scripts/local-model-smoke.py qwen3-asr --download --audio C:/path/to/speech.wav
.venv/Scripts/python scripts/local-model-smoke.py eres2netv2 --download --audio C:/path/to/speech.wav --reference C:/path/to/reference.wav
```
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
## 相关文档
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/development/AI-Core与Agent-Core开发说明.md`
- [后端接口契约](../docs/contracts/后端接口契约-开发版.md)
- [第二阶段接口契约](../docs/contracts/第二阶段接口契约-开发版.md)
- [多模态管线与模型运行](../docs/development/多模态管线与模型运行开发说明.md)
- [阶段 F 收尾验收](../docs/development/阶段F收尾验收记录.md)
- [AI Core 与 Agent Core](../docs/development/AI-Core与Agent-Core开发说明.md)
- [Knowledge 与 Retrieval Core](../docs/development/Knowledge与Retrieval-Core开发说明.md)
- [阶段 FEmbedding 与知识库问题](../docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md)
Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/development/Knowledge与Retrieval-Core开发说明.md`
机器可读接口以运行中的 `/openapi.json` 为准
+48 -1
View File
@@ -255,11 +255,57 @@ class ModelRequest(Contract):
class ChatRequest(ModelRequest):
conversation_id: str | None = None
conversation_id: str | None = Field(default=None, min_length=1, max_length=128)
user_message_id: str | None = Field(default=None, min_length=1, max_length=128)
assistant_message_id: str | None = Field(default=None, min_length=1, max_length=128)
conversation_title: str | None = Field(default=None, max_length=120)
use_rag: bool = True
retrieval: SearchRequest | None = None
class ConversationCreateRequest(Contract):
conversation_id: str | None = Field(default=None, min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=120)
@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
class Conversation(Contract):
conversation_id: str
title: str
created_at: datetime
updated_at: datetime
message_count: int = 0
class ConversationListResponse(Contract):
items: list[Conversation] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class ChatMessage(Contract):
message_id: str
conversation_id: str
role: Literal["user", "assistant", "system"]
content: str
created_at: datetime
citations: list[dict[str, Any]] = Field(default_factory=list)
tool_calls: list[dict[str, Any]] = Field(default_factory=list)
thinking: str | None = None
usage: dict[str, Any] | None = None
class ChatMessageListResponse(Contract):
items: list[ChatMessage] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class ModelEventType(str, Enum):
citation = "Citation"
text_delta = "TextDelta"
@@ -1050,6 +1096,7 @@ class TranscriptEditRequest(Contract):
class TranscriptNoteRequest(Contract):
update_existing: bool = False
title: str = Field(min_length=1, max_length=200)
folder: str | None = None
include_timestamps: bool = True
+27
View File
@@ -132,6 +132,33 @@ MIGRATIONS: list[str] = [
"""
ALTER TABLE blocks ADD COLUMN embedding_local_only INTEGER NOT NULL DEFAULT 0;
""",
# v7: application-owned chat conversations and messages, shared by web and desktop clients.
"""
CREATE TABLE IF NOT EXISTS chat_conversations (
conversation_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated
ON chat_conversations(updated_at DESC);
CREATE TABLE IF NOT EXISTS chat_messages (
message_id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES chat_conversations(conversation_id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT,
citations_json TEXT NOT NULL DEFAULT '[]',
tool_calls_json TEXT NOT NULL DEFAULT '[]',
usage_json TEXT,
created_at TEXT NOT NULL,
UNIQUE(conversation_id, sequence)
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation
ON chat_messages(conversation_id, sequence);
""",
]
+18 -3
View File
@@ -1,15 +1,30 @@
import asyncio
from fastapi import APIRouter
from app.services import model_diagnostics
from app.local_models import manager
from app.local_models.runtime import RuntimeConfig, configuration, configure, interpreter, runtime
router = APIRouter(prefix="/api/local-models", tags=["Local models"])
@router.get("/runtime-components/cuda")
async def cuda_status():
from app.local_models import components
return await components.status()
@router.post("/runtime-components/cuda", status_code=202)
async def install_cuda():
from app.local_models import components
return await components.install()
@router.get("")
async def list_models():
return {**manager.describe(), "runtime_installed": interpreter().is_file(), "config": configuration(),
items, diagnostics = await asyncio.gather(asyncio.to_thread(manager.describe), asyncio.to_thread(model_diagnostics.recent))
return {**items, "runtime_installed": interpreter().is_file(), "config": configuration(),
"active_models": list(runtime.active.values()), "queued_requests": len(runtime.waiters),
"last_inference": runtime.diagnostics[-1] if runtime.diagnostics else None}
"last_inference": diagnostics[-1] if diagnostics else None}
@router.put("/config")
@@ -34,5 +49,5 @@ async def delete(key: str):
@router.get("/diagnostics")
async def diagnostics():
return {"items": runtime.diagnostics, "config": configuration(), "scope": "current_process",
return {"items": await asyncio.to_thread(model_diagnostics.recent), "config": configuration(), "scope": "application_last_200_attempts",
"contains": "model_revision_device_timing_resources_only"}
+111
View File
@@ -0,0 +1,111 @@
"""User-triggered installation of the fixed optional CUDA runtime on Windows."""
import asyncio
import json
import os
import shutil
import subprocess
from app.config import BACKEND_DIR
from app.errors import ApiError
from app.local_models.process import ThreadedProcess
ROOT = BACKEND_DIR / '.venv-models-cuda'
state = {'status': 'unchecked', 'stage': '', 'cuda_available': None}
task = None
def ready():
return (ROOT / 'ready.json').is_file() and (ROOT / 'Scripts/python.exe').is_file()
async def status():
global task
if state['status'] == 'unchecked':
state.update(status='checking', stage='检查已有 CUDA 组件')
task = asyncio.create_task(run(False))
return {**state, 'supported': os.name == 'nt', 'custom_interpreter': bool(os.getenv('APP_MODEL_PYTHON'))}
async def install():
global task
from app.local_models.runtime import runtime
if os.name != 'nt':
raise ApiError(422, 'PLATFORM_UNSUPPORTED', '此安装入口目前支持 Windows。')
if task is not None and not task.done():
return await status()
if runtime.active or runtime.waiters:
raise ApiError(409, 'MODEL_IN_USE', '请等待本地模型任务结束后再安装组件。')
if state['status'] == 'installed':
return await status()
if not shutil.which('uv'):
raise ApiError(422, 'UV_NOT_INSTALLED', '后端未找到 uv,请先安装 uv 并重启后端。')
state.update(status='installing', stage='准备独立 CUDA 环境', error=None)
task = asyncio.create_task(run(True))
return await status()
async def execute(args, timeout):
process = ThreadedProcess(args, env={**os.environ, 'PYTHONIOENCODING': 'utf-8'},
limit=8192, creationflags=0x08000000 if os.name == 'nt' else 0)
process.stdin.close()
lines = []
try:
async with asyncio.timeout(timeout):
while line := await process.stdout.readline():
value = line.decode('utf-8', errors='replace').strip()
stages = {'COMPONENT:torch': '下载并安装 PyTorch CUDA(约 3 GB',
'COMPONENT:dependencies': '安装模型依赖', 'COMPONENT:verify': '验证运行组件'}
if value in stages:
state['stage'] = stages[value]
lines = (lines + [value])[-4:]
await process.wait()
if process.returncode:
raise RuntimeError('component command failed')
return lines
finally:
if process.returncode is None:
if os.name == 'nt':
await asyncio.to_thread(subprocess.run, ['taskkill', '/PID', str(process.process.pid), '/T', '/F'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=0x08000000)
else:
process.kill()
await process.wait()
await process.close()
async def run(download):
marker = ROOT / 'ready.json'
try:
if download:
marker.unlink(missing_ok=True)
await execute(['powershell.exe', '-NoProfile', '-NonInteractive', '-File',
str(BACKEND_DIR / 'scripts/install-model-runtime.ps1'), '-Device', 'cuda',
'-RuntimeDirectory', str(ROOT), '-QuietProgress'], 7200)
python = ROOT / 'Scripts/python.exe'
if not python.is_file():
state.update(status='not_installed', stage='尚未安装')
return
result = await execute([str(python), '-c',
'import json, torch, torchaudio, sentence_transformers, qwen_asr; '
'assert torch.version.cuda; '
'print(json.dumps({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()}))'], 180)
info = json.loads(result[-1])
marker.write_text(json.dumps(info), encoding='utf-8')
state.update(status='installed', stage='组件已安装', error=None, **info)
except asyncio.CancelledError:
marker.unlink(missing_ok=True)
state.update(status='interrupted', stage='安装检查已中断,可重试')
raise
except Exception:
marker.unlink(missing_ok=True)
state.update(status='failed', stage='组件安装或验证失败',
error='请检查网络、磁盘空间和 uv;可以重试。CPU 环境不受影响。')
async def shutdown():
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
if state['status'] in {'checking', 'interrupted'}:
state['status'] = 'unchecked'
+13 -1
View File
@@ -49,8 +49,20 @@ def task_key(key):
return str(model_path(key)), key
def disk_bytes(key):
total = 0
try:
root = model_path(key).resolve()
for path in root.rglob("*"):
if not path.is_symlink() and path.is_file() and path.resolve().is_relative_to(root):
total += path.stat().st_size
except OSError:
return None
return total
def describe():
return {"items": [{**spec.public(), **read_state(key)} for key, spec in CATALOG.items()]}
return {"items": [{**spec.public(), **read_state(key), "disk_bytes": disk_bytes(key)} for key, spec in CATALOG.items()]}
async def download(key):
+91 -28
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
import asyncio
import json
import os
import time
from contextlib import closing
from contextvars import ContextVar
from functools import wraps
from pathlib import Path
from typing import Literal
@@ -31,6 +33,18 @@ class RuntimeConfig(BaseModel):
runtime_context = ContextVar("runtime_config", default=None)
runtime_progress = ContextVar("runtime_progress", default=None)
embedding_priority = ContextVar("embedding_priority", default=0)
def background_embeddings(operation):
@wraps(operation)
async def wrapped(*args, **kwargs):
token = embedding_priority.set(20)
try:
return await operation(*args, **kwargs)
finally:
embedding_priority.reset(token)
return wrapped
def configuration():
@@ -55,7 +69,11 @@ def configure(request):
return request
def interpreter():
def interpreter(config=None):
from app.local_models import components
requested_device = (config or configuration()).device
if not os.getenv("APP_MODEL_PYTHON") and requested_device == "cuda" and components.ready():
return components.ROOT / "Scripts/python.exe"
return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))))
@@ -75,32 +93,86 @@ class Runtime:
return any(target in paths for paths in self.active_files.values())
async def infer(self, key, operation, payload, *, priority=10):
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先在模型配置中下载本地模型。")
if not interpreter().is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先运行本地模型 CPU/CUDA 安装脚本。")
config = configuration()
from app.services import model_diagnostics
config = configuration().model_copy(deep=True)
self.counter += 1
ticket = (priority, self.counter)
self.waiters.append(ticket)
process = None
attempt = None
queued_at = time.monotonic()
reason = None
from app.services.usage_service import usage_context
from uuid import uuid4
context = dict(usage_context.get() or {})
context.setdefault("request_id", uuid4().hex)
usage_token = usage_context.set(context)
try:
# One resident model at a time prevents overlapping CPU/GPU allocations.
while self.active or ticket != min(self.waiters):
await asyncio.sleep(0.05)
self.waiters.remove(ticket)
self.active[ticket] = key
self.active_files[ticket] = {str(Path(payload[name]).resolve()) for name in ("source", "reference") if payload.get(name)}
# Deletion may have occurred while this request was queued.
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "模型文件已被删除。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
queue_seconds = time.monotonic() - queued_at
# Keep the reservation while replacing a failed CUDA process with CPU.
for device in (["cuda", "cpu"] if config.device == "cuda" else ["cpu"]):
started = time.monotonic()
diagnostics = dict(model=CATALOG[key].repository, revision=CATALOG[key].revision,
operation=operation, source="local", requested_device=config.device,
attempted_device=device, queue_seconds=queue_seconds, fallback_reason=reason, request_id=context["request_id"])
try:
result = await self._execute(key, operation, payload, config.model_copy(update={"device": device}), diagnostics)
diagnostics.update(result.get("diagnostics", {}))
diagnostics.update(requested_device=config.device, status="completed")
if reason:
diagnostics["fallback_reason"] = reason
return result["result"]
except asyncio.CancelledError:
diagnostics.update(status="cancelled", error_code="LOCAL_MODEL_CANCELLED")
raise
except ProviderError as exc:
diagnostics.update(status="failed", error_code=exc.code)
if device == "cuda" and exc.code in {"LOCAL_CUDA_INIT_FAILED", "LOCAL_CUDA_OOM"}:
reason = exc.code
callback = runtime_progress.get()
if callback:
callback({"reset": True, "progress": 0})
continue
raise
except Exception:
diagnostics.update(status="failed", error_code="LOCAL_MODEL_INVALID_RESPONSE")
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型返回无效数据。") from None
finally:
diagnostics["requested_device"] = config.device
diagnostics["elapsed_seconds"] = time.monotonic() - started
self.diagnostics.append(model_diagnostics.record(**diagnostics))
self.diagnostics = self.diagnostics[-100:]
except asyncio.CancelledError:
if ticket not in self.active:
model_diagnostics.record(model=CATALOG[key].repository, operation=operation,
source="local", status="cancelled", error_code="LOCAL_QUEUE_CANCELLED",
requested_device=config.device, queue_seconds=time.monotonic() - queued_at)
raise
finally:
if ticket in self.waiters:
self.waiters.remove(ticket)
self.active.pop(ticket, None)
self.active_files.pop(ticket, None)
usage_context.reset(usage_token)
async def _execute(self, key, operation, payload, config, diagnostics):
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先下载本地模型。")
executable = interpreter(config)
if not executable.is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先安装本地模型运行环境。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
diagnostics.update(attempt_id=attempt.attempt_id, request_id=attempt.request_id)
process = None
try:
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
"PYTHONIOENCODING": "utf-8"}
args = (str(interpreter()), str(Path(__file__).with_name("worker.py")))
args = (str(executable), str(Path(__file__).with_name("worker.py")))
options = {"env": env, "limit": 16 * 1024 * 1024,
**({"creationflags": 0x08000000} if os.name == "nt" else {})}
try:
@@ -118,8 +190,6 @@ class Runtime:
process.stdin.close()
final = None
while line := await process.stdout.readline():
if len(line) > 16 * 1024 * 1024:
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型输出超限。")
message = json.loads(line)
if "progress" in message:
callback = runtime_progress.get()
@@ -137,26 +207,19 @@ class Runtime:
raise ProviderError("LOCAL_MODEL_PROCESS_FAILED", "本地模型进程退出,请检查依赖与资源预算。")
if not isinstance(result, dict):
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型进程未返回有效结果。")
diagnostics.update(result.get("diagnostics", {}))
if "error_code" in result:
raise ProviderError(result["error_code"], result.get("message", "本地推理失败。"))
attempt.observe(result)
attempt.completed = True
self.diagnostics.append({"model": CATALOG[key].repository, "revision": CATALOG[key].revision,
**result.get("diagnostics", {})})
self.diagnostics = self.diagnostics[-100:]
return result["result"]
return result
finally:
if ticket in self.waiters:
self.waiters.remove(ticket)
if process is not None and process.returncode is None:
process.kill()
await process.wait()
if process is not None and hasattr(process, "close"):
await process.close()
self.active.pop(ticket, None)
self.active_files.pop(ticket, None)
if attempt:
attempt.persist()
attempt.persist()
runtime = Runtime()
@@ -188,7 +251,7 @@ class LocalEmbedding:
config = (self._config or configuration()).model_copy(deep=True)
token = runtime_context.set(config)
try:
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=0)
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
finally:
runtime_context.reset(token)
+28 -7
View File
@@ -76,16 +76,25 @@ def voice_embedding(model, audio, device):
return torch.nn.functional.normalize(vector, dim=0)
class CudaInitializationError(RuntimeError):
pass
def run(request):
import torch
import psutil
config, payload = request["config"], request["payload"]
torch.set_num_threads(config["cpu_threads"])
requested = config["device"]
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
if device != "cpu":
total = torch.cuda.get_device_properties(0).total_memory
torch.cuda.set_per_process_memory_fraction(min(1.0, config["gpu_memory_limit_mb"] * 1024 ** 2 / total))
try:
device = "cuda:0" if requested == "cuda" and torch.cuda.is_available() else "cpu"
if device != "cpu":
torch.cuda.init()
total = torch.cuda.get_device_properties(0).total_memory
torch.cuda.set_per_process_memory_fraction(min(1.0, config["gpu_memory_limit_mb"] * 1024 ** 2 / total))
except Exception as exc:
raise CudaInitializationError() from exc
request["_actual_device"] = device
process = psutil.Process()
peak = [0]
stop = threading.Event()
@@ -102,6 +111,7 @@ def run(request):
path, operation = request["model_path"], request["operation"]
try:
usage = {}
audio_seconds = None
if operation == "embedding":
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(path, device=device, local_files_only=True, trust_remote_code=False,
@@ -116,6 +126,7 @@ def run(request):
device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512)
loaded = time.monotonic()
audio = decode(payload["source"])
audio_seconds = len(audio) / 16000
regions = speech_regions(audio)
language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language"))
segments = []
@@ -154,7 +165,7 @@ def run(request):
result = {"speakers": speakers}
else:
raise ValueError("Unknown inference operation")
return {"result": result, "usage": usage, "diagnostics": {"requested_device": requested, "actual_device": device,
return {"result": result, "usage": usage, "audio_seconds": audio_seconds, "diagnostics": {"requested_device": requested, "actual_device": device,
"fallback_reason": "CUDA_UNAVAILABLE" if requested == "cuda" and device == "cpu" else None,
"load_seconds": loaded - started, "inference_seconds": time.monotonic() - loaded,
"peak_memory_bytes": max(peak[0], process.memory_info().rss), "operation": operation}}
@@ -170,6 +181,16 @@ if __name__ == "__main__":
response = run(request)
except (ImportError, ModuleNotFoundError):
response = {"error_code": "LOCAL_RUNTIME_DEPENDENCY_MISSING", "message": "本地模型运行依赖不完整,请重新运行安装脚本。"}
except Exception:
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
except Exception as exc:
# Only device failures allow the host to retry once in a fresh CPU process.
import torch
cuda_failure = isinstance(exc, CudaInitializationError)
cuda_oom = request.get("_actual_device") == "cuda:0" and isinstance(exc, torch.cuda.OutOfMemoryError)
if cuda_failure or cuda_oom:
response = {"error_code": "LOCAL_CUDA_OOM" if cuda_oom else "LOCAL_CUDA_INIT_FAILED",
"message": "CUDA 运行失败,将释放进程并重试 CPU。"}
else:
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
if "error_code" in response:
response["diagnostics"] = {"requested_device": request["config"]["device"], "actual_device": request.get("_actual_device", "unknown")}
sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8"))
+2
View File
@@ -26,6 +26,8 @@ async def lifespan(_: FastAPI):
yield
finally:
await transcription_service.shutdown()
from app.local_models import components
await components.shutdown()
from app.local_models import manager
for _, key in list(manager._downloads):
await manager.cancel_download(key)
+38 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json
import hashlib
from contextlib import closing
from pathlib import Path
from uuid import uuid4
@@ -22,14 +23,17 @@ MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".tx
@router.post("/attachments", status_code=201)
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255)):
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255),
idempotency_key: str | None = Header(None, min_length=16, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")):
suffix = Path(filename).suffix.lower()
if suffix not in MEDIA_SUFFIXES:
raise ApiError(422, "UNSUPPORTED_MEDIA", "Unsupported attachment extension.")
attachment_id = f"media_{uuid4().hex}{suffix}"
identity = hashlib.sha256(idempotency_key.encode()).hexdigest() if idempotency_key else uuid4().hex
attachment_id = f"media_{identity}{suffix}"
destination = attachment_path(attachment_id)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".upload")
temporary = destination.with_suffix(destination.suffix + f".{uuid4().hex}.upload")
digest = hashlib.sha256()
size = 0
try:
with temporary.open("xb") as stream:
@@ -37,10 +41,40 @@ async def upload_attachment(request: Request, filename: str = Query(min_length=1
size += len(chunk)
if size > MAX_UPLOAD_BYTES:
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.")
digest.update(chunk)
stream.write(chunk)
if not size:
raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.")
temporary.replace(destination)
content_hash = digest.hexdigest()
if idempotency_key:
with closing(connect()) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS media_upload_idempotency (idempotency_key TEXT PRIMARY KEY, attachment_id TEXT NOT NULL, filename TEXT NOT NULL, content_hash TEXT NOT NULL)")
conn.execute("BEGIN IMMEDIATE")
try:
row = conn.execute("SELECT attachment_id,filename,content_hash FROM media_upload_idempotency WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if row:
if row["filename"] != Path(filename).name or row["content_hash"] != content_hash:
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
existing = attachment_path(row["attachment_id"])
if not existing.is_file() or hashlib.sha256(existing.read_bytes()).hexdigest() != content_hash:
raise ApiError(409, "IDEMPOTENCY_EXPIRED", "该上传标识对应的附件已不存在,请开始一次新提交。")
attachment_id = row["attachment_id"]
else:
if destination.exists() and hashlib.sha256(destination.read_bytes()).hexdigest() != content_hash:
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
if not destination.exists():
temporary.replace(destination)
conn.execute("INSERT INTO media_upload_idempotency VALUES (?,?,?,?)",
(idempotency_key, attachment_id, Path(filename).name, content_hash))
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
elif destination.exists():
if hashlib.sha256(destination.read_bytes()).digest() != digest.digest():
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
else:
temporary.replace(destination)
finally:
temporary.unlink(missing_ok=True)
return {"attachment_id": attachment_id, "filename": Path(filename).name, "size": size}
+57 -2
View File
@@ -1,12 +1,67 @@
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.contracts import ProviderCreateRequest, ProviderConfig, ModelRequest, Message, MessageRole
from app.providers.factory import ProviderFactory
from app.request_overrides import apply_overrides
from app.request_overrides import RequestOverride, apply_overrides
router = APIRouter(prefix="/api/providers", tags=["Providers"])
class RulesTransfer(BaseModel):
version: int = Field(default=1, ge=1, le=1)
request_overrides: list[RequestOverride] = Field(max_length=100)
@router.post("/request-rules/validate")
async def validate_rules(request: RulesTransfer):
return request
class ProbeRequest(BaseModel):
provider: ProviderCreateRequest
stream: bool = True
@router.post("/request-probe")
async def probe(request: ProbeRequest):
"""Explicit user-triggered inference; no vault context, tools or media uploads."""
import asyncio
from contextlib import aclosing
from app.container import container
from app.errors import ApiError
from app.providers.base import ProviderError
from app.providers.factory import UnsupportedProviderError
config = ProviderConfig(provider_id="request-probe", **request.provider.model_dump())
if not config.default_model:
raise ApiError(422, "MODEL_REQUIRED", "请填写要验证的模型 ID。")
try:
adapter = container.provider_factory.build(config)
model_request = ModelRequest(provider_id=config.provider_id, model=config.default_model,
messages=[Message(role=MessageRole.user, content="Reply with OK.")], max_tokens=32)
received = False
async with asyncio.timeout(45):
if request.stream:
async with aclosing(adapter.stream(model_request)) as events:
async for event in events:
if event.event.value in {"TextDelta", "ThinkingDelta"}:
received = received or bool(str(event.data.get("text") or "").strip())
if event.event.value == "Error":
raise ProviderError("PROVIDER_PROBE_FAILED", "模型返回了错误事件。")
else:
response = await adapter.complete(model_request)
received = bool(response.text and response.text.strip())
if not received:
raise ApiError(422, "PROVIDER_EMPTY_RESPONSE", "请求未返回有效文本,不能标记验证通过。")
except ProviderError as exc:
raise ApiError(502, exc.code, "推理验证失败,请检查模型、凭据和自定义参数。") from exc
except TimeoutError as exc:
raise ApiError(504, "PROVIDER_TIMEOUT", "推理验证超时。") from exc
except UnsupportedProviderError as exc:
raise ApiError(422, "PROVIDER_TYPE_UNSUPPORTED", "该协议不支持推理验证。") from exc
return {"success": True, "stream": request.stream, "model": config.default_model,
"message": "当前请求配置已通过实际推理验证。"}
class PreviewRequest(BaseModel):
provider: ProviderCreateRequest
stream: bool = True
+17
View File
@@ -6,6 +6,8 @@ available only for explicitly injected tests and protocol fixtures.
from __future__ import annotations
import hashlib
import asyncio
import time
import json
import math
from dataclasses import dataclass, field, replace
@@ -179,6 +181,7 @@ class ModelRoutingService:
payload = apply_overrides(kwargs.get(field, {}), provider.request_overrides, capability)
kwargs[field] = payload if field == "json" else {key: json.dumps(value) if isinstance(value, (dict, list, bool)) or value is None else value for key, value in payload.items()}
attempt = UsageAttempt(binding.provider_id, binding.model, provider.provider_type.value, capability)
started = time.monotonic()
try:
async with httpx.AsyncClient(timeout=30, transport=self.transport) as client:
async with client.stream("POST", url, headers=headers, **kwargs) as response:
@@ -202,6 +205,11 @@ class ModelRoutingService:
raise invalid_response() from exc
finally:
attempt.persist()
from app.services.model_diagnostics import record
task = asyncio.current_task()
status = "completed" if attempt.completed else ("cancelled" if task and task.cancelling() else "failed")
record(model=binding.model, operation=capability, source="api", status=status,
attempt_id=attempt.attempt_id, request_id=attempt.request_id, elapsed_seconds=time.monotonic() - started)
if not isinstance(data, dict) or data.get("error"):
raise invalid_response()
return data, url
@@ -255,6 +263,9 @@ class ModelRoutingService:
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
except ProviderError as exc:
reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
from app.local_models.runtime import LocalEmbedding
local_embedding = self.local_embedding.snapshot() if isinstance(self.local_embedding, LocalEmbedding) else self.local_embedding
try:
@@ -314,6 +325,9 @@ class ModelRoutingService:
return RoutedTranscript(text=text, source="api", segments=segments)
except ProviderError as exc:
reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
try:
text = await self.local_speech.transcribe(source, language)
if isinstance(text, RoutedTranscript):
@@ -346,6 +360,9 @@ class ModelRoutingService:
return SpeakerMatchResult(score=score, source="api")
except ProviderError as exc:
reason = exc.code
from app.services.model_diagnostics import record
record(model=binding.model, source="api", status="fallback", error_code=reason,
fallback_reason=reason, operation="model_routing")
try:
score = await self.local_speech.match(source, reference)
if not finite_number(score) or not 0 <= score <= 1:
+123 -3
View File
@@ -1,4 +1,5 @@
import asyncio
import json
from collections.abc import AsyncIterator
from contextlib import aclosing
from datetime import datetime, timezone
@@ -15,6 +16,10 @@ from app.contracts import (
AgentRunListResponse,
AgentTraceResponse,
ChatRequest,
ChatMessageListResponse,
Conversation,
ConversationCreateRequest,
ConversationListResponse,
BenchmarkDatasetListResponse,
BenchmarkEventType,
BenchmarkKind,
@@ -321,6 +326,40 @@ async def clear_search_history() -> dict[str, list[str]]:
return {"queries": []}
@router.get("/chat/conversations", response_model=ConversationListResponse, tags=["Chat"])
async def list_chat_conversations(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> ConversationListResponse:
from app.services import chat_history
items, total = chat_history.list_conversations(limit, offset)
return ConversationListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
@router.post("/chat/conversations", response_model=Conversation, status_code=201, tags=["Chat"])
async def create_chat_conversation(request: ConversationCreateRequest) -> Conversation:
from app.services import chat_history
return chat_history.create(request.title, request.conversation_id)
@router.get("/chat/conversations/{conversation_id}/messages", response_model=ChatMessageListResponse, tags=["Chat"])
async def list_chat_messages(
conversation_id: str,
limit: int = Query(default=500, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> ChatMessageListResponse:
from app.services import chat_history
items, total = chat_history.list_messages(conversation_id, limit, offset)
return ChatMessageListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
@router.delete("/chat/conversations/{conversation_id}", response_model=OperationResponse, tags=["Chat"])
async def delete_chat_conversation(conversation_id: str) -> OperationResponse:
from app.services import chat_history
if not chat_history.delete(conversation_id):
raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id})
return OperationResponse(status="completed", resource_id=conversation_id, message="deleted")
@router.post(
"/chat",
response_class=StreamingResponse,
@@ -333,14 +372,38 @@ async def clear_search_history() -> dict[str, list[str]]:
tags=["Chat"],
)
async def chat(request: ChatRequest) -> StreamingResponse:
from app.services import chat_history
conversation_id = request.conversation_id
assistant_message_id = request.assistant_message_id or f"message_{uuid4().hex}"
if conversation_id:
user_message = next(
(message for message in reversed(request.messages) if message.role.value == "user" and message.content.strip()),
None,
)
if user_message is not None:
chat_history.append_message(
conversation_id,
message_id=request.user_message_id or f"message_{uuid4().hex}",
role="user",
content=user_message.content,
title=request.conversation_title or user_message.content[:30],
)
provider = provider_or_404(request.provider_id)
async def stream() -> AsyncIterator[str]:
sequence = 0
assistant_content = ""
assistant_thinking = ""
citations: list[dict] = []
tool_calls: list[dict] = []
argument_buffers: dict[str, str] = {}
usage: dict | None = None
try:
from app.services.chat_context import prepare
grounded_request, citations = await prepare(request)
for citation in citations:
grounded_request, grounded_citations = await prepare(request)
for citation in grounded_citations:
citations.append(citation)
event = ModelEvent(event=ModelEventType.citation, sequence=sequence,
data=citation, timestamp=utc_now())
sequence += 1
@@ -349,13 +412,58 @@ async def chat(request: ChatRequest) -> StreamingResponse:
async for event in events:
event = event.model_copy(update={"sequence": sequence})
sequence += 1
if event.event == ModelEventType.text_delta:
assistant_content += str(event.data.get("text", ""))
elif event.event == ModelEventType.thinking_delta:
assistant_thinking += str(event.data.get("text", ""))
elif event.event == ModelEventType.tool_call_start:
tool_calls.append({
"tool_call_id": str(event.data.get("tool_call_id", "")),
"name": str(event.data.get("name", "unknown")),
"parameters": event.data.get("arguments") if isinstance(event.data.get("arguments"), dict) else {},
"status": "running",
})
elif event.event == ModelEventType.tool_call_delta:
call_id = str(event.data.get("tool_call_id", ""))
call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None)
if call is not None:
delta = event.data.get("arguments_delta")
if isinstance(delta, str):
argument_buffers[call_id] = argument_buffers.get(call_id, "") + delta
try:
parsed_arguments = json.loads(argument_buffers[call_id])
if isinstance(parsed_arguments, dict):
call["parameters"] = parsed_arguments
except ValueError:
pass
arguments = event.data.get("arguments")
if isinstance(arguments, dict):
call["parameters"].update(arguments)
elif event.event == ModelEventType.tool_call_end:
call_id = str(event.data.get("tool_call_id", ""))
call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None)
if call is not None:
call["status"] = "completed"
elif event.event == ModelEventType.usage:
input_tokens = int(event.data.get("input_tokens", 0))
output_tokens = int(event.data.get("output_tokens", 0))
usage = {"input_tokens": input_tokens, "output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens}
elif event.event == ModelEventType.error:
if assistant_content:
assistant_content += "\n\n"
assistant_content += str(event.data.get("message", "Model generation failed."))
yield as_sse(event.event.value, event.model_dump_json())
except Exception as exc:
failure_message = exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"
if assistant_content:
assistant_content += "\n\n"
assistant_content += failure_message
error = ModelEvent(
event=ModelEventType.error,
sequence=sequence,
data={"code": exc.code if isinstance(exc, ApiError) else "CHAT_FAILED",
"message": exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"},
"message": failure_message},
timestamp=utc_now(),
)
done = ModelEvent(
@@ -364,6 +472,18 @@ async def chat(request: ChatRequest) -> StreamingResponse:
)
yield as_sse(error.event.value, error.model_dump_json())
yield as_sse(done.event.value, done.model_dump_json())
finally:
if conversation_id and (assistant_content or assistant_thinking or citations or tool_calls):
chat_history.append_message(
conversation_id,
message_id=assistant_message_id,
role="assistant",
content=assistant_content,
thinking=assistant_thinking or None,
citations=citations,
tool_calls=tool_calls,
usage=usage,
)
return StreamingResponse(stream(), media_type="text/event-stream")
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from contextlib import closing
from datetime import datetime, timezone
import json
import sqlite3
from typing import Any
from uuid import uuid4
from app.contracts import ChatMessage, Conversation
from app.database.db import connect, transaction
from app.errors import ApiError
def _now() -> datetime:
return datetime.now(timezone.utc)
def _conversation(row) -> Conversation:
return Conversation(
conversation_id=row["conversation_id"],
title=row["title"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
message_count=row["message_count"],
)
def _message(row) -> ChatMessage:
citations = json.loads(row["citations_json"])
for citation in citations:
if isinstance(citation.get("heading_path"), list):
citation["heading_path"] = " / ".join(str(part) for part in citation["heading_path"])
return ChatMessage(
message_id=row["message_id"],
conversation_id=row["conversation_id"],
role=row["role"],
content=row["content"],
thinking=row["thinking"],
citations=citations,
tool_calls=json.loads(row["tool_calls_json"]),
usage=json.loads(row["usage_json"]) if row["usage_json"] else None,
created_at=datetime.fromisoformat(row["created_at"]),
)
def create(title: str, conversation_id: str | None = None) -> Conversation:
conversation_id = conversation_id or f"conversation_{uuid4().hex}"
now = _now().isoformat()
with closing(connect()) as conn, transaction(conn):
try:
conn.execute(
"INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)",
(conversation_id, title.strip(), now, now),
)
except sqlite3.IntegrityError as exc:
raise ApiError(409, "CONVERSATION_ALREADY_EXISTS", "conversation already exists", {"conversation_id": conversation_id}) from exc
result = get(conversation_id)
assert result is not None
return result
def get(conversation_id: str) -> Conversation | None:
with closing(connect()) as conn:
row = conn.execute(
"""SELECT c.*, COUNT(m.message_id) AS message_count
FROM chat_conversations c LEFT JOIN chat_messages m USING(conversation_id)
WHERE c.conversation_id=? GROUP BY c.conversation_id""",
(conversation_id,),
).fetchone()
return _conversation(row) if row else None
def list_conversations(limit: int, offset: int) -> tuple[list[Conversation], int]:
with closing(connect()) as conn:
total = conn.execute("SELECT COUNT(*) FROM chat_conversations").fetchone()[0]
rows = conn.execute(
"""SELECT c.*, COUNT(m.message_id) AS message_count
FROM chat_conversations c LEFT JOIN chat_messages m USING(conversation_id)
GROUP BY c.conversation_id ORDER BY c.updated_at DESC LIMIT ? OFFSET ?""",
(limit, offset),
).fetchall()
return [_conversation(row) for row in rows], total
def list_messages(conversation_id: str, limit: int, offset: int) -> tuple[list[ChatMessage], int]:
if get(conversation_id) is None:
raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id})
with closing(connect()) as conn:
total = conn.execute("SELECT COUNT(*) FROM chat_messages WHERE conversation_id=?", (conversation_id,)).fetchone()[0]
rows = conn.execute(
"SELECT * FROM chat_messages WHERE conversation_id=? ORDER BY sequence LIMIT ? OFFSET ?",
(conversation_id, limit, offset),
).fetchall()
return [_message(row) for row in rows], total
def delete(conversation_id: str) -> bool:
with closing(connect()) as conn, transaction(conn):
return conn.execute("DELETE FROM chat_conversations WHERE conversation_id=?", (conversation_id,)).rowcount > 0
def append_message(
conversation_id: str,
*,
message_id: str,
role: str,
content: str,
title: str | None = None,
thinking: str | None = None,
citations: list[dict[str, Any]] | None = None,
tool_calls: list[dict[str, Any]] | None = None,
usage: dict[str, Any] | None = None,
) -> None:
now = _now().isoformat()
clean_title = (title or "").strip() or content[:30].strip() or "New conversation"
with closing(connect()) as conn:
conn.execute("BEGIN IMMEDIATE")
try:
_append_message_in_transaction(
conn, conversation_id, message_id=message_id, role=role, content=content,
title=clean_title, thinking=thinking, citations=citations, tool_calls=tool_calls,
usage=usage, now=now,
)
conn.execute("COMMIT")
except BaseException:
if conn.in_transaction:
conn.execute("ROLLBACK")
raise
def _append_message_in_transaction(
conn,
conversation_id: str,
*,
message_id: str,
role: str,
content: str,
title: str,
thinking: str | None,
citations: list[dict[str, Any]] | None,
tool_calls: list[dict[str, Any]] | None,
usage: dict[str, Any] | None,
now: str,
) -> None:
conversation = conn.execute(
"SELECT 1 FROM chat_conversations WHERE conversation_id=?", (conversation_id,)
).fetchone()
if conversation is None:
# A stream may finish after deletion. Check under BEGIN IMMEDIATE so
# deletion and assistant persistence cannot recreate an orphaned chat.
if role == "assistant":
return
conn.execute(
"INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)",
(conversation_id, title, now, now),
)
count = conn.execute(
"SELECT COUNT(*) FROM chat_messages WHERE conversation_id=?", (conversation_id,)
).fetchone()[0]
if count == 0:
conn.execute(
"UPDATE chat_conversations SET title=? WHERE conversation_id=?",
(title, conversation_id),
)
existing = conn.execute(
"SELECT conversation_id FROM chat_messages WHERE message_id=?", (message_id,)
).fetchone()
if existing:
if existing["conversation_id"] != conversation_id:
raise ApiError(409, "MESSAGE_ID_CONFLICT", "message id belongs to another conversation")
return
sequence = conn.execute(
"SELECT COALESCE(MAX(sequence), -1) + 1 FROM chat_messages WHERE conversation_id=?",
(conversation_id,),
).fetchone()[0]
conn.execute(
"""INSERT INTO chat_messages(message_id,conversation_id,sequence,role,content,thinking,citations_json,tool_calls_json,usage_json,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)""",
(message_id, conversation_id, sequence, role, content, thinking,
json.dumps(citations or [], ensure_ascii=False), json.dumps(tool_calls or [], ensure_ascii=False),
json.dumps(usage, ensure_ascii=False) if usage is not None else None, now),
)
conn.execute(
"UPDATE chat_conversations SET updated_at=? WHERE conversation_id=?",
(now, conversation_id),
)
+31 -10
View File
@@ -19,8 +19,10 @@ async def create_transcript_note(job_id, options):
job = require_job(job_id)
if job.status != "completed":
raise ApiError(409, "TRANSCRIPT_NOT_READY", "Only completed transcripts can become notes.")
options_hash = hashlib.sha256(options.model_dump_json().encode()).hexdigest()
options_hash = hashlib.sha256(options.model_copy(update={"update_existing": False}).model_dump_json(exclude={"update_existing"}).encode()).hexdigest()
with closing(connect()) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS media_note_baselines (note_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL)")
previous = conn.execute("SELECT m.note_id,b.content_hash FROM media_notes m LEFT JOIN media_note_baselines b ON b.note_id=m.note_id WHERE m.job_id=? AND m.options_hash=? ORDER BY m.revision DESC LIMIT 1", (job_id, options_hash)).fetchone()
row = conn.execute("SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, options_hash)).fetchone()
if row:
@@ -44,15 +46,34 @@ async def create_transcript_note(job_id, options):
if job.local_only:
# Persist the indexing policy in the Vault, including later rebuilds.
lines = ["---", "embedding_local_only: true", "---", "", *lines]
try:
note = await note_service.create_note(title=title, markdown="\n".join(lines), folder=options.folder, tags=["转写"])
except ApiError as exc:
if exc.code != "RESOURCE_CONFLICT" or "note_id" not in exc.details:
raise
# Recover a crash between successful note creation and linking the job.
note = await note_service.get_note(exc.details["note_id"])
if note is None or marker not in note.markdown:
raise
markdown = "\n".join(lines)
if options.update_existing:
if previous is None or previous[1] is None:
raise ApiError(409, "NOTE_UPDATE_BASELINE_MISSING", "没有可安全更新的导出记录,请先创建新笔记。")
current = await note_service.get_note(previous[0])
if current is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "已导出笔记不存在。")
# Recover a successful update if linking failed after the Vault write.
if current.markdown == markdown:
note = current
else:
note = await note_service.update_note(previous[0], markdown=markdown, expected_content_hash=previous[1])
else:
note = await _create_note(title, markdown, options, marker)
with closing(connect()) as conn, transaction(conn):
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, options_hash, note.note_id))
conn.execute("INSERT OR REPLACE INTO media_note_baselines VALUES (?,?)", (note.note_id, hashlib.sha256(markdown.encode()).hexdigest()))
return note
async def _create_note(title, markdown, options, marker):
try:
note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"])
except ApiError as exc:
if exc.code != "RESOURCE_CONFLICT" or "note_id" not in exc.details:
raise
# Recover a crash between successful note creation and linking the job.
note = await note_service.get_note(exc.details["note_id"])
if note is None or marker not in note.markdown:
raise
return note
+37
View File
@@ -0,0 +1,37 @@
"""Bounded, durable diagnostics. No payloads, paths, exception text or credentials."""
import json
import logging
import math
from contextlib import closing
from datetime import datetime, timezone
from app.database.db import connect, transaction
TEXT = {"model", "revision", "operation", "source", "requested_device", "actual_device",
"attempted_device", "fallback_reason", "error_code", "status", "request_id", "attempt_id"}
NUMBERS = {"load_seconds", "inference_seconds", "elapsed_seconds", "peak_memory_bytes", "queue_seconds"}
def connection():
conn = connect()
conn.execute("CREATE TABLE IF NOT EXISTS model_diagnostics (id INTEGER PRIMARY KEY AUTOINCREMENT, record_json TEXT NOT NULL)")
return conn
def record(**values):
safe = {key: value[:240] for key, value in values.items() if key in TEXT and isinstance(value, str)}
safe.update({key: value for key, value in values.items()
if key in NUMBERS and type(value) in (float, int) and math.isfinite(value) and value >= 0})
safe["timestamp"] = datetime.now(timezone.utc).isoformat()
try:
with closing(connection()) as conn, transaction(conn):
conn.execute("INSERT INTO model_diagnostics(record_json) VALUES (?)", (json.dumps(safe),))
conn.execute("DELETE FROM model_diagnostics WHERE id NOT IN (SELECT id FROM model_diagnostics ORDER BY id DESC LIMIT 200)")
except Exception:
logging.getLogger(__name__).warning("Model diagnostic persistence failed")
return safe
def recent():
with closing(connection()) as conn:
return [json.loads(row[0]) for row in conn.execute("SELECT record_json FROM model_diagnostics ORDER BY id")]
+8 -2
View File
@@ -17,7 +17,7 @@ from app.contracts import Note, NoteBlock, NoteSummary
from app.database.db import connect, transaction
from app.errors import ApiError
from app.knowledge.parser import ParsedNote, parse_note
from app.local_models.runtime import LocalEmbedding
from app.local_models.runtime import LocalEmbedding, background_embeddings
from app.retrieval import routed_vectors
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
from app.services.coordination import serialized_vault_mutation
@@ -77,6 +77,7 @@ def _delete_markdown(rel_path: str) -> None:
PreparedIndex = tuple[list[list[float]], routed_vectors.RemoteEmbeddings | None]
@background_embeddings
async def prepare_note_index(parsed: ParsedNote, *, strict=False) -> PreparedIndex:
"""Compute vectors before opening a write transaction (including API I/O)."""
texts = [block.content for block in parsed.blocks]
@@ -180,13 +181,18 @@ async def get_note(note_id: str) -> Note | None:
@serialized_vault_mutation
async def update_note(
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None, expected_content_hash: str | None = None
) -> Note:
record = repository.get_note_record(note_id)
if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
old_md = _read_markdown(record.file_path)
if expected_content_hash is not None:
import hashlib
if hashlib.sha256(old_md.encode()).hexdigest() != expected_content_hash:
raise ApiError(409, "NOTE_CONTENT_CONFLICT", "笔记已被编辑,请保留现有内容或导出为新笔记。")
new_md = old_md if markdown is None else markdown
# PATCH 语义:tags=None 保持原标签;[] 清空;非空列表替换(区别于 create 的 frontmatter 推导)
effective_tags = record.tags if tags is None else tags
@@ -134,6 +134,10 @@ async def _execute(job_id, request, routing=None):
from app.contracts import TranscriptSegment
token = runtime_context.set(RuntimeConfig.model_validate(job.model_snapshot.get("local_runtime", {})))
def progress(message):
if message.get("reset"):
job.segments = []; job.progress = 0
save(job, "AttemptRestarted")
return
job.progress = max(0.0, min(0.99, message["progress"]))
job.segments.append(TranscriptSegment.model_validate(message["segment"]))
save(job, "SegmentReady")
+14 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import math
from contextlib import closing
from contextvars import ContextVar
from datetime import datetime, timezone
@@ -53,6 +54,7 @@ class UsageAttempt:
self.capability, self.source = capability, source
self.started_at = datetime.now(timezone.utc).isoformat()
self.raw = {}
self.audio_seconds = None
self.completed = False
context = usage_context.get() or {}
self.request_id = context.get("request_id") or uuid4().hex
@@ -61,6 +63,9 @@ class UsageAttempt:
def observe(self, data):
if not isinstance(data, dict):
return
duration = data.get("audio_seconds", data.get("duration"))
if self.capability in {"transcription", "speaker_matching"} and type(duration) in (int, float) and math.isfinite(duration) and 0 <= duration <= 7200:
self.audio_seconds = max(self.audio_seconds or 0, duration)
values = [data.get("usage"), (data.get("message") or {}).get("usage") if isinstance(data.get("message"), dict) else None,
(data.get("response") or {}).get("usage") if isinstance(data.get("response"), dict) else None]
if self.protocol == "ollama":
@@ -87,7 +92,7 @@ class UsageAttempt:
miss = inputs - hit
if hit is not None and inputs is not None and hit > inputs:
hit, miss = None, None
return dict(input_tokens=inputs, output_tokens=outputs,
return dict(audio_seconds=self.audio_seconds, input_tokens=inputs, output_tokens=outputs,
total_tokens=inputs + outputs if inputs is not None and outputs is not None else first("total_tokens"),
cache_hit_tokens=hit, cache_miss_tokens=miss, cache_write_tokens=write,
reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"))
@@ -103,7 +108,7 @@ class UsageAttempt:
def aggregate(start, end, provider_id=None, model=None, source=None):
query = "SELECT counters_json,completed FROM model_usage WHERE started_at>=? AND started_at<?"
query = "SELECT counters_json,completed,capability FROM model_usage WHERE started_at>=? AND started_at<?"
args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
if value:
@@ -115,8 +120,14 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
totals = {key: None for key in METRICS}
coverage = {key: 0 for key in METRICS}
hits, eligible_input, cache_requests = 0, 0, 0
audio_requests, audio_covered, audio_seconds = 0, 0, None
for row in rows:
if row[2] in {"transcription", "speaker_matching"}:
audio_requests += 1
counts = json.loads(row[0])
if counts.get("audio_seconds") is not None:
audio_covered += 1
audio_seconds = (audio_seconds or 0) + counts["audio_seconds"]
for key in METRICS:
if counts.get(key) is not None:
totals[key] = (totals[key] or 0) + counts[key]
@@ -125,7 +136,7 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
hits += counts["cache_hit_tokens"]
eligible_input += counts["input_tokens"] if counts.get("input_tokens") is not None else counts["cache_hit_tokens"] + counts["cache_miss_tokens"]
cache_requests += 1
return {"totals": totals, "coverage": coverage, "request_count": len(rows),
return {"audio_request_count": audio_requests, "audio_seconds": audio_seconds, "audio_covered_requests": audio_covered, "totals": totals, "coverage": coverage, "request_count": len(rows),
"complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests,
"cache_hit_rate": hits / eligible_input if eligible_input else None,
"options": [dict(row) for row in options], "start": start, "end": end,
+12 -4
View File
@@ -1,9 +1,12 @@
param(
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu'
[ValidateSet('cpu', 'cuda')][string]$Device = 'cpu',
[string]$RuntimeDirectory = '',
[switch]$QuietProgress
)
$ErrorActionPreference = 'Stop'
$uvOptions = if ($QuietProgress) { @('--quiet') } else { @() }
$backendRoot = Split-Path $PSScriptRoot -Parent
$runtimeRoot = Join-Path $backendRoot '.venv-models'
$runtimeRoot = if ($RuntimeDirectory) { [IO.Path]::GetFullPath($RuntimeDirectory) } else { Join-Path $backendRoot '.venv-models' }
$runtimePython = Join-Path $runtimeRoot 'Scripts/python.exe'
if (!(Test-Path -LiteralPath $runtimePython)) {
& uv venv --python 3.12 $runtimeRoot
@@ -11,9 +14,14 @@ if (!(Test-Path -LiteralPath $runtimePython)) {
}
# CPU is the default. CUDA wheels include the runtime, not the NVIDIA driver.
$torchIndex = if ($Device -eq 'cuda') { 'https://download.pytorch.org/whl/cu128' } else { 'https://download.pytorch.org/whl/cpu' }
& uv pip install --python $runtimePython --index-url $torchIndex 'torch==2.9.1' 'torchaudio==2.9.1'
$wheelVariant = if ($Device -eq 'cuda') { 'cu128' } else { 'cpu' }
# Pin the local version too: ==2.9.1 alone also accepts an already-installed CPU wheel.
Write-Output 'COMPONENT:torch'
& uv @uvOptions pip install --python $runtimePython --index-url $torchIndex "torch==2.9.1+$wheelVariant" "torchaudio==2.9.1+$wheelVariant"
if ($LASTEXITCODE -ne 0) { throw 'PyTorch 安装失败' }
& uv pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt')
Write-Output 'COMPONENT:dependencies'
& uv @uvOptions pip install --python $runtimePython -r (Join-Path $PSScriptRoot 'model-requirements.lock') -c (Join-Path $PSScriptRoot 'model-requirements.txt')
if ($LASTEXITCODE -ne 0) { throw '模型依赖安装失败' }
Write-Output 'COMPONENT:verify'
& $runtimePython -c 'import torch; print({"torch":torch.__version__,"cuda_available":torch.cuda.is_available()})'
if ($LASTEXITCODE -ne 0) { throw '模型运行环境检查失败' }
+121
View File
@@ -0,0 +1,121 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
import pytest
from app.contracts import ChatRequest, ModelEvent, ModelEventType
from app.main import app
from app.services import chat_history
def test_chat_history_survives_new_connections_and_deletes_messages() -> None:
conversation = chat_history.create("Persistent chat", "conversation-1")
chat_history.append_message(
conversation.conversation_id,
message_id="user-1",
role="user",
content="question",
)
chat_history.append_message(
conversation.conversation_id,
message_id="assistant-1",
role="assistant",
content="answer",
citations=[{"note_id": "note-1", "heading_path": ["Heading"]}],
usage={"input_tokens": 2, "output_tokens": 1, "total_tokens": 3},
)
listed, total = chat_history.list_conversations(50, 0)
messages, message_total = chat_history.list_messages("conversation-1", 50, 0)
assert total == 1
assert listed[0].message_count == 2
assert message_total == 2
assert messages[1].citations[0]["note_id"] == "note-1"
assert messages[1].usage["total_tokens"] == 3
assert chat_history.delete("conversation-1") is True
assert chat_history.list_conversations(50, 0)[1] == 0
def test_chat_stream_persists_user_and_assistant_messages(monkeypatch) -> None:
from app import routes
class Adapter:
async def stream(self, _request):
now = datetime.now(timezone.utc)
yield ModelEvent(event=ModelEventType.text_delta, data={"text": "persisted answer"}, timestamp=now)
yield ModelEvent(event=ModelEventType.usage, data={"input_tokens": 4, "output_tokens": 2}, timestamp=now)
yield ModelEvent(event=ModelEventType.done, timestamp=now)
monkeypatch.setattr(routes, "provider_or_404", lambda _provider_id: SimpleNamespace(adapter=Adapter()))
payload = {
"provider_id": "configured",
"model": "model",
"conversation_id": "conversation-stream",
"user_message_id": "user-stream",
"assistant_message_id": "assistant-stream",
"conversation_title": "Persist this",
"use_rag": False,
"messages": [{"role": "user", "content": "question"}],
}
with TestClient(app) as client:
with client.stream("POST", "/api/chat", json=payload) as response:
assert response.status_code == 200
assert "persisted answer" in "".join(response.iter_text())
messages = client.get("/api/chat/conversations/conversation-stream/messages").json()["items"]
conversations = client.get("/api/chat/conversations").json()["items"]
assert [message["content"] for message in messages] == ["question", "persisted answer"]
assert messages[1]["usage"]["total_tokens"] == 6
assert conversations[0]["title"] == "Persist this"
assert conversations[0]["message_count"] == 2
def test_chat_conversation_crud_api() -> None:
with TestClient(app) as client:
created = client.post("/api/chat/conversations", json={"conversation_id": "crud", "title": "CRUD"})
assert created.status_code == 201
assert client.get("/api/chat/conversations").json()["page"]["total"] == 1
assert client.get("/api/chat/conversations/crud/messages").json()["items"] == []
assert client.delete("/api/chat/conversations/crud").status_code == 200
missing = client.get("/api/chat/conversations/crud/messages")
assert missing.status_code == 404
assert missing.json()["error"]["code"] == "CONVERSATION_NOT_FOUND"
@pytest.mark.parametrize("close_early", [True, False])
@pytest.mark.parametrize("deleted", [True, False])
def test_stream_finalization_respects_conversation_deletion(monkeypatch, close_early, deleted) -> None:
from app import routes
class Adapter:
async def stream(self, _request):
now = datetime.now(timezone.utc)
yield ModelEvent(event=ModelEventType.text_delta, data={"text": "partial answer"}, timestamp=now)
yield ModelEvent(event=ModelEventType.done, timestamp=now)
monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter()))
async def scenario():
response = await routes.chat(ChatRequest(
provider_id="configured", model="model", conversation_id="stream",
use_rag=False, messages=[{"role": "user", "content": "question"}],
))
await anext(response.body_iterator)
if deleted:
assert chat_history.delete("stream")
if close_early:
await response.body_iterator.aclose()
else:
async for _ in response.body_iterator:
pass
if deleted:
assert chat_history.get("stream") is None
assert chat_history.list_conversations(50, 0)[1] == 0
else:
messages, total = chat_history.list_messages("stream", 50, 0)
assert total == 2
assert [message.content for message in messages] == ["question", "partial answer"]
asyncio.run(scenario())
+2 -2
View File
@@ -47,7 +47,7 @@ def test_local_model_missing_is_explicit():
def test_cancel_reaps_active_model_process(monkeypatch):
import app.local_models.runtime as module
monkeypatch.setattr(module,'read_state',lambda key:{'status':'installed'})
monkeypatch.setattr(module,'interpreter',lambda:Path(sys.executable))
monkeypatch.setattr(module,'interpreter',lambda *_:Path(sys.executable))
class Input:
def write(self, value):
request = json.loads(value)
@@ -92,7 +92,7 @@ def test_subprocess_fallback_runs_and_reaps_real_worker(monkeypatch, tmp_path, c
import app.local_models.process as process_module
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable))
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
worker = tmp_path / 'worker.py'
worker.write_text(
'import json,sys,time\n'
@@ -0,0 +1,223 @@
"""Finalization regressions: device recovery, durable facts and guarded writes."""
import asyncio
import json
import sys
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.errors import ApiError
from app.providers.base import ProviderError
@pytest.mark.parametrize('code,retries', [('LOCAL_CUDA_OOM', True), ('LOCAL_CUDA_INIT_FAILED', True),
('LOCAL_INFERENCE_FAILED', False), ('LOCAL_RUNTIME_DEPENDENCY_MISSING', False)])
def test_cuda_retries_only_device_failures_in_reaped_process(monkeypatch, code, retries):
import app.local_models.runtime as module
from app.services import model_diagnostics
from app.services.usage_service import connection
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
events = []
class Process:
def __init__(self):
from types import SimpleNamespace
self.stdin = SimpleNamespace(write=self.write, drain=self.drain, close=lambda: None)
self.stdout = asyncio.StreamReader()
self.returncode = None
self.device = None
def write(self, raw):
self.device = json.loads(raw)['config']['device']
events.append('start-' + self.device)
result = {'error_code': code} if self.device == 'cuda' else {'result': [[1, 0]], 'usage': {'input_tokens': 2}, 'diagnostics': {'actual_device': 'cpu'}}
self.stdout.feed_data((json.dumps(result) + '\n').encode())
self.stdout.feed_eof()
async def drain(self):
pass
async def close(self):
pass
async def wait(self):
self.returncode = 0
events.append('reaped-' + self.device)
def kill(self):
self.returncode = -9
async def spawn(*args, **kwargs):
if events:
assert events[-1] == 'reaped-cuda'
return Process()
monkeypatch.setattr(module.asyncio, 'create_subprocess_exec', spawn)
async def scenario():
runtime = module.Runtime()
if retries:
assert await runtime.infer('bekko', 'embedding', {'texts': ['private text']}) == [[1, 0]]
else:
with pytest.raises(ProviderError) as error:
await runtime.infer('bekko', 'embedding', {'texts': ['private text']})
assert error.value.code == code
assert not runtime.active and not runtime.waiters
asyncio.run(scenario())
assert events == (['start-cuda', 'reaped-cuda', 'start-cpu', 'reaped-cpu'] if retries else ['start-cuda', 'reaped-cuda'])
records = model_diagnostics.recent()
assert records[0]['error_code'] == code
assert 'private text' not in json.dumps(records)
if retries:
assert records[-1]['requested_device'] == 'cuda' and records[-1]['actual_device'] == 'cpu'
assert records[-1]['fallback_reason'] == code
assert records[0]['request_id'] == records[1]['request_id']
assert records[0]['attempt_id'] != records[1]['attempt_id']
with closing(connection()) as conn:
assert conn.execute('SELECT COUNT(*) FROM model_usage').fetchone()[0] == (2 if retries else 1)
def test_cpu_failure_does_not_loop_and_interactive_precedes_index(monkeypatch):
import app.local_models.runtime as module
async def scenario():
runtime = module.Runtime()
entered, release = asyncio.Event(), asyncio.Event()
order = []
async def execute(key, operation, payload, config, diagnostics):
order.append(payload['name'])
if payload['name'] == 'running':
entered.set()
await release.wait()
return {'result': []}
monkeypatch.setattr(runtime, '_execute', execute)
first = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'running'}))
await entered.wait()
background = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'index'}, priority=20))
query = asyncio.create_task(runtime.infer('bekko', 'embedding', {'name': 'query'}, priority=0))
await asyncio.sleep(0)
release.set()
await asyncio.gather(first, background, query)
assert order == ['running', 'query', 'index']
calls = []
async def failed(key, operation, payload, config, diagnostics):
calls.append(config.device)
raise ProviderError('LOCAL_CUDA_OOM', 'simulated')
monkeypatch.setattr(runtime, '_execute', failed)
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
with pytest.raises(ProviderError):
await runtime.infer('bekko', 'embedding', {})
assert calls == ['cuda', 'cpu'] and not runtime.active
asyncio.run(scenario())
def test_durable_diagnostics_are_bounded_and_disk_size_is_real():
from app.services import model_diagnostics
from app.local_models import manager
for index in range(205):
model_diagnostics.record(model='bekko', status='failed', error_code='TEST', payload='secret', elapsed_seconds=index)
records = model_diagnostics.recent()
assert len(records) == 200 and records[0]['elapsed_seconds'] == 5
assert 'secret' not in json.dumps(records)
path = manager.model_path('bekko')
path.mkdir(parents=True)
(path / 'weights.partial').write_bytes(b'1234567')
assert manager.disk_bytes('bekko') == 7
def test_upload_key_replay_and_content_conflict():
from app.main import app
with TestClient(app) as client:
headers = {'Idempotency-Key': 'stable-upload-123456'}
first = client.post('/api/media/attachments?filename=lecture.txt', content=b'original', headers=headers)
again = client.post('/api/media/attachments?filename=lecture.txt', content=b'original', headers=headers)
assert first.status_code == again.status_code == 201
assert first.json()['attachment_id'] == again.json()['attachment_id']
assert client.post('/api/media/attachments?filename=lecture.txt', content=b'changed', headers=headers).status_code == 409
changed_name = client.post('/api/media/attachments?filename=lecture.md', content=b'original', headers=headers)
assert changed_name.status_code == 409 and changed_name.json()['error']['code'] == 'IDEMPOTENCY_CONFLICT'
assert client.get('/api/media/attachments/' + first.json()['attachment_id']).content == b'original'
def test_updated_transcript_note_keeps_identity_and_rejects_user_edits():
from app.contracts import TranscriptNoteRequest, TranscriptEditRequest, IndexRebuildRequest
from app.services import transcription_service as jobs, note_service, index_service
from app.services.media_notes import create_transcript_note
from app.services.attachment_service import attachment_path
path = attachment_path('lecture.txt')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('original', encoding='utf-8')
async def scenario():
job = await jobs.create_transcription('lecture.txt', local_only=True)
options = TranscriptNoteRequest(title='Lecture')
first = await create_transcript_note(job.job_id, options)
await index_service.rebuild(IndexRebuildRequest())
jobs.edit(job.job_id, TranscriptEditRequest(revision=1, text='revised'))
update = options.model_copy(update={'update_existing': True})
second = await create_transcript_note(job.job_id, update)
assert first.note_id == second.note_id and 'revised' in second.markdown
assert 'embedding_local_only: true' in second.markdown
again = await create_transcript_note(job.job_id, update)
assert again.note_id == first.note_id
await note_service.update_note(first.note_id, markdown='User edits')
jobs.edit(job.job_id, TranscriptEditRequest(revision=2, text='third revision'))
with pytest.raises(ApiError) as error:
await create_transcript_note(job.job_id, update)
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
assert (await note_service.get_note(first.note_id)).markdown == 'User edits'
copy = await create_transcript_note(job.job_id, options)
assert copy.note_id != first.note_id
asyncio.run(scenario())
def test_audio_usage_is_separate_and_unknown_durations_stay_null():
from app.services.usage_service import UsageAttempt, aggregate
now = datetime.now(timezone.utc)
first = UsageAttempt('local', 'asr', 'local', 'transcription', source='local')
first.observe({'audio_seconds': 2.25, 'usage': {}})
first.persist(); first.persist()
unknown = UsageAttempt('remote', 'asr', 'openai_compatible', 'transcription')
unknown.persist()
result = aggregate(now - timedelta(days=1), now + timedelta(days=1))
assert result['audio_request_count'] == 2 and result['audio_covered_requests'] == 1
assert result['audio_seconds'] == 2.25 and result['totals']['input_tokens'] is None
remote = aggregate(now - timedelta(days=1), now + timedelta(days=1), source='api')
assert remote['audio_seconds'] is None
def test_request_rule_import_rejects_credentials_and_host_fields():
from app.main import app
with TestClient(app) as client:
path = '/api/providers/request-rules/validate'
body = {'version': 1, 'request_overrides': [{'body': {'enable_thinking': False}}]}
assert client.post(path, json=body).status_code == 200
for bad in ({'api_key': 'secret'}, {'nested': {'authorization': 'secret'}}, {'stream': False}):
body['request_overrides'][0]['body'] = bad
assert client.post(path, json=body).status_code == 422
@pytest.mark.parametrize('stream', [False, True])
def test_inference_probe_uses_adapter_body_and_no_vault_context(monkeypatch, stream):
import httpx
from app.container import container
from app.main import app
original = container.provider_factory.build
requests = []
def respond(request):
data = json.loads(request.content)
requests.append(data)
assert data['enable_thinking'] is False and data['stream'] == stream
assert data['messages'] == [{'role': 'user', 'content': 'Reply with OK.'}]
assert not data.get('tools')
if stream:
return httpx.Response(200, text='data: {"choices":[{"delta":{"content":"OK"},"finish_reason":null}]}\n\ndata: [DONE]\n\n')
return httpx.Response(200, json={'choices': [{'message': {'role': 'assistant', 'content': 'OK'}, 'finish_reason': 'stop'}]})
def build(config):
adapter = original(config)
adapter.transport = httpx.MockTransport(respond)
return adapter
monkeypatch.setattr(container.provider_factory, 'build', build)
with TestClient(app) as client:
response = client.post('/api/providers/request-probe', json={'stream': stream, 'provider': {
'name': 'Probe', 'provider_type': 'openai_compatible', 'base_url': 'https://fixture.invalid/v1',
'default_model': 'test', 'request_overrides': [{'body': {'enable_thinking': False}}]}})
assert response.status_code == 200, response.text
assert len(requests) == 1
+86
View File
@@ -0,0 +1,86 @@
import asyncio
import json
import os
import pytest
from app.errors import ApiError
from app.local_models import components, runtime
@pytest.fixture(autouse=True)
def isolate(monkeypatch, tmp_path):
monkeypatch.setattr(components, 'ROOT', tmp_path / 'cuda')
monkeypatch.setattr(components, 'state', {'status': 'unchecked', 'stage': '', 'cuda_available': None})
monkeypatch.setattr(components, 'task', None)
def test_status_checks_without_installing_and_detects_existing_cuda(monkeypatch):
python = components.ROOT / 'Scripts/python.exe'
python.parent.mkdir(parents=True)
python.touch()
calls = []
async def execute(args, timeout):
calls.append(args)
return [json.dumps({'torch': '2.9.1+cu128', 'cuda_available': True})]
monkeypatch.setattr(components, 'execute', execute)
async def scenario():
assert (await components.status())['status'] == 'checking'
await components.task
assert (await components.status())['status'] == 'installed'
assert len(calls) == 1 and calls[0][0] == str(python)
assert components.ready()
asyncio.run(scenario())
@pytest.mark.skipif(os.name != 'nt', reason='Windows installer')
def test_install_deduplicates_and_failure_can_retry(monkeypatch):
monkeypatch.setattr(components.shutil, 'which', lambda name: 'uv.exe')
async def scenario():
entered, release = asyncio.Event(), asyncio.Event()
calls = []
async def execute(args, timeout):
calls.append(args)
entered.set()
await release.wait()
raise RuntimeError('private exception')
monkeypatch.setattr(components, 'execute', execute)
await components.install()
await entered.wait()
first = components.task
await components.install()
assert first is components.task
release.set()
await first
assert components.state['status'] == 'failed'
assert 'private exception' not in str(components.state)
await components.install()
await components.task
assert len(calls) == 2 and '-RuntimeDirectory' in calls[0]
assert not components.ready()
asyncio.run(scenario())
@pytest.mark.skipif(os.name != 'nt', reason='Windows installer')
def test_install_refuses_active_inference(monkeypatch):
monkeypatch.setattr(runtime.runtime, 'active', {1: 'bekko'})
async def scenario():
with pytest.raises(ApiError) as exc:
await components.install()
assert exc.value.code == 'MODEL_IN_USE'
asyncio.run(scenario())
def test_interpreter_keeps_cpu_default_and_respects_explicit_override(monkeypatch):
monkeypatch.delenv('APP_MODEL_PYTHON', raising=False)
python = components.ROOT / 'Scripts/python.exe'
python.parent.mkdir(parents=True)
python.touch()
(components.ROOT / 'ready.json').write_text('{}')
monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cpu'))
assert runtime.interpreter() != python
# A queued attempt keeps its frozen device even after the saved setting changes.
assert runtime.interpreter(runtime.RuntimeConfig(device='cuda')) == python
assert runtime.interpreter(runtime.RuntimeConfig(device='cpu')) != python
monkeypatch.setenv('APP_MODEL_PYTHON', 'explicit-python.exe')
assert str(runtime.interpreter()) == 'explicit-python.exe'
+5 -1
View File
@@ -2,6 +2,10 @@
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
当前文档基线为 2026-09-05:第一阶段和第二阶段 A~F 工程范围已经合并到 `main`,当前可运行形态仍为 Vue/Vite Web 前端与 FastAPI AI Core。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
仓库入口文档:[项目 README](../README.md)、[前端 README](../frontend/README.md)、[后端 README](../backend/README.md)。
## 目录分类
| 目录 | 内容 | 适用场景 |
@@ -29,7 +33,7 @@
## development:开发说明
- [多模态管线与模型运行开发说明](development/多模态管线与模型运行开发说明.md)
- [阶段 F 收尾验收记录](development/阶段F收尾验收记录.md)
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
- [Benchmark 开发说明](development/Benchmark开发说明.md)
@@ -5,7 +5,7 @@
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
> 实施状态更新:2026-09-04。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成。阶段 E 已完成 Responses/Anthropic 协议、国内 logo 预设、Provider 配置恢复和 Embedding/转写/声纹 API 路由;本地语音模型仍为阶段 F 接口预留。RAG Benchmark 检索评测(Dataset 加载、异步运行、SSE 进度、指标聚合与报告)已完成,Agent Benchmark 暂缓。后续继续接入真实音频处理、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
> 实施状态更新:2026-09-05。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段及第二阶段 A~F 工程范围已经合并到 `main`Vue Web 联调前端、FastAPI、真实单 Vault、Knowledge/Retrieval、知识库 Chat、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、Provider 多协议与国内 logo 预设、RAG Benchmark、本地 Embedding、音频转写、片段级声纹聚类、CPU/CUDA 运行管理及用量诊断均已实现。当前生产 Embedding 使用固定 revision 的 Bekko A8MGranite 97M Multilingual r2 可选;音频本地链路使用 Qwen3-ASR-0.6B 与 ERes2NetV2。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 仍未实现;逐字强制对齐、重叠语音分离及带标注长音频质量验收尚未完成
---
@@ -47,17 +47,17 @@
| 元数据 | SQLite | 笔记元数据、Block、标签、会话、Trace、任务、索引状态 |
| 全文检索 | SQLite FTS5 | 关键词、标题、术语、标签等文本检索 |
| 向量检索 | sqlite-vec + VectorStore | 本地语义检索 |
| Embedding | 可插拔 EmbeddingProvider默认本地 BGE-M3 类模型 | 为 Note Block 生成向量 |
| Reranker | BGE reranker 类 Cross-Encoder | 对候选检索结果进行精排 |
| Embedding | 可插拔 EmbeddingProvider默认 Bekko A8M,可选 Granite 97M Multilingual r2 | 为 Note Block 生成 384 维向量,并按模型空间隔离索引 |
| Reranker | 当前 `LexicalReranker`;保留 `RerankerProvider` 替换边界 | 对 RRF 候选进行词面重叠与原始分数加权精排 |
| Agent | 自研 Agent Runtime | 模型推理、工具选择、工具调用、结果回灌、运行控制 |
| Skill | 自研声明式 Skill Runtime | 复用提示词、工具集合、权限和检索配置 |
| Plugin | 自研 Plugin Runtime + Plugin Manifest + MCP Bridge | 扩展程序能力、Tool、外部服务集成和受控 UI Contribution |
| Theme | Theme Manifest + Design Token + 受限 CSS | 本地主题包导入、预览、启停与社区格式兼容 |
| LLM | 自研 Provider Adapter | 统一不同模型服务商的输入、输出、Streaming 与 Tool Calling |
| 模型协议 | OpenAI Responses / Chat Completions compatible / Anthropic Messages / Ollama | 用户自定义模型接入 |
| ASR | faster-whisper | 音频转写 |
| 说话人分离 | pyannote.audio | 课堂、会议等多人音频中的说话人区分 |
| 情感识别 | emotion2vec | 可选音频分析能力 |
| ASR | Qwen3-ASR-0.6B | 本地音频转写与语言识别,返回片段级时间边界 |
| 声纹匹配与片段聚类 | ERes2NetV2 中文声纹模型 | 两段音频相似度与转写片段 speaker 聚类 |
| 情感识别 | Provider 接口预留,尚未选择运行模型 | 后续可选音频分析能力 |
| 文档导出 | Document AST + Exporter Adapter | Markdown 到 HTML、PDF、DOCX,并保留图表、公式和代码块 |
| 密钥存储 | 当前 Fernet 开发存储;目标 Tauri Stronghold | Web 联调期避免明文落盘,桌面集成后保存模型 API Key 和同步凭证 |
| 云同步 | 独立 Sync ServerFastAPI + PostgreSQL + S3/MinIO | 可选自托管,多设备 Vault 同步、版本管理和设备管理 |
@@ -675,13 +675,15 @@ class EmbeddingProvider(Protocol):
async def embed_query(self, query: str) -> list[float]: ...
```
目标默认配置使用本地 BGE-M3 类模型。当前第一阶段实现是 128 维 `HashEmbeddingProvider`,只用于离线跑通向量存储、索引更新和 Hybrid 链路,不代表真实语义召回质量。第二阶段接入真实 Embedding 时继续实现相同接口,上层 Retrieval Core 不依赖具体模型运行时
生产默认配置使用 `hotchpotch/bekko-embedding-v1-a8m`,可选 `ibm-granite/granite-embedding-97m-multilingual-r2`,两者均输出 384 维向量。模型权重使用代码目录中审阅过的固定 revision,下载后校验,推理阶段离线读取。`HashEmbeddingProvider` 仅供测试显式注入,不进入生产检索回退
Embedding 支持 Provider API 与本地模型路由:配置可用 API 时优先调用,响应失败或无效时回退本地模型;未配置 API 时直接使用本地模型;`local_only` 禁止远程调用。索引和查询冻结同一份模型与设备配置,并记录实际来源和回退原因。
索引记录需要保存 embedding model id、模型版本、向量维度和归一化方式。用户更换模型或任一索引兼容字段变化后,索引服务必须将旧向量标记为不可用并要求重建,禁止把不同模型生成的向量写入同一索引空间。
### 9.5 RRF 与 Reranker
FTS5 和 Vector Search 分别产生候选集合,经 RRF 进行排名融合。融合后的候选交给 BGE reranker 类 Cross-Encoder 进行精排
FTS5 和 Vector Search 分别产生候选集合,经 RRF 进行排名融合。当前 `LexicalReranker` 使用词面重叠和归一化原始分数做确定性精排;`RerankerProvider` 接口保留后续替换 Cross-Encoder 的边界,当前阶段没有随本地模型运行环境安装独立 Reranker 权重
初始参数可以采用:
@@ -1268,7 +1270,7 @@ enabled
→ 返回连接测试结果
```
当前设置页已经提供 OpenAI、DeepSeek 与 Ollama 预设,保存后通过 `/api/providers/{provider_id}/models` 自动发现模型。Credential API 只返回配置状态,不提供任何明文读取接口。
当前设置页提供 OpenAI、DeepSeek、通义千问、Kimi、智谱、豆包、腾讯混元、百度千帆、MiniMax、阶跃星辰、硅基流动和 Ollama 等带 logo 预设,也支持自定义兼容服务。保存后通过 `/api/providers/{provider_id}/models` 自动发现模型;聊天、Embedding、转写和声纹能力可独立绑定。Credential API 只返回配置状态,不提供任何明文读取接口。
日志中不记录完整 API Key。请求异常信息在进入前端前过滤 Authorization Header 和密钥片段。
@@ -1282,16 +1284,19 @@ enabled
```mermaid
flowchart LR
A["Audio"] --> D["pyannote.audio"]
D --> S["Speaker Segments"]
S --> W["faster-whisper"]
W --> T["Timestamped Transcript"]
A["Audio"] --> X["PyAV Decode · 16 kHz Mono"]
X --> V["Energy Segmentation"]
V --> W["Qwen3-ASR-0.6B"]
V --> S["ERes2NetV2 Embeddings"]
W --> T["Segment Transcript"]
S --> SC["Speaker Clustering"]
SC --> T
T --> C["Content Structuring"]
C --> M["Markdown Note"]
M --> I["Index Pipeline"]
```
pyannote.audio 生成说话人区间;faster-whisper 对各区间进行转写。最终 Transcript 至少包含:
PyAV 将音轨解码为 16 kHz 单声道,能量分段后由 Qwen3-ASR-0.6B 转写;ERes2NetV2 为片段提取 192 维声纹并按相似度聚类。最终 Transcript 至少包含:
```text
speaker
@@ -1317,7 +1322,9 @@ status
error
```
`pyannote.audio``faster-whisper` 通过独立 Adapter 加载,模型下载、设备选择、精度、批量大小和缓存目录由配置管理。缺少说话人模型时可以只返回时间戳转写,但必须明确标记 diarization 不可用模型失败不能生成伪造的 completed 结果。
本地模型由独立运行环境和子进程按需加载,模型下载、固定 revision、设备、超时、内存预算和缓存目录由配置管理。转写与声纹也可以绑定 Provider API;API 无配置或返回无效时回退本地,`local_only` 请求禁止远程调用。缺少声纹模型时只能返回没有 speaker 的片段并明确标记 diarization 不可用模型失败不能生成伪造的 completed 结果。
当前时间信息为能量分段产生的片段级边界,不是逐字强制对齐。ERes2NetV2 聚类不能处理同一片段内多人或重叠发言,因此不将当前能力描述为完整说话人分离。
### 14.2 OCR
@@ -1325,9 +1332,9 @@ OCR 作为 Media Pipeline 的输入适配能力,用于图片笔记、白板照
OCR 引擎在当前技术栈中尚未固定,调用接口先定义为 `OCRProvider`,具体实现完成 PoC 后确定。
### 14.3 emotion2vec
### 14.3 音频情感分析
emotion2vec 作为音频扩展分析模块。输出可以附加到音频段元数据,不参与核心 RAG 索引和 Agent 启动流程。
音频情感分析保留 Provider/Adapter 扩展位置,当前阶段未选定或集成本地运行模型。未来输出可以附加到段元数据,不参与核心 RAG 索引和 Agent 启动流程。
### 14.4 Document AST 与多格式导出
@@ -1630,7 +1637,7 @@ Agent 中间运行状态
设备级性能配置
```
例如 Client A 使用本地 BGE-M3Client B 使用另一种 Embedding Provider。服务器只同步 Markdown。Client B 收到文件后按照自己的 Embedding 配置生成向量,并写入本机 VectorStore
例如 Client A 使用本地 Bekko A8MClient B 使用 Granite 或 API Embedding Provider。服务器只同步 Markdown。Client B 收到文件后按照自己的 Embedding 配置生成向量,并写入本机对应的隔离向量空间
### 16.5 多设备同步流程
@@ -2010,7 +2017,7 @@ SQLite
Git
```
Rust Toolchain 与 Tauri CLI 只在桌面容器阶段安装。当前轻量 Embedding/Reranker 不要求 CUDA;接入 faster-whisper、pyannote.audio 或真实本地模型时再按所选运行时增加 CPU/GPU 依赖
Rust Toolchain 与 Tauri CLI 只在桌面容器阶段安装。本地模型依赖与 API 环境分离,默认使用 CPU;Windows 可以通过设置页或 `backend/scripts/install-model-runtime.ps1 -Device cuda` 显式安装 PyTorch 2.9.1 cu128 运行组件。CPU 与 CUDA 环境可并存,安装脚本不安装或修改 NVIDIA 驱动,也不要求 vLLM 或 FlashAttention
### 19.2 本地开发
@@ -2233,10 +2240,11 @@ Agent Runtime
```text
Audio
pyannote.audio
Speaker Segments
faster-whisper
Timestamped Transcript
PyAV 解码为 16 kHz 单声道
能量分段
Qwen3-ASR-0.6B 片段转写
ERes2NetV2 声纹提取与片段聚类
→ 片段级时间戳 Transcript
→ Content Structuring
→ Markdown
→ Note Core
@@ -2333,14 +2341,17 @@ Markdown Workspace
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
截至 2026-09-03,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host,以及 Plugin Command/Settings 前后端闭环也已完成。Plugin 详情页现已提供 Host 状态、重启、动态设置、Secret 管理和命令执行,全局命令面板可加载 Plugin Command。当前验证基线为后端 136 项测试、前端 32 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
截至 2026-09-05,上述第一阶段链路和第二阶段 A~F 工程范围均已合并。除 Workspace、Agent Trace、MCP、Plugin Command/Settings 外,当前还包含 Provider 多协议与国内预设、真实 Embedding 路由与隔离向量空间、RAG Benchmark、Qwen3-ASR 转写、ERes2NetV2 声纹匹配与片段聚类、CPU/CUDA 组件管理、请求 JSON、Token/音频用量及运行诊断。阶段 F 合并验证基线为后端 559 项测试、前端 103 项测试、TypeScript 类型检查及生产构建通过。
Bekko A8M 与 Granite 97M Multilingual r2 在 8 篇短文、6 条改写查询的小样本冒烟中均得到 Hit@1、Recall@5、MRR 1.0;该结果只证明中文检索闭环可运行,不足以区分质量优劣。CPU/CUDA 已完成短音频到知识库检索的真实闭环;带标注课程长音频的 WER/CER、DER、重叠语音和吞吐仍需专项验收。
第二阶段在既有 Contract 上接入:
```text
Multimodal
├── faster-whisper
── pyannote.audio
├── Qwen3-ASR-0.6B
── ERes2NetV2 片段声纹聚类
└── API → 本地模型回退路由
Extension / Model
├── MCP Bridgestdio 首版已实现)
@@ -2365,7 +2376,7 @@ Frontend Extension
└── Plugin Settings UI
```
上述列表描述第二阶段技术范围,其中 stdio MCP Bridge、Plugin Command Contribution 和 Plugin Settings Contribution 后端 Contract 已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG Benchmark 和 Provider 增强已经实现;Agent Benchmark、内容导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
第三阶段处理:
@@ -2417,11 +2428,11 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启
## 25. 当前技术基线摘要
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引、任务及 Agent TraceProvider/Extension Registry 当前仍为内存实现
目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引、搜索历史、Provider 配置、任务、多模态记录及 Agent TraceMCP Server 配置持久化到后端数据目录,运行时 Tool/Extension Registry 在进程启动后按持久配置重建
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 仍待第二阶段后续接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并持久化可供前端可视化与 Benchmark 共用的 Agent Trace ContractSkill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 通过 FTS5、sqlite-vec、RRF 和 `LexicalReranker` 运行混合检索,生产 Embedding 默认使用 Bekko A8M、可选 Granite 97M Multilingual r2 或 Provider API,并按模型空间隔离索引;`HashEmbeddingProvider` 仅用于测试。RAG Benchmark 已接入版本化 Dataset、异步运行、SSE 进度、指标和报告。Agent Runtime 使用 Tool Registry 操作知识库和任务,并持久化可供前端 Benchmark 共用的 Agent Trace ContractSkill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。
当前 Plugin Runtime 支持 Manifest、生命周期、声明式白名单 Tool Contribution、Plugin Command 与 Plugin Settings/Secret,并已通过 stdio MCP Bridge 接入独立进程 Tool、专用 MCP Command Target、Host 状态与重启接口。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses、Anthropic Messages 等协议仍待第二阶段后续完善。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript
当前 Plugin Runtime 支持 Manifest、生命周期、声明式白名单 Tool Contribution、Plugin Command 与 Plugin Settings/Secret,并已通过 MCP Bridge 接入 stdio、Streamable HTTP 和旧 SSE Server。Provider Adapter 实现 OpenAI Chat/OpenAI-CompatibleOpenAI Responses、Anthropic Messages 与 Ollama,并支持模型发现、独立凭据和受限自定义请求 JSON。多模态本地链路使用 PyAV、Qwen3-ASR-0.6B 和 ERes2NetV2,默认 CPU、CUDA 显式选装;API 无配置或无效时回退本地模型。当前声纹聚类只处理片段,不等同于完整说话人分离
第二阶段内容输出以 Document AST、Exporter Adapter、Mermaid Renderer 和 Function Plot Renderer 为共同边界,支持 HTML、PDF、DOCX 与静态图导出。Theme Package 使用 Manifest、Design Token 和受限 CSS 实现本地导入;联网主题市场不属于本阶段核心依赖。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。
@@ -1573,3 +1573,19 @@ frontend/src/
### Benchmark Embedding 运行归属(阶段 E 集成修复)
`config_snapshot.local_embedding` 仅表示本地基线;`config_snapshot.embedding``{ "policy": "per_case", "details": "cases[].embedding" }`。报告与 CaseCompleted 事件的逐样本 `embedding` 包含实际 sourceapi/local/not_used/unavailable)、model_id、dimensions,以及可选 version、fallback_reason、requested_route、route_version、attempted_space。requested_route 仅含提供商引用、模型、相对端点和维度,不包含 API Key 或凭据引用。FTS 不使用 Embedding,标记 not_used;远程失败或索引不完整回退时记录实际本地模型及原因。
### 阶段 F 收尾接口补充(2026-09-05
CUDA 组件:`GET /api/local-models/runtime-components/cuda` 返回 status、stage、supported、custom_interpreter、cuda_available、可选 torch/error。status 为 checking/not_installed/installing/installed/failed/interrupted;读取只检查现有环境,不下载安装。`POST` 同路径明确触发后台安装,返回 202;重复请求复用当前安装任务。正在推理/排队返回 409 MODEL_IN_USE,缺少 uv 返回 422 UV_NOT_INSTALLED,不支持的平台返回 422 PLATFORM_UNSUPPORTED。阶段进度不冒充字节百分比。关闭后端时回收安装进程树,重启后重新验证环境。
| 接口/字段 | 行为 |
| --- | --- |
| `POST /api/media/attachments` | 可选 `Idempotency-Key` Header,16–100 位字母、数字、下划线或连字符。后端持久保存键、文件名、attachment_id 和内容摘要;同键同文件同内容返回同 attachment_id,文件名(含扩展名)或内容不一致返回 409 `IDEMPOTENCY_CONFLICT`。对应附件已清理时返回 409 `IDEMPOTENCY_EXPIRED`,客户端需开始新提交。上传仍受 25 MiB 限制。 |
| `TranscriptNoteRequest.update_existing` | 默认 false;true 时将新修订安全写入相同导出选项对应的笔记。无基线返回 409 `NOTE_UPDATE_BASELINE_MISSING`;正文改变返回 409 `NOTE_CONTENT_CONFLICT`。同修订重复调用保持幂等。 |
| 本地模型 `disk_bytes` | 权重目录实际字节数;无法读取为 null。与下载 bytes/total 分开。 |
| `GET /api/local-models/diagnostics` | `scope=application_last_200_attempts`,应用 SQLite 中最近 200 条诊断,包含调用及回退事件。未实际开始推理时不伪造 actual_device。 |
| `GET /api/usage` | 增加 `audio_request_count`、可空的 `audio_seconds``audio_covered_requests`,适用原有时间/提供商/模型/来源过滤。次数按 transcription/speaker_matching 实际 attempt;未报告时长不估算。 |
| `POST /api/providers/request-rules/validate` | 输入/输出 `{version:1, request_overrides:[...]}`;最多 100 条,复用请求扩展校验,不保存提供商。 |
| `POST /api/providers/request-probe` | 输入 `{provider:ProviderCreateRequest, stream:boolean}`;固定短消息真实聊天推理,45 秒超时。成功返回 success/stream/model/message;空响应 422、供应商错误 502、超时 504。只使用 credential_id,不接收明文密钥。 |
请求预览新增 capability 选择(chat/embedding/transcription/speaker_matching),仍只返回隐藏正文的请求体。实际扩展字段是否被供应商接受,以推理响应为准。
@@ -0,0 +1,22 @@
# Frontend phase2PR #23 关闭意见修复
对应评论:https://gitea.kronecker.cc/Kronecker/NotesAgentic/pulls/23#issuecomment-97
本次在独立克隆目录整合 `feat/frontend-phase2-themes-trace-mermaid``f273fef`)与 `main``352557d`),处理关闭评论中的两项 P2 问题及合并冲突。
## 修复内容
- 主题安装和列表加载仅处理存储,不挂载 CSS。切换主题时先校验目标 CSS,再移除旧主题样式并仅挂载当前自定义主题;切回内置主题时清除自定义样式。
- 插件设置保存记录提交时的编辑版本。请求期间的新编辑保留未保存状态,可再次提交;失败保留输入并允许重试。切换插件后,旧加载和保存响应不再覆盖当前插件状态。
- 解决 9 个冲突文件,保留 main 的聊天记录持久化、会话并发修复、中英文支持及完整 Shiki 语言与图标能力,同时保留 phase2 的 Trace、引用导航、主题包、Mermaid 和共享插件命令表单。
- 内置主题列表继续随界面语言响应式更新,自定义主题列表由安装记录派生,避免维护多份可变列表。
## 验证
- 39 个前端测试文件、224 项测试通过,包含 main 与 phase2 原有用例及新增回归测试。
- 类型检查和生产构建通过;仍有大体积 chunk 提示。
- 临时恢复旧主题服务和旧插件设置面板后,5 项新增回归测试按预期失败;随后恢复修复代码。
- 独立浏览器验证页使用真实组件、主题存储及 Mermaid/Shiki 渲染;插件设置请求由页内测试接口延迟返回,不访问实际插件后端。
- 浏览器确认安装未启用主题无样式影响、多主题切换无残留、回到内置主题清除样式;保存期间继续输入后可再次保存最新值;浅色和深色下 Mermaid 与 Shiki 均生成正常内容。
未执行生产插件后端的端到端验收;本次不包含后端实现修改。
@@ -93,6 +93,30 @@ POST /api/providers/request-preview 不联网,隐藏正文/文件且不包含
## 验证记录
### 阶段 F 收尾行为(2026-09-05
CUDA 页面安装入口:**设置 → 模型提供商 → 本地模型 → CUDA 运行组件(可选)**。未安装时显示“下载并安装 CUDA 组件”;安装中展示真实阶段和不定进度条,失败可重试。后端仅运行项目内固定安装脚本,写入独立 `.venv-models-cuda`,检查依赖及 CUDA wheel 后才标记就绪。已有环境会先检查;成功后选择 CUDA 并保存运行设置即可使用,CPU 环境保留。显式 `APP_MODEL_PYTHON` 继续优先,页面提示覆盖关系。当前页面安装支持 Windows,需后端能找到 uv;不会自动安装显卡驱动。
- 模型卡片读取权重目录的实际文件大小,包含未完成下载的文件;下载进度与磁盘占用分别显示。
- 本地任务串行执行;等待队列中交互检索优先级为 0,媒体任务为 10,后台笔记索引为 20。同级 FIFO,不抢占已运行任务。
- 默认 CPU。选择 CUDA 后,设备不可用直接使用 CPU;CUDA 初始化失败或显存不足时先释放原子进程,再用冻结的同一任务配置重试 CPU 一次。其他错误不触发设备重试;用户取消不会启动后续尝试。重试会清除上一尝试的部分转写片段。
- 安装脚本固定 CPU/CUDA wheel 为 `2.9.1+cpu` / `2.9.1+cu128`,避免已有 CPU wheel 被误认为满足 CUDA 安装。可用 `-RuntimeDirectory` 指定独立环境,后端通过 `APP_MODEL_PYTHON` 选择;不自动更换显卡驱动。
- 运行诊断写入应用 SQLite,保留最近 200 条,覆盖本地成功、失败、取消及能力 API 调用/回退事件。仅保留模型、设备、数值耗时、资源、状态码及请求标识,不保存输入、文件路径、密钥或异常全文。排队取消不记作实际模型用量;设备重试有独立 attempt,共享逻辑 request_id。
- 前端同一次提交在响应丢失后复用上传和任务幂等键;收到附件 ID 后只重试创建任务。“重新处理为新任务”明确创建新标识。客户端待提交状态仅在当前页面内存中,已接收任务和结果由后端持久化。
- 转写修订可选择“更新已导出笔记”。后端在 Vault 写锁内校验上次导出内容摘要,保留 note_id 和本地索引限制。用户编辑过正文时返回冲突,不覆盖;旧记录没有摘要时需先创建新笔记。重建索引保留导出基线与关联。
- 用量卡片单列音频实际调用次数、已报告时长和覆盖次数;时长不换算为 Token。重试分别计数,历史未知数据保持“未提供”。
- 请求 JSON 可导入、导出和恢复默认。文件格式为 `{ "version": 1, "request_overrides": [...] }`,只包含扩展规则;服务端复用受保护字段与凭据校验,导入成功仍需保存提供商才生效。
- 请求预览不联网。聊天“发送测试推理请求”使用当前草稿、已保存的凭据引用和固定短消息,支持流式/非流式,不读取知识库、工具或附件,并计入真实用量。更改模型、连接、规则或 JSON 有效性后,旧结果和迟到响应失效;媒体规则继续通过真实媒体操作验收。
独立 CUDA 环境示例(不改变默认 CPU 环境):
```powershell
./backend/scripts/install-model-runtime.ps1 -Device cuda -RuntimeDirectory ./backend/.venv-models-cuda
$env:APP_MODEL_PYTHON = (Resolve-Path ./backend/.venv-models-cuda/Scripts/python.exe).Path
```
设置环境变量后需从同一终端重启后端;CPU 默认仍可用。模型权重与运行环境不提交仓库。
### 2026-09-04 联调修复补充
Windows 热重载不支持异步子进程时使用线程管道兼容路径。本地 Embedding 进入推理前冻结模型与设备配置,向量空间标识来自同一快照;普通 API 成功及失败回退策略保持不变。
@@ -0,0 +1,55 @@
# 阶段 F 收尾验收记录
日期:2026-09-05。对应 `feat/multimodal-finalization`,基于 `6bdba2c`(阶段 F 主分支合并)。
## 完成范围
本轮补齐运行诊断持久化、真实磁盘占用、后台索引优先级、CUDA 设备失败时 CPU 单次重试、上传与任务重试幂等、跨修订安全更新笔记、音频用量分项,以及请求 JSON 导入/导出/重置和实际聊天推理验证。原有 API 优先、无配置/无效响应使用本地模型、local_only 禁止远程调用的流程继续保留。
具体行为见[开发说明](多模态管线与模型运行开发说明.md),接口见[开发版契约](../contracts/第二阶段接口契约-开发版.md),故障与修复见[问题记录 F-13F-15](../retrospectives/阶段F-Embedding与知识库问题与解决方案.md)。
## 自动化与页面验证
| 项目 | 结果 |
| --- | --- |
| 后端全量 `python -m pytest -q -p no:cacheprovider` | 559 通过;1 条已有 Starlette/httpx 弃用提示 |
| 前端全量 `npm test -- --run` | 29 个文件、103 项通过 |
| 类型与生产构建 `npm run build` | vue-tsc 与 Vite 构建通过,仍有既有大 bundle 提示 |
| `git diff --check` | 通过 |
| 真实页面 | 模型卡片读取实际大小;音频统计显示真实缺失;提供商表单展示请求编辑、恢复默认、导入/导出及推理验证入口 |
| 请求 Adapter 验证 | 隔离 HTTP Transport 检查最终流式/非流式请求和扩展字段,不访问外部供应商 |
| 失败恢复 | 初始化/OOM 故障注入、CPU 再失败、进程回收、队列顺序、重复提交、修订冲突和旧结果失效均覆盖 |
## CPU / CUDA 真实模型闭环
Windows、Python 3.12。保留原 `.venv-models` CPU 环境,独立安装 `.venv-models-cuda`;安装后检查 `torch=2.9.1+cu128``cuda_available=True`。显卡为 NVIDIA GeForce RTX 4060 Laptop GPU。
CPU 与 CUDA 分别创建隔离 Vault、附件目录和 SQLite,只读取固定 revision 权重;运行短中文音频 → Qwen3-ASR → ERes2NetV2 片段聚类 → Markdown 笔记 → Bekko 语义检索 → 修订更新。两次均返回已完成,检索命中同一笔记,更新保留 note_id 和 `embedding_local_only: true`,结束后本地运行队列无活跃任务。CPU 实际设备为 `cpu`CUDA 各次实际设备为 `cuda:0`
| CUDA 环节 | 权重 revision | 加载 / 推理耗时 |
| --- | --- | --- |
| Qwen3-ASR-0.6B | `5eb144179a02acc5e5ba31e748d22b0cf3e303b0` | 30.375 / 3.234 秒 |
| ERes2NetV2 片段聚类 | `3317286545c587ae682dbc166831d9448780eebb` | 5.735 / 0.578 秒 |
| Bekko 首次笔记索引 | `c721113d59a1d91b447450324f51c4b3332c924a` | 19.860 / 0.656 秒 |
这些是单次功能冒烟观察值;运行期间有其他验证任务,不用于宣称吞吐或 CPU/GPU 性能倍率。短样本只产生 1 个片段和 1 个 speaker,不能验证多人重叠语音质量。CUDA OOM 恢复使用故障注入,并非实机显存耗尽测试。
## 中文 Embedding 小样本对照
固定 8 篇人工构造的短文,主题为线性代数、死锁、Python 函数、语义检索、光合作用、备份及两个无关干扰项(晚餐、篮球)。6 条改写查询,各有一个预期相关文档;对全部文档做余弦排序。
| 模型 | revision | Hit@1 / Recall@5 / MRR |
| --- | --- | --- |
| Bekko A8M | `c721113d59a1d91b447450324f51c4b3332c924a` | 1.0 / 1.0 / 1.0 |
| Granite 97M Multilingual r2 | `835ad14087e140460703cf0fae09f97d469d65c2` | 1.0 / 1.0 / 1.0 |
两者在这 6 条查询上的目标排名均为 1。该结果仅证明中文检索冒烟可运行,样本量不足以区分模型优劣;继续保留 Bekko 默认、Granite 可选。
## 未关闭的专项验收
- 带参考转写和说话人标注的真实课程长录音尚未提供,不能报告 CER/WER、DER、阈值或长音频吞吐达标。
- 现阶段时间戳为片段级;逐字强制对齐、同段多人/重叠语音仍未实现,不将片段聚类视为完整说话人分离。
- 外部供应商特殊 JSON 的兼容性,需要在目标账号和模型上点击实际推理验证;离线协议通过不替代厂商验收。
- Tauri/Rust Host 和生产 MCP 沙箱按后续阶段安排;本轮数据持久化在后端 SQLite/Vault,为桌面集成保留稳定接口。
结论:阶段 F 本轮工程收尾已实现并完成 CPU/CUDA 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。
+1 -1
View File
@@ -37,7 +37,7 @@
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
| Editor / Chat | 待补文件冲突合并受控链接对话框会话持久化 |
| Editor / Chat | 待补文件冲突合并受控链接对话框会话持久化已接入后端 SQLite |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
@@ -138,6 +138,54 @@ embedding_local_only: true
## 9. 工程经验
### F-18:设备快照与迟到导入错误未完全隔离
问题:推理任务虽然冻结了 RuntimeConfig,但启动子进程时又从数据库读取最新 device 来选择 Python 环境;排队期间修改设置会改变已提交任务的运行环境,CUDA → CPU 重试也可能继续使用 CUDA 环境。请求规则的迟到成功响应已失效,但迟到失败仍会把旧错误显示到新草稿。
实际方案:`interpreter` 接收本次 attempt 的冻结配置,`_execute` 在检查前解析一次可执行路径并复用;CUDA attempt 使用已验证的独立 CUDA 环境,CPU attempt 使用默认 CPU 环境,显式 APP_MODEL_PYTHON 仍保持最高优先级。导入异常与成功响应使用同一 generation 条件,只允许当前操作更新界面。
验证:增加保存设置变化后仍按显式 attempt 选择环境、CPU 重试环境,以及旧导入失败晚于新编辑的回归。最终后端 559 项、前端 103 项和生产构建通过。
### F-16:CUDA 选装只有脚本,前端缺少安装入口
问题:上一轮完成了独立 CUDA 环境安装和 GPU 实测,但页面只有设备下拉框及脚本说明。用户无法从前端下载组件,工程收尾遗漏了可操作入口。
实际方案:增加独立组件卡片和 GET/POST 状态、安装接口;展示环境检查、下载 PyTorch、安装依赖、验证等真实阶段,失败允许重试。固定脚本、目录和参数,默认 CPU 环境不变;安装成功后 CUDA 模式自动选择已验证环境,显式 Python 覆盖仍优先。推理期间拒绝安装,重复点击不产生多个任务,后端关闭时回收安装子进程树。
验证:21 项后端相关测试及新增前端组件测试通过,类型检查和构建通过;真实页面已显示本机 `2.9.1+cu128` 组件就绪,默认 CPU 未改变。本轮复用已安装组件验证识别,未重复下载 3 GB 安装包;下载入口、重复请求和失败重试由隔离测试覆盖。
### F-17:幂等键跨扩展名与请求规则导入竞态
问题:附件 ID 原先由幂等键哈希和扩展名共同生成,相同键更换扩展名可以创建第二份附件。请求规则导入等待服务端验证期间,用户的新编辑可能被迟到的导入响应覆盖。
实际方案:后端持久映射幂等键、文件名、附件 ID 和内容摘要,并在 SQLite 写锁中完成查重;文件名或内容变化均返回冲突,已清理的旧附件要求开始新提交。请求规则编辑器为导入和每次草稿变化递增 generation,只接受仍对应当前草稿的响应。
验证:增加同键跨扩展名冲突,以及导入后继续编辑、迟到响应不覆盖的测试。相关后端 17 项、前端 15 项通过;最终全量后端 559 项、前端 102 项及生产构建通过。
### F-13:CUDA 失败重试与诊断无法追溯
问题:设备不可用时能够使用 CPU,但 CUDA 初始化失败、显存不足会直接使任务失败;诊断只留在进程内存中,重启后无法解释当时的失败和回退。
实际方案:在同一队列占位内完成 CUDA → CPU 单次重试,先回收失败子进程再启动 CPU。只接受初始化失败、CUDA OOM 两类重试原因,普通模型错误不扩大重试范围。转写部分结果随尝试重置,取消仍终止流程。诊断按白名单写入 SQLite,保留最近 200 条,记录请求设备、实际设备、尝试设备、状态和耗时;未知设备不冒充实际使用设备。
验证:故障注入覆盖初始化失败、OOM、普通错误、CPU 再失败、资源释放顺序、队列顺序和请求用量归属。Windows RTX 4060 Laptop 实机安装 `torch 2.9.1+cu128`ASR、片段声纹和 Embedding 的实际设备均为 `cuda:0`,完成笔记生成、检索与修订更新。实机正常 CUDA 路径通过;OOM 回退是确定性注入验证,未人为耗尽用户显存。
### F-14:响应丢失重复上传与转写笔记无法安全更新
问题:前端每次点击都生成新幂等键,上传或创建任务已成功但响应丢失时,重试可能制造重复附件/任务。已有导出幂等只能返回相同修订,缺少新修订更新原笔记的保护机制。
实际方案:同一次页面提交冻结文件与选项,复用上传/任务键,已获得的附件 ID 继续使用;主动重新处理才重置标识。后端重复上传校验内容摘要。导出基线保存正文摘要,跨修订更新在 Vault 写锁内核对基线,用户编辑冲突返回 409,允许改为创建新笔记;旧无基线记录不强行覆盖。索引重建不丢失基线,本地限制继续随笔记持久化。
验证:覆盖上传响应丢失、任务响应丢失、主动重跑、重复键内容冲突、修订更新保持 note_id、重复导出、重建恢复和用户正文冲突。CPU/CUDA 两次真实本地管线均在隔离 Vault/SQLite 中通过检索与修订闭环,不写入用户笔记库。
### F-15:运行管理与请求配置验收缺项
问题:下载计数不能反映实际占用,后台索引与交互查询同优先级;音频调用没有独立时长统计;请求规则缺少导入/导出/恢复默认和真实推理验证,草稿改变后旧验证结果可能误导用户。
实际方案:磁盘大小读取目录文件,查询/媒体/后台索引分别排队;音频次数、已报告时长与 Token 分开聚合,保留覆盖数。规则文件由服务端验证后替换草稿,保存后生效;验证按钮使用固定短消息走实际 Adapter。草稿变化使预览与验证失效,包括无效 JSON 和迟到响应。
验证:增加实际目录统计、音频缺失值与去重、规则拒绝受保护字段、隔离 HTTP 协议测试及前端迟到响应测试。最终后端 555 项、前端 100 项通过。真实供应商兼容性仍须使用目标账号验证;本轮不将 MockTransport 协议测试称为厂商实测。
### F-12:普通分割线与元数据头部消歧
F-11 修复后,`---``---\n\n# Title\n\n正文` 等合法 Markdown 被误判为未闭合 frontmatter,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。
+80
View File
@@ -0,0 +1,80 @@
# NotesAgent Frontend
NotesAgent Frontend 是基于 Vue 3、TypeScript、Vite、Pinia、Vue Router、Milkdown 和 CodeMirror 6 的 Web 联调前端。当前页面调用 FastAPI 真实接口,不使用业务 Mock 作为运行时回退;测试文件中的 mock 只用于隔离单元和组件测试。
## 初始化与运行
```powershell
pnpm install
pnpm dev
```
开发地址为 <http://127.0.0.1:5173>。Vite 将 `/api``/health` 转发到 <http://127.0.0.1:8000>,因此联调前需要先启动后端。
## 页面与能力
| 路由 | 当前能力 |
| --- | --- |
| `/``/workspace` | 选择当前 Vault、浏览目录、编辑和保存 Markdown |
| `/search` | 全文、向量和混合检索;从后端读取并清空搜索历史 |
| `/chat` | 流式 AI 对话、知识库上下文与 Citation |
| `/agent/runs/:runId?` | 创建 Agent 运行,查看可恢复 Trace 与 Tool/Permission 事件 |
| `/media` | 上传音频、创建/取消/重试转写、修订结果并生成知识库笔记 |
| `/tasks` | 管理用户、笔记和 Agent 产生的任务 |
| `/extensions/skills` | Skill 安装、启停与配置 |
| `/extensions/mcp` | stdio、Streamable HTTP、旧 SSE Server 配置与工具发现 |
| `/extensions/plugins` | Plugin Host、Command、Settings、Secret 与 MCP 状态 |
| `/themes` | 内置 Design Token 主题和编辑器显示偏好 |
| `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断;全局中英文和拼写检查设置 |
## 技术结构
| 目录 | 职责 |
| --- | --- |
| `src/features` | 按页面和业务域组织的 Vue 组件 |
| `src/stores` | Pinia 状态与页面编排 |
| `src/services` | FastAPI HTTP/SSE 客户端和 DTO 转换 |
| `src/contracts` | 与后端契约对应的 TypeScript 类型 |
| `src/components` | 应用壳、命令面板和共享组件 |
| `src/utils` | Markdown 清洗、Shiki 高亮等纯工具 |
| `src/styles` | Design Token、布局、主题和动效 |
编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
## 数据边界
- 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。
- AI 对话生成和知识库检索通过 FastAPI;会话列表、用户消息、流式助手结果、引用和 Token 用量保存在后端 SQLite,刷新页面后可恢复。
- API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`
- 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。
- 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。
- 前端不直接访问 SQLite,不拼装第三方模型协议;Provider Adapter 和请求覆盖规则由后端执行。
- 后端不可用时页面显示连接或操作错误,不生成演示数据替代真实结果。
当前仍运行在 Web/Vite 环境。后续 Tauri 集成将复用现有 Service/Contract 边界,并由 Rust Host 接管窗口、Vault 选择、Sidecar、Stronghold 和生产沙箱。
## 模型设置
设置页支持带 logo 的提供商预设、模型发现、聊天/Embedding/转写/声纹能力绑定,以及按 capability、model 和 stream 条件匹配的自定义请求 JSON。请求预览不联网;“发送测试推理请求”使用当前草稿和已保存凭据执行真实短请求。
本地模型页显示固定 revision、许可、下载状态和实际磁盘占用。CPU 是默认运行方式;Windows 可从页面安装独立 CUDA 12.8 组件,安装过程不修改显卡驱动。当前模型选型详见[多模态管线与模型运行](../docs/development/多模态管线与模型运行开发说明.md)。
## 测试与构建
```powershell
pnpm test
pnpm type-check
pnpm build
```
当前基线为 30 个测试文件、106 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。
## 开发约定
- 依赖统一使用 pnpm 管理,不混用 npm 或 yarn。
- 新接口先更新 `src/contracts``src/services`,页面和 Store 不直接散落 `fetch` 协议细节。
- 异步页面需要处理加载、空数据、后端错误、重复提交和迟到响应。
- 功能行为或契约变化时,同一提交同步更新测试和相关文档。
- 页面需求见[前端页面需求说明](../docs/contracts/前端页面需求说明-开发版.md),后端行为以运行时 `/openapi.json` 为准。
+4
View File
@@ -11,8 +11,12 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@codemirror/commands": "6.11.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "6.12.4",
"@codemirror/state": "6.7.1",
"@codemirror/theme-one-dark": "^6.1.0",
"@codemirror/view": "6.43.9",
"@element-plus/icons-vue": "^2.3.2",
"@milkdown/crepe": "7.22.1",
"@milkdown/kit": "7.22.1",
+12
View File
@@ -8,12 +8,24 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: 6.11.0
version: 6.11.0
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.2
'@codemirror/language':
specifier: 6.12.4
version: 6.12.4
'@codemirror/state':
specifier: 6.7.1
version: 6.7.1
'@codemirror/theme-one-dark':
specifier: ^6.1.0
version: 6.1.3
'@codemirror/view':
specifier: 6.43.9
version: 6.43.9
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.42(typescript@5.9.3))
@@ -0,0 +1,45 @@
// Usage: node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
// Source: @iconify-json/vscode-icons 1.2.76 (MIT). No runtime network requests.
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { bundledLanguagesInfo } from 'shiki/langs'
const source = process.argv[2]
if (!source) throw new Error('Provide the extracted vscode-icons package directory')
const data = JSON.parse(readFileSync(resolve(source, 'icons.json'), 'utf8'))
const overrides = {
ahk: 'autohotkey', ahk2: 'autohotkey', asm: 'assembly', bat: 'bat',
'angular-html': 'angular', 'angular-ts': 'angular',
'common-lisp': 'lisp', 'emacs-lisp': 'lisp',
'fortran-fixed-form': 'fortran', 'fortran-free-form': 'fortran',
'git-commit': 'git', 'git-rebase': 'git',
jsonc: 'json', jsonl: 'json', shellscript: 'shell', shellsession: 'shell',
jsx: 'reactjs', tsx: 'reactts', latex: 'tex', bibtex: 'bibtex',
'objective-c': 'objectivec', 'objective-cpp': 'objectivecpp',
dart: 'dartlang', d: 'dlang', v: 'vlang', gdshader: 'godot',
fish: 'shell', 'ssh-config': 'shell', 'vue-html': 'vue', 'vue-vine': 'vue',
'html-derivative': 'html', qss: 'qt', rbs: 'ruby',
}
const groups = new Map()
const unmatched = []
for (const info of [...bundledLanguagesInfo, { id: 'text', name: 'Text' }]) {
const candidates = [overrides[info.id], info.id, ...(info.aliases ?? []), info.name.toLowerCase().replace(/\s+/g, '')].filter(Boolean)
const icon = candidates.map(name => `file-type-${name}`).find(name => data.icons[name])
if (!icon) { unmatched.push(info.id); continue }
const ids = groups.get(icon) ?? []
ids.push(info.id)
groups.set(icon, ids)
}
const base = '.milkdown-host .language-list-item[data-language]'
const svgUrl = icon => {
const item = data.icons[icon]
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${item.width ?? data.width ?? 32} ${item.height ?? data.height ?? 32}">${item.body}</svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
let css = `/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
for (const [icon, ids] of groups) {
css += ids.map(id => `${base}[data-language="${id}"]::before`).join(',\n') + ` { background-image: ${svgUrl(icon)}; }\n`
}
writeFileSync(new URL('../src/features/editor/language-icons.css', import.meta.url), css)
writeFileSync(new URL('../src/features/editor/language-icons-LICENSE.txt', import.meta.url), readFileSync(resolve(source, 'license.txt')))
console.log(`${bundledLanguagesInfo.length + 1 - unmatched.length} languages mapped; generic file icon for: ${unmatched.join(', ')}`)
+3 -1
View File
@@ -1,3 +1,5 @@
import { t } from '@/i18n'
export interface ServiceStatus {
name: string
version: string
@@ -10,7 +12,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
export async function getServiceStatus(): Promise<ServiceStatus> {
const response = await fetch(`${apiBaseUrl}/api/status`)
if (!response.ok) {
throw new Error(`后端请求失败:HTTP ${response.status}`)
throw new Error(`${t('后端请求失败:', 'Backend request failed: ')}HTTP ${response.status}`)
}
return response.json() as Promise<ServiceStatus>
}
@@ -8,6 +8,7 @@ import * as workspaceService from '@/services/workspaceService'
import * as pluginService from '@/services/pluginService'
import type { PluginCommand, PluginCommandEffect } from '@/contracts'
import { usePluginStore } from '@/stores/plugin'
import { t } from '@/i18n'
const router = useRouter()
const editorStore = useEditorStore()
@@ -25,17 +26,17 @@ const selectionSnapshot = ref<string | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const builtinCommands = computed<Command[]>(() => [
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
{ id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') },
{ id: 'themes', label: '主题管理', hint: '导航', run: () => router.push('/themes') },
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') },
{ id: 'tasks', label: '任务列表', hint: '导航', run: () => router.push('/tasks') },
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() },
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() },
{ id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() },
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote },
{ id: 'themes', label: t('主题管理', 'Manage themes'), hint: t('导航', 'Navigation'), run: () => router.push('/themes') },
{ id: 'tasks', label: t('任务列表', 'Tasks'), hint: t('导航', 'Navigation'), run: () => router.push('/tasks') },
{ id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') },
{ id: 'search', label: t('全局搜索', 'Global search'), hint: t('导航', 'Navigation'), run: () => router.push('/search') },
{ id: 'chat', label: t('打开 AI 对话', 'Open AI chat'), hint: t('导航', 'Navigation'), run: () => router.push('/chat') },
{ id: 'agent', label: t('创建智能体运行', 'Create agent run'), hint: t('导航', 'Navigation'), run: () => router.push('/agent/runs') },
{ id: 'settings', label: t('打开设置', 'Open settings'), hint: t('导航', 'Navigation'), run: () => router.push('/settings') },
{ id: 'mode', label: editorStore.mode === 'source' ? t('切换为写作模式', 'Switch to writing mode') : t('切换为源码模式', 'Switch to source mode'), hint: t('编辑器', 'Editor'), run: () => editorStore.toggleMode() },
{ id: 'save', label: t('保存当前笔记', 'Save current note'), hint: t('编辑器', 'Editor'), run: () => editorStore.save() },
{ id: 'theme', label: themeStore.isDark ? t('切换为浅色主题', 'Switch to light theme') : t('切换为深色主题', 'Switch to dark theme'), hint: t('外观', 'Appearance'), run: () => themeStore.toggleTheme() },
{ id: 'new-note', label: t('创建笔记', 'Create note'), hint: t('工作区', 'Workspace'), run: createNote },
])
const commands = computed<Command[]>(() => [
@@ -81,12 +82,12 @@ async function execute(command: Command | undefined) {
try {
await command.run()
} catch (error) {
commandNotice.value = error instanceof Error ? error.message : '命令执行失败'
commandNotice.value = error instanceof Error ? error.message : t('命令执行失败', 'Command failed')
}
}
async function createNote() {
const rawName = window.prompt('笔记名称')?.trim()
const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim()
if (!rawName) return
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
@@ -100,7 +101,7 @@ async function loadPluginCommands() {
try {
pluginCommands.value = await pluginService.listPluginCommands('command_palette')
} catch (error) {
commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败'
commandError.value = error instanceof Error ? error.message : t('Plugin 命令加载失败', 'Failed to load plugin commands')
}
}
@@ -112,7 +113,7 @@ async function executePluginCommand(command: PluginCommand) {
if (hasRequiredArguments(command)) {
pluginStore.selectPlugin(command.plugin_id)
await router.push('/extensions/plugins')
commandNotice.value = '请在 Plugin 详情页填写参数后执行' + command.title + '”。'
commandNotice.value = `${t('请在 Plugin 详情页填写参数后执行', 'Enter parameters on the Plugin details page, then run')}${command.title}”.`
return
}
const result = await pluginService.executePluginCommand(command.command_id, {}, {
@@ -138,11 +139,11 @@ async function applyPluginEffect(effect: PluginCommandEffect) {
if (effect.type === 'refresh') {
if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins()
if (effect.payload.scope === 'commands') await loadPluginCommands()
commandNotice.value = '相关数据已刷新。'
commandNotice.value = t('相关数据已刷新。', 'Related data refreshed.')
return
}
if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return }
commandNotice.value = 'Plugin 命令执行完成。'
if (effect.type === 'job') { commandNotice.value = t('后台任务已创建:', 'Background job created: ') + effect.payload.job_id; return }
commandNotice.value = t('Plugin 命令执行完成。', 'Plugin command completed.')
}
function handleKeydown(event: KeyboardEvent) {
@@ -160,20 +161,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<template>
<div v-if="commandNotice" class="command-toast" role="status">
<span>{{ commandNotice }}</span><button aria-label="关闭通知" @click="commandNotice = ''">×</button>
<span>{{ commandNotice }}</span><button :aria-label="t('关闭通知', 'Close notification')" @click="commandNotice = ''">×</button>
</div>
<Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide">
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板">
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" />
<section class="command-palette" role="dialog" aria-modal="true" :aria-label="t('命令面板', 'Command palette')">
<input ref="input" v-model="query" class="command-input" :placeholder="t('输入命令…', 'Enter a command…')" @keydown.enter.prevent="execute(filteredCommands[0])" />
<p v-if="commandError" class="command-error">{{ commandError }}</p>
<div class="command-list">
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
<span>{{ command.label }}</span><small>{{ command.hint }}</small>
</button>
<p v-if="!filteredCommands.length">没有匹配的命令</p>
<p v-if="!filteredCommands.length">{{ t('没有匹配的命令', 'No matching commands') }}</p>
</div>
<footer><span>Enter 执行</span><span>Esc 关闭</span></footer>
<footer><span>Enter · {{ t('执行', 'Run') }}</span><span>Esc · {{ t('关闭', 'Close') }}</span></footer>
</section>
</div>
</Teleport>
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import FilePicker from './FilePicker.vue'
describe('FilePicker', () => {
it('keeps the native file input accessible and reports the selected file', async () => {
const wrapper = mount(FilePicker, {
props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件', accept: '.json' },
})
const input = wrapper.get('input[type="file"]')
const file = new File(['{}'], 'rules.json', { type: 'application/json' })
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
expect(wrapper.emitted('select')).toEqual([[file]])
expect(wrapper.get('label').attributes('for')).toBe(input.attributes('id'))
expect(wrapper.text()).toContain('尚未选择文件')
await wrapper.setProps({ file })
expect(wrapper.text()).toContain('rules.json')
})
it('emits null when the native selection is cleared', async () => {
const wrapper = mount(FilePicker, {
props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件' },
})
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [], configurable: true })
await input.trigger('change')
expect(wrapper.emitted('select')).toEqual([[null]])
})
})
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useId } from 'vue'
import { Upload } from '@element-plus/icons-vue'
defineProps<{
file: File | null
label: string
emptyLabel: string
accept?: string
disabled?: boolean
}>()
const emit = defineEmits<{ select: [file: File | null] }>()
const inputId = useId()
function selectFile(event: Event) {
emit('select', (event.target as HTMLInputElement).files?.[0] ?? null)
}
function allowReselect(event: MouseEvent) {
;(event.currentTarget as HTMLInputElement).value = ''
}
</script>
<template>
<div class="file-picker" :class="{ disabled }">
<input
:id="inputId"
class="file-picker-input"
type="file"
:accept="accept"
:disabled="disabled"
@click="allowReselect"
@change="selectFile"
/>
<label class="file-picker-trigger" :for="inputId">
<Upload aria-hidden="true" />
<span>{{ label }}</span>
</label>
<span class="file-picker-name" :class="{ empty: !file }" :title="file?.name || emptyLabel">
{{ file?.name || emptyLabel }}
</span>
</div>
</template>
<style scoped>
.file-picker { display: flex; min-width: 0; align-items: center; gap: var(--space-sm); }
.file-picker-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.file-picker-trigger { display: inline-flex; min-height: 36px; flex: 0 0 auto; align-items: center; gap: var(--space-sm); padding: 0 var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); font-weight: 600; cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast); }
.file-picker-trigger svg { width: 16px; height: 16px; }
.file-picker-trigger:hover { border-color: var(--color-accent-secondary); background: var(--color-background-hover); color: var(--color-accent-primary); transform: translateY(-1px); }
.file-picker-input:focus-visible + .file-picker-trigger { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
.file-picker-name { min-width: 0; overflow: hidden; color: var(--color-text-secondary); text-overflow: ellipsis; white-space: nowrap; user-select: text; }
.file-picker-name.empty { color: var(--color-text-tertiary); }
.disabled { opacity: .55; }
.disabled .file-picker-trigger { cursor: not-allowed; transform: none; }
@media (max-width: 560px) { .file-picker { align-items: stretch; flex-direction: column; } .file-picker-trigger { justify-content: center; } }
</style>
@@ -3,24 +3,25 @@ import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
import { t } from '@/i18n'
const route = useRoute()
const router = useRouter()
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
const navItems = [
{ name: 'workspace', icon: FolderOpened, label: '工作区' },
{ name: 'search', icon: Search, label: '搜索' },
{ name: 'chat', icon: ChatDotRound, label: 'AI 对话' },
{ name: 'agent', icon: Cpu, label: '智能体' },
{ name: 'tasks', icon: CircleCheck, label: '任务' },
{ name: 'media', icon: Monitor, label: '音视频' },
const navItems = computed(() => [
{ name: 'workspace', icon: FolderOpened, label: t('工作区', 'Workspace') },
{ name: 'search', icon: Search, label: t('搜索', 'Search') },
{ name: 'chat', icon: ChatDotRound, label: t('AI 对话', 'AI Chat') },
{ name: 'agent', icon: Cpu, label: t('智能体', 'Agent') },
{ name: 'tasks', icon: CircleCheck, label: t('任务', 'Tasks') },
{ name: 'media', icon: Monitor, label: t('音视频', 'Media') },
{ name: 'skills', icon: Lightning, label: 'Skill' },
{ name: 'plugins', icon: Connection, label: 'Plugin' },
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
{ name: 'themes', icon: Brush, label: '主题' },
{ name: 'settings', icon: Setting, label: '设置' },
]
{ name: 'themes', icon: Brush, label: t('主题', 'Themes') },
{ name: 'settings', icon: Setting, label: t('设置', 'Settings') },
])
const currentName = computed(() => {
return route.name as string
@@ -52,9 +53,9 @@ function toggleExpanded() {
</div>
</nav>
<div class="sidebar-footer">
<button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
<button class="nav-item collapse-button" type="button" :title="expanded ? t('收起导航', 'Collapse navigation') : t('展开导航', 'Expand navigation')" @click="toggleExpanded">
<AppIcon class="nav-icon" :icon="expanded ? ArrowLeftBold : ArrowRightBold" />
<span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
<span class="nav-label">{{ expanded ? t('收起', 'Collapse') : t('展开', 'Expand') }}</span>
</button>
</div>
</aside>
@@ -7,6 +7,7 @@ import SearchFiltersPanel from '@/features/search/SearchFiltersPanel.vue'
import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue'
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
import { useRoute } from 'vue-router'
import { t } from '@/i18n'
const props = defineProps<{
component: string | null
@@ -17,12 +18,12 @@ const routeName = computed(() => route.name as string)
const sidebarTitle = computed(() => {
const titles: Record<string, string> = {
'file-tree': '文件',
'conversation-list': '对话',
'run-list': '智能体运行',
'search-filters': '搜索筛选',
'task-filters': '任务筛选',
'extension-list': '扩展',
'file-tree': t('文件', 'Files'),
'conversation-list': t('对话', 'Conversations'),
'run-list': t('智能体运行', 'Agent Runs'),
'search-filters': t('搜索筛选', 'Search Filters'),
'task-filters': t('任务筛选', 'Task Filters'),
'extension-list': t('扩展', 'Extensions'),
}
return titles[props.component || ''] || ''
})
+16 -15
View File
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useProviderStore } from '@/stores/provider'
import { useAgentStore } from '@/stores/agent'
import { useRoute } from 'vue-router'
import { t } from '@/i18n'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
@@ -15,12 +16,12 @@ const route = useRoute()
const saveStatusText = computed(() => {
const map: Record<string, string> = {
idle: '',
dirty: '未保存',
saving: '保存中...',
saved: '已保存',
save_failed: '保存失败',
external_changed: '外部已更新',
conflict: '存在冲突',
dirty: t('未保存', 'Unsaved'),
saving: t('保存中...', 'Saving...'),
saved: t('已保存', 'Saved'),
save_failed: t('保存失败', 'Save failed'),
external_changed: t('外部已更新', 'Changed externally'),
conflict: t('存在冲突', 'Conflict'),
}
return map[editorStore.saveStatus] || ''
})
@@ -39,16 +40,16 @@ const saveStatusColor = computed(() => {
const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status
return s === 'unknown' ? '索引状态未获取' : s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? `${t('索引中', 'Indexing')} (${settingsStore.indexStatus.pending_jobs})` : t('索引错误', 'Index error')
})
const aiCoreStatusText = computed(() => {
const map: Record<string, string> = {
unknown: 'AI Core 状态未获取',
starting: 'AI Core 启动中',
running: 'AI Core 运行中',
stopped: 'AI Core 已停止',
error: 'AI Core 错误',
unknown: t('AI Core 状态未获取', 'AI Core status unavailable'),
starting: t('AI Core 启动中', 'AI Core starting'),
running: t('AI Core 运行中', 'AI Core running'),
stopped: t('AI Core 已停止', 'AI Core stopped'),
error: t('AI Core 错误', 'AI Core error'),
}
return map[settingsStore.aiCoreStatus] || ''
})
@@ -85,7 +86,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
</span>
<span v-if="agentStore.isRunning" class="status-item agent-status">
<span class="spinner" />
智能体运行中
{{ t('智能体运行中', 'Agent running') }}
</span>
</div>
<div class="statusbar-right">
@@ -93,10 +94,10 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ defaultProvider.name }} · {{ defaultProvider.default_model }}
</span>
<span v-if="showEditorInfo" class="status-item">
{{ editorStore.lineCount }}
{{ editorStore.lineCount }} {{ t('行', 'lines') }}
</span>
<span v-if="showEditorInfo" class="status-item">
{{ editorStore.wordCount }}
{{ editorStore.wordCount }} {{ t('字', 'words') }}
</span>
</div>
</footer>
+11 -10
View File
@@ -6,6 +6,7 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
import { t } from '@/i18n'
const route = useRoute()
const workspaceStore = useWorkspaceStore()
@@ -15,15 +16,15 @@ const themeStore = useThemeStore()
const pageTitle = computed(() => {
const name = route.name as string
const titles: Record<string, string> = {
workspace: '工作区',
search: '搜索',
chat: 'AI 对话',
agent: '智能体执行轨迹',
tasks: '任务',
skills: 'Skill 管理',
plugins: 'Plugin 与 MCP',
themes: '主题管理',
settings: '设置',
workspace: t('工作区', 'Workspace'),
search: t('搜索', 'Search'),
chat: t('AI 对话', 'AI Chat'),
agent: t('智能体执行轨迹', 'Agent Trace'),
tasks: t('任务', 'Tasks'),
skills: t('Skill 管理', 'Skill Management'),
plugins: t('Plugin 与 MCP', 'Plugins and MCP'),
themes: t('主题管理', 'Theme Management'),
settings: t('设置', 'Settings'),
}
return titles[name] || 'NotesAgent'
})
@@ -53,7 +54,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
<span class="app-name">NotesAgent</span>
</div>
<div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" />
</button>
<div class="window-controls">
+27 -26
View File
@@ -9,6 +9,7 @@ import type { AgentEvent } from '@/contracts'
import { localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import ToolOption from './ToolOption.vue'
import { useCitationNavigation } from '@/composables/useCitationNavigation'
import { localeTag, t } from '@/i18n'
const route = useRoute()
const router = useRouter()
@@ -30,19 +31,19 @@ onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
form.provider_id = providerStore.defaultProviderId
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
} catch (error) { pageError.value = error instanceof Error ? error.message : t('智能体配置加载失败', 'Failed to load agent configuration') }
})
watch(() => route.params.runId, async (runId) => {
if (typeof runId !== 'string') return
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : '运行记录加载失败' }
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : t('运行记录加载失败', 'Failed to load run') }
}, { immediate: true })
watch(() => form.provider_id, async (providerId) => {
form.model = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
if (!providerId) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : t('模型列表加载失败,请手动填写模型 ID。', 'Unable to load models. Enter a model ID manually.') }
})
function toggleTool(name: string) {
@@ -54,7 +55,7 @@ function toggleTool(name: string) {
async function createRun() {
pageError.value = ''
try {
if (!form.provider_id || !form.model.trim()) throw new Error('请选择提供商并填写模型 ID。')
if (!form.provider_id || !form.model.trim()) throw new Error(t('请选择提供商并填写模型 ID。', 'Select a provider and enter a model ID.'))
const run = await agentStore.createRun({
input: form.input, provider_id: form.provider_id, model: form.model,
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
@@ -63,12 +64,12 @@ async function createRun() {
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
})
await router.replace({ name: 'agent', params: { runId: run.run_id } })
} catch (error) { pageError.value = error instanceof Error ? error.message : '运行创建失败' }
} catch (error) { pageError.value = error instanceof Error ? error.message : t('运行创建失败', 'Failed to create run') }
}
function eventText(event: AgentEvent) {
if (event.event === 'RunCompleted') return '任务已成功完成。'
if (event.event === 'RunCancelled') return '任务已取消。'
if (event.event === 'RunCompleted') return t('任务已成功完成。', 'The task completed successfully.')
if (event.event === 'RunCancelled') return t('任务已取消。', 'The task was cancelled.')
const text = event.data.text ?? event.data.message ?? event.data.code
if (text) return String(text)
return ''
@@ -80,31 +81,31 @@ async function handleOpenCitation(data: Record<string, unknown>) {
try {
await openCitation(data)
} catch (error) {
pageError.value = error instanceof Error ? error.message : '引用定位失败'
pageError.value = error instanceof Error ? error.message : t('引用定位失败', 'Failed to open citation')
}
}
</script>
<template>
<section class="feature-page agent-page">
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界并实时查看模型工具和权限事件</p></div>
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
<header class="feature-header"><div><h1>{{ isNewRun ? t('创建智能体运行', 'Create Agent Run') : t('智能体执行轨迹', 'Agent Trace') }}</h1><p>{{ t('配置执行边界并实时查看模型工具和权限事件。', 'Configure execution limits and inspect model, tool, and permission events in real time.') }}</p></div>
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">{{ t('新建运行', 'New run') }}</button></header>
<div v-if="pageError || agentStore.error || providerStore.error" class="error-banner">{{ pageError || agentStore.error || providerStore.error }}</div>
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
<div class="field"><label>{{ t('任务', 'Task') }}</label><textarea v-model="form.input" class="textarea" required :placeholder="t('描述希望智能体完成的任务', 'Describe the task for the agent')" /></div>
<div class="form-grid">
<div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
<div class="field"><label>模型</label><input v-model="form.model" class="input" list="agent-models" placeholder="填写模型 ID" required /><datalist id="agent-models"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></datalist></div>
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
<div class="field"><label>工具超时</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>运行超时</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('模型提供商', 'Model provider') }}</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
<div class="field"><label>{{ t('模型', 'Model') }}</label><input v-model="form.model" class="input" list="agent-models" :placeholder="t('填写模型 ID', 'Enter model ID')" required /><datalist id="agent-models"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></datalist></div>
<div class="field"><label>{{ t('技能', 'Skill') }}</label><select v-model="form.skill_id" class="select"><option value="">{{ t('不使用技能', 'No skill') }}</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
<div class="field"><label>{{ t('最大步骤', 'Maximum steps') }}</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
<div class="field"><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('运行超时(秒)', 'Run timeout (seconds)') }}</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('令牌预算', 'Token budget') }}</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('最大并发工具', 'Maximum concurrent tools') }}</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
</div>
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
<div class="field"><label>{{ t('允许使用的工具', 'Allowed tools') }}</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
<label class="network"><input v-model="form.allow_network" type="checkbox" /> {{ t('允许本次运行调用网络工具', 'Allow network tools for this run') }}</label>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? t('创建中…', 'Creating…') : t('创建并运行', 'Create and run') }}</button></div>
</form>
<div v-else class="trace-layout">
@@ -118,15 +119,15 @@ async function handleOpenCitation(data: Record<string, unknown>) {
}">{{ runStatusLabel(agentStore.activeRun?.status) }}</span>
<h2>{{ agentStore.activeRun?.run_id ?? agentStore.activeRunId }}</h2>
<p v-if="agentStore.activeRun" class="run-meta">
<span>步骤 {{ agentStore.currentStep }} / {{ agentStore.activeRun.max_steps }}</span>
<span>{{ t('步骤', 'Step') }} {{ agentStore.currentStep }} / {{ agentStore.activeRun.max_steps }}</span>
<span>·</span>
<span>Token: {{ agentStore.activeRun.token_usage?.total_tokens ?? 0 }}</span>
<span v-if="agentStore.activeRun.started_at">·</span>
<span v-if="agentStore.activeRun.started_at">开始: {{ new Date(agentStore.activeRun.started_at).toLocaleString() }}</span>
<span v-if="agentStore.activeRun.started_at">{{ t('开始', 'Started') }}: {{ new Date(agentStore.activeRun.started_at).toLocaleString(localeTag()) }}</span>
</p>
</div>
<div class="inline-actions">
<button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">取消运行</button>
<button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">{{ t('取消运行', 'Cancel run') }}</button>
<button class="button-secondary" @click="agentStore.loadRun(agentStore.activeRunId!)">重新加载</button>
</div>
</div>
@@ -138,7 +139,7 @@ async function handleOpenCitation(data: Record<string, unknown>) {
</div>
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">所需权限{{ permissionLabel(agentStore.permissionRequest.permission) }}{{ agentStore.permissionRequest.permission }}</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">仅本次允许</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">本次会话允许</button><button class="button-danger" @click="agentStore.respondPermission('deny')">拒绝</button></div></div>
<div class="modal"><span class="badge warning">{{ t('权限确认', 'Permission Confirmation') }}</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">{{ t('所需权限:', 'Required permission: ') }}{{ permissionLabel(agentStore.permissionRequest.permission) }} ({{ agentStore.permissionRequest.permission }})</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">{{ t('仅本次允许', 'Allow once') }}</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">{{ t('本次会话允许', 'Allow for session') }}</button><button class="button-danger" @click="agentStore.respondPermission('deny')">{{ t('拒绝', 'Deny') }}</button></div></div>
</div>
</section>
</template>
+4 -3
View File
@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
import { localeTag, t } from '@/i18n'
import { runStatusLabel } from './labels'
const agentStore = useAgentStore()
@@ -9,7 +10,7 @@ const router = useRouter()
const error = ref('')
onMounted(async () => {
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : '运行记录加载失败' }
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : t('运行记录加载失败', 'Failed to load runs') }
})
function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) }
@@ -17,13 +18,13 @@ function selectRun(runId: string) { void router.push({ name: 'agent', params: {
<template>
<div class="sidebar-panel">
<button class="button-primary new-button" @click="router.push({ name: 'agent' })"> 新建运行</button>
<button class="button-primary new-button" @click="router.push({ name: 'agent' })"> {{ t('新建运行', 'New run') }}</button>
<p v-if="error" class="subtle error-text">{{ error }}</p>
<div class="sidebar-list">
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span>
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString() : '等待开始' }}</small>
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString(localeTag()) : t('等待开始', 'Waiting to start') }}</small>
</button>
</div>
</div>
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toolDescription, toolLabel } from './labels'
import { t } from '@/i18n'
const props = defineProps<{ name: string; description: string; selected: boolean }>()
const emit = defineEmits<{ toggle: [name: string] }>()
@@ -19,7 +20,7 @@ const showOriginal = computed(() => props.description.length > 0)
</span>
</label>
<details v-if="showOriginal" class="tool-original">
<summary>查看服务原文与参数</summary>
<summary>{{ t('查看服务原文与参数', 'View original service description and parameters') }}</summary>
<p>{{ description }}</p>
</details>
</article>
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { localeTag, t } from '@/i18n'
import { computed, ref } from 'vue'
import type { TraceNode, AgentEvent } from '@/contracts'
import { buildTraceNodes, getToolCallsFromEvents, getTotalDuration } from '@/services/traceService'
import { eventLabel } from './labels'
import { eventLabel, localizeDetails } from './labels'
const props = defineProps<{
events: AgentEvent[]
@@ -64,7 +65,7 @@ function isDetailOpen(nodeId: string): boolean {
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
return d.toLocaleTimeString(localeTag(), { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
function formatDuration(ms: number): string {
@@ -111,7 +112,7 @@ function prettyData(data: Record<string, unknown>): string {
if (typeof filtered.output === 'string' && filtered.output.length > 500) {
filtered.output = filtered.output.slice(0, 500) + '...'
}
return JSON.stringify(filtered, null, 2)
return JSON.stringify(localizeDetails(filtered), null, 2)
}
function flatNodes(nodes: TraceNode[], depth = 0): Array<{ node: TraceNode; depth: number }> {
@@ -213,7 +214,7 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
</article>
<div v-if="!events.length" class="empty-state">
<div><strong>等待执行轨迹</strong><p>事件连接建立后将在这里实时显示</p></div>
<div><strong>{{ t('等待执行轨迹', 'Waiting for trace events') }}</strong><p>{{ t('事件连接建立后将在这里实时显示。', 'Events will appear here after the connection is established.') }}</p></div>
</div>
</div>
</div>
+27 -7
View File
@@ -1,4 +1,5 @@
import type { AgentEventType, AgentRunStatus } from '@/contracts'
import { appLocale, t } from '@/i18n'
const runStatusLabels: Record<AgentRunStatus, string> = {
queued: '排队中',
@@ -27,6 +28,20 @@ const eventLabels: Record<AgentEventType, string> = {
RunCancelled: '运行取消',
}
const runStatusLabelsEn: Record<AgentRunStatus, string> = {
queued: 'Queued', running: 'Running', waiting_permission: 'Waiting for permission',
completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled',
}
const eventLabelsEn: Record<AgentEventType, string> = {
RunStarted: 'Run started', TextDelta: 'Response', ThinkingDelta: 'Reasoning',
ToolCall: 'Tool call', ToolResult: 'Tool result', PermissionRequired: 'Permission required',
Usage: 'Usage', Citation: 'Citation', ModelCallStarted: 'Model call started',
ModelCallCompleted: 'Model call completed', ModelCallFailed: 'Model call failed',
PermissionResolved: 'Permission resolved', RunCompleted: 'Run completed',
RunFailed: 'Run failed', RunCancelled: 'Run cancelled',
}
const toolLabels: Record<string, string> = {
'system.echo': '回显测试',
'math.add': '数值相加',
@@ -116,45 +131,50 @@ const detailLabels: Record<string, string> = {
}
export function runStatusLabel(status?: AgentRunStatus): string {
return status ? runStatusLabels[status] : '未知状态'
if (!status) return t('未知状态', 'Unknown status')
return appLocale.value === 'en' ? runStatusLabelsEn[status] : runStatusLabels[status]
}
export function eventLabel(event: AgentEventType): string {
return eventLabels[event]
return appLocale.value === 'en' ? eventLabelsEn[event] : eventLabels[event]
}
export function toolLabel(name: string): string {
const remote = mcpName(name)
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`
if (remote) return appLocale.value === 'en' ? `MCP Tool · ${remote}` : (mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`)
if (appLocale.value === 'en') return name.split('.').map(part => part[0]?.toUpperCase() + part.slice(1)).join(' ')
return toolLabels[name] ?? name
}
export function toolDescription(name: string, fallback: string): string {
const remote = mcpName(name)
if (remote) {
if (appLocale.value === 'en') return fallback && !/\p{Script=Han}/u.test(fallback) ? fallback : `MCP tool ${remote}. See the original service description for full parameters.`
if (/\p{Script=Han}/u.test(fallback)) return fallback
return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。'
}
if (appLocale.value === 'en') return fallback && !/\p{Script=Han}/u.test(fallback) ? fallback : `Built-in tool: ${name}`
return toolDescriptions[name] ?? fallback
}
export function permissionLabel(permission: string): string {
if (appLocale.value === 'en') return permission.split('.').map(part => part[0]?.toUpperCase() + part.slice(1)).join(' ')
return permissionLabels[permission] ?? permission
}
function localizeValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(localizeValue)
if (value && typeof value === 'object') return localizeDetails(value as Record<string, unknown>)
if (value === true) return '是'
if (value === false) return '否'
if (value === true) return t('是', 'Yes')
if (value === false) return t('否', 'No')
if (typeof value === 'string' && value in runStatusLabels) {
return runStatusLabels[value as AgentRunStatus]
return runStatusLabel(value as AgentRunStatus)
}
return value
}
export function localizeDetails(data: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [detailLabels[key] ?? key, localizeValue(value)])
Object.entries(data).map(([key, value]) => [appLocale.value === 'en' ? key.replaceAll('_', ' ') : (detailLabels[key] ?? key), localizeValue(value)])
)
}
@@ -11,6 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
vi.mock('@/services/chatService', () => ({
listConversations: vi.fn().mockResolvedValue({ items: [], page: { total: 0, limit: 100, offset: 0 } }),
listConversationMessages: vi.fn(), createConversation: vi.fn(), removeConversation: vi.fn(), streamChat: vi.fn(),
}))
beforeEach(() => {
setActivePinia(createPinia())
+18 -17
View File
@@ -6,6 +6,7 @@ import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useCitationNavigation } from '@/composables/useCitationNavigation'
import { t } from '@/i18n'
const chatStore = useChatStore()
const providerStore = useProviderStore()
@@ -19,7 +20,7 @@ const availableModels = computed(() => providerStore.modelsByProvider[chatStore.
onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), chatStore.loadConversations()])
if (disposed || providerStore.error) return
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
if (!selected) {
@@ -29,7 +30,7 @@ onMounted(async () => {
}
} catch (error) {
if (disposed) return
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,请检查后端连接。'
loadError.value = error instanceof Error ? error.message : t('无法加载 AI 配置,请检查后端连接。', 'Unable to load AI configuration. Check the backend connection.')
}
})
@@ -37,7 +38,7 @@ async function refreshModels(providerId: string) {
loadError.value = ''
if (!providerId) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : t('模型列表加载失败,请手动填写模型 ID。', 'Unable to load models. Enter a model ID manually.') }
}
watch(() => chatStore.selectedProviderId, async (providerId) => {
@@ -52,7 +53,7 @@ async function openCitationCard(citation: Citation) {
try {
await openCitation(citation)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '引用定位失败'
loadError.value = error instanceof Error ? error.message : t('引用定位失败', 'Failed to open citation')
}
}
</script>
@@ -63,19 +64,19 @@ async function openCitationCard(citation: Citation) {
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
</select></div>
<div class="field compact"><label>模型 ID</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" placeholder="填写模型 ID" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />检索知识库</label>
<span class="subtle">开启后将相关笔记片段发送给所选模型并显示来源技能调用请使用智能体</span>
<div class="field compact"><label>{{ t('模型 ID', 'Model ID') }}</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" :placeholder="t('填写模型 ID', 'Enter model ID')" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />{{ t('检索知识库', 'Search knowledge base') }}</label>
<span class="subtle">{{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }}</span>
</header>
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
<div v-if="loadError || providerStore.error || chatStore.historyError" class="error-banner chat-error">{{ loadError || providerStore.error || chatStore.historyError }}</div>
<main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>请先配置模型提供商聊天记录仅保留在本次页面会话中</p></div></div>
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录保存在本地数据库中。', 'Configure a model provider first. Messages are saved in the local database.') }}</p></div></div>
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
<div class="avatar">{{ message.role === 'user' ? t('你', 'You') : 'AI' }}</div>
<div class="message-body">
<details v-if="message.thinking" class="thinking"><summary>思考过程</summary><p>{{ message.thinking }}</p></details>
<details v-if="message.thinking" class="thinking"><summary>{{ t('思考过程', 'Reasoning') }}</summary><p>{{ message.thinking }}</p></details>
<MarkdownContent v-if="message.content" class="message-content" :source="message.content" />
<div v-else-if="chatStore.isStreaming" class="message-content">正在思考</div>
<div v-else-if="chatStore.isStreaming" class="message-content">{{ t('正在思考', 'Thinking') }}</div>
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
<div v-if="message.citations?.length" class="citations">
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitationCard(citation)">
@@ -83,16 +84,16 @@ async function openCitationCard(citation: Citation) {
</button>
</div>
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined">输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</span></small>
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined"> ({{ t('输入', 'input') }} {{ message.usage.input_tokens }} / {{ t('输出', 'output') }} {{ message.usage.output_tokens }})</span></small>
</div>
</article>
</main>
<footer class="composer">
<textarea v-model="chatStore.inputText" class="textarea" placeholder="输入问题,Ctrl + Enter 发送"
<textarea v-model="chatStore.inputText" class="textarea" :placeholder="t('输入问题,Ctrl + Enter 发送', 'Enter a question; press Ctrl + Enter to send')"
@keydown.ctrl.enter.prevent="send" />
<div class="composer-actions"><span class="subtle">回答可能包含错误请核对 Citation</span>
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button>
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">发送</button>
<div class="composer-actions"><span class="subtle">{{ t('回答可能包含错误,请核对 Citation。', 'Answers may contain errors. Verify the citations.') }}</span>
<button v-if="chatStore.isStreaming || chatStore.isPreparing" class="button-danger" @click="chatStore.stopGeneration">{{ t('停止', 'Stop') }}</button>
<button v-else class="button-primary" :disabled="!chatStore.canSend || !chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">{{ t('发送', 'Send') }}</button>
</div>
</footer>
</section>
@@ -1,18 +1,21 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { t } from '@/i18n'
const chatStore = useChatStore()
onMounted(() => { void chatStore.loadConversations() })
</script>
<template>
<div class="sidebar-panel">
<button class="button-primary new-button" @click="chatStore.createNewConversation"> 新对话</button>
<button class="button-primary new-button" @click="chatStore.createNewConversation"> {{ t('新对话', 'New conversation') }}</button>
<div class="sidebar-list conversation-list">
<div v-for="conversation in chatStore.sortedConversations" :key="conversation.conversation_id"
class="sidebar-list-item conversation" :class="{ active: chatStore.activeConversationId === conversation.conversation_id }"
@click="chatStore.setActiveConversation(conversation.conversation_id)">
<div><strong>{{ conversation.title }}</strong><p>{{ conversation.message_count }} 条消息</p></div>
<button class="delete" title="删除会话" @click.stop="chatStore.deleteConversation(conversation.conversation_id)">×</button>
<div><strong>{{ conversation.title }}</strong><p>{{ conversation.message_count }} {{ t('条消息', 'messages') }}</p></div>
<button class="delete" :title="t('删除会话', 'Delete conversation')" @click.stop="chatStore.deleteConversation(conversation.conversation_id)">×</button>
</div>
</div>
</div>
+11 -9
View File
@@ -1,26 +1,28 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import { computed } from 'vue'
import { t } from '@/i18n'
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const statusText: Record<string, string> = {
idle: '空闲', dirty: '未保存', saving: '保存中…', saved: '已保存', save_failed: '保存失败',
external_changed: '外部文件已变化', conflict: '存在编辑冲突',
}
const statusText = computed<Record<string, string>>(() => ({
idle: t('空闲', 'Idle'), dirty: t('未保存', 'Unsaved'), saving: t('保存中…', 'Saving…'), saved: t('已保存', 'Saved'), save_failed: t('保存失败', 'Save failed'),
external_changed: t('外部文件已变化', 'File changed externally'), conflict: t('存在编辑冲突', 'Edit conflict'),
}))
</script>
<template>
<header class="editor-header">
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<div class="mode-switch" aria-label="编辑模式">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">写作</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">源码</button>
<div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button>
</div>
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">保存</button>
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">{{ t('保存', 'Save') }}</button>
</div>
</header>
</template>
@@ -53,4 +53,19 @@ describe('EditorPane file switching', () => {
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
expect(wrapper.text()).not.toContain('祝你写作愉快')
})
it('applies the saved spell-check and language settings to source mode', async () => {
const editor = useEditorStore()
const settings = (await import('@/stores/settings')).useSettingsStore()
editor.setMode('source')
settings.spellCheck = true
settings.language = 'en'
wrapper = mount(EditorPane, { attachTo: document.body })
await nextTick()
const textarea = wrapper.get('textarea')
expect(textarea.attributes('spellcheck')).toBe('true')
expect(textarea.attributes('lang')).toBe('en')
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
})
})
+3 -3
View File
@@ -14,10 +14,10 @@ function updateContent(event: Event) {
</script>
<template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}`"
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
:initial-content="editorStore.content" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
aria-label="Markdown 源码编辑器" @input="updateContent" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
</template>
<style scoped>
@@ -3,9 +3,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -44,6 +49,51 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, 0)))
})
for (let attempt = 0; attempt < 100 && !wrapper.find('.language-button').exists(); attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await wrapper.get('.language-button').trigger('click')
const item = wrapper.get(`.language-list-item[data-language="${id}"]`)
expect(item.text()).toBe(label)
await item.trigger('click')
const markdown = editor.action(getMarkdown())
expect(markdown).toContain(`\`\`\`${id}\n`)
const html = await renderMarkdown(markdown)
expect(new Set([...html.matchAll(/--shiki-light:([^;" ]+)/g)].map(match => match[1])).size).toBeGreaterThan(1)
const reopened = mount(VisualMarkdownEditor, { props: { initialContent: markdown }, attachTo: document.body })
mounted.push(reopened)
const restored = await waitForEditor(reopened)
expect(restored.action(ctx => ctx.get(editorViewCtx).state.doc.firstChild?.attrs.language)).toBe(id)
})
it.each(['github-light', 'github-dark'] as const)('keeps Shiki %s mappings after Crepe merges its defaults', async theme => {
useThemeStore().codeBlockTheme = theme
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```python\nprint("Hello")\n```' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const config = editor.action(ctx => ctx.get(codeBlockConfig.key))
const matching = config.languages.filter(item => item.alias.includes('python'))
expect(matching).toHaveLength(1)
for (const name of ['Java', 'Go', 'Rust']) {
const language = config.languages.find(item => item.name === name.toLowerCase())
expect(language, `${name} remains available`).toBeDefined()
const view = new CodeMirror({ doc: 'class Example {}', extensions: [...config.extensions, await language!.load()] })
try { expect(view.dom.querySelector('.shiki-token')).not.toBeNull() }
finally { view.destroy() }
}
const cm = new CodeMirror({ doc: 'print("Hello")', extensions: [...config.extensions, await matching[0]!.load()] })
try {
const string = [...cm.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(el => el.textContent?.includes('Hello'))
expect(string?.style.color.toUpperCase()).toBe(theme === 'github-dark' ? '#9ECBFF' : '#032F62')
} finally { cm.destroy() }
})
it('applies bold from the top toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
@@ -90,4 +140,19 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
expect(editor.action(getMarkdown()).trim()).toBe('alpha')
})
it('updates native spell checking on the ProseMirror editor', async () => {
const settings = useSettingsStore()
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'mispelled word' }, attachTo: document.body })
mounted.push(wrapper)
await waitForEditor(wrapper)
settings.spellCheck = true
settings.language = 'en'
await wrapper.vm.$nextTick()
const editable = wrapper.get('.ProseMirror')
expect(editable.attributes('spellcheck')).toBe('true')
expect(editable.attributes('lang')).toBe('en')
})
})
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -22,6 +28,7 @@ import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
@@ -33,6 +40,15 @@ const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
if (!editable) return
editable.spellcheck = settingsStore.spellCheck
editable.setAttribute('spellcheck', String(settingsStore.spellCheck))
editable.lang = settingsStore.language
}
type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block'
@@ -57,14 +73,14 @@ function runCommand(command: ToolbarCommand) {
function applyLink() {
if (!crepe) return
// TODO(editor): Element Plus prompt URL
const href = window.prompt('请输入链接地址', 'https://')?.trim()
const href = window.prompt(t('请输入链接地址', 'Enter link address'), 'https://')?.trim()
if (!href) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const commands = ctx.get(commandsCtx)
if (view.state.selection.empty) {
const label = window.prompt('请输入链接文字', href)?.trim() || href
const label = window.prompt(t('请输入链接文字', 'Enter link text'), href)?.trim() || href
const from = view.state.selection.from
const transaction = view.state.tr.insertText(label, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
@@ -107,60 +123,67 @@ onMounted(async () => {
defaultValue: props.initialContent,
features: { [Crepe.Feature.TopBar]: false },
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false,
searchPlaceholder: '搜索语言',
noResultText: '没有匹配的语言',
copyText: '复制',
searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'),
copyText: t('复制', 'Copy'),
},
[Crepe.Feature.Latex]: {
inlineEditConfirm: '确认',
inlineEditConfirm: t('确认', 'Confirm'),
},
[Crepe.Feature.LinkTooltip]: {
editButton: '编辑',
removeButton: '移除',
confirmButton: '确认',
inputPlaceholder: '粘贴链接地址…',
editButton: t('编辑', 'Edit'),
removeButton: t('移除', 'Remove'),
confirmButton: t('确认', 'Confirm'),
inputPlaceholder: t('粘贴链接地址…', 'Paste link address…'),
},
[Crepe.Feature.Toolbar]: {
boldLabel: '加粗',
italicLabel: '斜体',
strikethroughLabel: '删除线',
codeLabel: '行内代码',
latexLabel: '行内公式',
linkLabel: '链接',
boldLabel: t('加粗', 'Bold'),
italicLabel: t('斜体', 'Italic'),
strikethroughLabel: t('删除线', 'Strikethrough'),
codeLabel: t('行内代码', 'Inline code'),
latexLabel: t('行内公式', 'Inline formula'),
linkLabel: t('链接', 'Link'),
},
[Crepe.Feature.BlockEdit]: {
textGroup: {
label: '文本',
text: { label: '正文' },
h1: { label: '一级标题' },
h2: { label: '二级标题' },
h3: { label: '三级标题' },
h4: { label: '四级标题' },
h5: { label: '五级标题' },
h6: { label: '六级标题' },
quote: { label: '引用' },
divider: { label: '分割线' },
label: t('文本', 'Text'),
text: { label: t('正文', 'Paragraph') },
h1: { label: t('一级标题', 'Heading 1') },
h2: { label: t('二级标题', 'Heading 2') },
h3: { label: t('三级标题', 'Heading 3') },
h4: { label: t('四级标题', 'Heading 4') },
h5: { label: t('五级标题', 'Heading 5') },
h6: { label: t('六级标题', 'Heading 6') },
quote: { label: t('引用', 'Quote') },
divider: { label: t('分割线', 'Divider') },
},
listGroup: {
label: '列表',
bulletList: { label: '无序列表' },
orderedList: { label: '有序列表' },
taskList: { label: '任务列表' },
label: t('列表', 'Lists'),
bulletList: { label: t('无序列表', 'Bullet list') },
orderedList: { label: t('有序列表', 'Ordered list') },
taskList: { label: t('任务列表', 'Task list') },
},
advancedGroup: {
label: '插入',
image: { label: '图片' },
codeBlock: { label: '代码块' },
table: { label: '表格' },
math: { label: '公式块' },
label: t('插入', 'Insert'),
image: { label: t('图片', 'Image') },
codeBlock: { label: t('代码块', 'Code block') },
table: { label: t('表格', 'Table') },
math: { label: t('公式块', 'Formula block') },
},
},
},
})
// Crepe's defaultsDeep merges language arrays and theme extension internals.
// Replace both AFTER feature configuration to avoid default grammar collisions.
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
@@ -171,52 +194,56 @@ onMounted(async () => {
})
})
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
applyProofingPreferences()
loading.value = false
})
onBeforeUnmount(() => { void crepe?.destroy() })
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
<template>
<div class="visual-editor">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏">
<label class="toolbar-select heading-select" title="设置标题级别">
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span>
<select aria-label="标题级别" @change="applyHeading">
<option value="" selected>标题</option>
<option value="paragraph">正文</option>
<select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
<option value="" selected>{{ t('标题', 'Heading') }}</option>
<option value="paragraph">{{ t('正文', 'Paragraph') }}</option>
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
</select>
</label>
<button type="button" title="加粗 (Ctrl+B)" aria-label="加粗" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button>
<button type="button" title="斜体 (Ctrl+I)" aria-label="斜体" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button>
<button type="button" :title="t('加粗 (Ctrl+B)', 'Bold (Ctrl+B)')" :aria-label="t('加粗', 'Bold')" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button>
<button type="button" :title="t('斜体 (Ctrl+I)', 'Italic (Ctrl+I)')" :aria-label="t('斜体', 'Italic')" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button>
<span class="toolbar-divider" />
<button type="button" class="list-glyph" title="有序列表" aria-label="有序列表" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines"></span></button>
<button type="button" class="list-glyph" title="无序列表" aria-label="无序列表" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker"></span><span class="list-lines"></span></button>
<button type="button" class="list-glyph" :title="t('有序列表', 'Ordered list')" :aria-label="t('有序列表', 'Ordered list')" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines"></span></button>
<button type="button" class="list-glyph" :title="t('无序列表', 'Bullet list')" :aria-label="t('无序列表', 'Bullet list')" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker"></span><span class="list-lines"></span></button>
<span class="toolbar-divider" />
<label class="toolbar-select font-size-select" title="选择预设字号">
<label class="toolbar-select font-size-select" :title="t('选择预设字号', 'Choose a preset font size')">
<span class="format-glyph font-size-glyph">A</span>
<select aria-label="文字字号" @change="applyFontSize">
<option value="" selected>字号</option>
<select :aria-label="t('文字字号', 'Font size')" @change="applyFontSize">
<option value="" selected>{{ t('字号', 'Size') }}</option>
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
</select>
</label>
<div class="font-size-input" title="输入字号后按 Enter 或点击应用">
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" aria-label="自定义字号"
<div class="font-size-input" :title="t('输入字号后按 Enter 或点击应用', 'Enter a font size, then press Enter or Apply')">
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" :aria-label="t('自定义字号', 'Custom font size')"
@keydown.enter.prevent="applyFontSizeValue" />
<span>px</span>
<button type="button" aria-label="应用自定义字号" @pointerdown.prevent="applyFontSizeValue">应用</button>
<button type="button" :aria-label="t('应用自定义字号', 'Apply custom font size')" @pointerdown.prevent="applyFontSizeValue">{{ t('应用', 'Apply') }}</button>
</div>
<span class="toolbar-divider" />
<button type="button" title="行内代码" aria-label="行内代码" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button>
<button type="button" title="代码块" aria-label="代码块" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
<button type="button" title="行内公式" aria-label="行内公式" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button type="button" title="公式块" aria-label="公式块" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" title="插入链接" aria-label="插入链接" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
<button type="button" :title="t('行内代码', 'Inline code')" :aria-label="t('行内代码', 'Inline code')" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button>
<button type="button" :title="t('代码块', 'Code block')" :aria-label="t('代码块', 'Code block')" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
<button type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
</div>
<div v-if="loading" class="editor-loading">正在加载编辑器</div>
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
</div>
</template>
@@ -276,7 +303,10 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.language-picker[popover]) { position: fixed !important; inset: auto; left: var(--picker-left) !important; top: var(--picker-top) !important; margin: 0; padding: 0; border: 0; overflow: visible; background: transparent; color: var(--color-text-primary); }
.milkdown-host :deep(.language-picker .language-list) { height: auto; max-height: var(--picker-list-height, 280px); }
.milkdown-host :deep(.language-picker .list-wrapper) { width: min(260px, calc(100vw - 24px)); border: 1px solid var(--color-border-default); background: var(--color-surface-elevated); box-shadow: var(--shadow-md); }
.milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Roberto Huertas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
/** Promote Milkdown's menu to the top layer without moving its Vue-owned DOM. */
export function installLanguagePickerPopover(root: HTMLElement): () => void {
const menus = new Set<HTMLElement>()
function sync() {
root.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => {
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
if (!trigger || typeof menu.showPopover !== 'function') return
menus.add(menu)
menu.setAttribute('popover', 'manual')
const search = menu.querySelector<HTMLInputElement>('.search-input')
if (search) {
search.autocomplete = 'off'
search.spellcheck = false
}
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
if (menu.matches(':popover-open')) menu.hidePopover()
return
}
if (!menu.matches(':popover-open')) menu.showPopover()
const anchor = trigger.getBoundingClientRect()
const below = window.innerHeight - anchor.bottom - 16
const above = anchor.top - 16
const placeAbove = below < 240 && above > below
const available = Math.max(80, placeAbove ? above : below)
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
const bounds = menu.getBoundingClientRect()
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
})
for (const menu of menus) if (!root.contains(menu)) menus.delete(menu)
}
const observer = new MutationObserver(sync)
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
root.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
return () => {
observer.disconnect()
root.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
for (const menu of menus) if (menu.matches(':popover-open')) menu.hidePopover()
}
}
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { Compartment } from '@codemirror/state'
import { bundledLanguagesInfo } from 'shiki/langs'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
import { getCodeTokenizer } from '@/utils/markdown'
const editors: EditorView[] = []
afterEach(() => { editors.splice(0).forEach(view => view.destroy()) })
it.each(['github-light', 'github-dark'] as const)('uses Shiki %s tokens and updates editable content', async theme => {
const support = await shikiLanguage('python', theme)
const view = new EditorView({ doc: 'print("Hello")', extensions: [support] })
editors.push(view)
const tokenize = await getCodeTokenizer(theme)
const expected = tokenize('print("Hello")', 'python')[0]!.find(token => token.content.includes('Hello'))!
const colored = [...view.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(node => node.textContent?.includes('Hello'))!
expect(colored).toBeDefined()
const sample = document.createElement('span'); sample.style.color = expected.color!
expect(colored.style.color).toBe(sample.style.color)
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'def hello():\n return 42' } })
expect(view.state.doc.toString()).toContain('return 42')
expect(view.dom.querySelectorAll('.shiki-token').length).toBeGreaterThan(2)
expect(view.dom.textContent).toContain('return 42')
})
it('reconfigures language and theme without modifying the document', async () => {
const config = new Compartment()
const view = new EditorView({ doc: 'const answer = 42', extensions: [config.of(await shikiLanguage('javascript', 'github-light'))] })
editors.push(view)
const before = view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color
view.dispatch({ effects: config.reconfigure(await shikiLanguage('javascript', 'github-dark')) })
expect(view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color).not.toBe(before)
expect(view.state.doc.toString()).toBe('const answer = 42')
view.dispatch({ effects: config.reconfigure(await shikiLanguage('text', 'github-dark')) })
expect(view.state.doc.toString()).toBe('const answer = 42')
})
it('offers fenced-code aliases and retains the LaTeX selector', () => {
const languages = shikiLanguages('github-light')
expect(languages.find(item => item.name === 'python')?.alias).toContain('py')
expect(languages.find(item => item.name === 'latex')?.alias).toContain('latex')
})
it('offers every bundled Shiki language and alias', () => {
const languages = shikiLanguages('github-light')
expect(languages).toHaveLength(bundledLanguagesInfo.length + 1)
for (const info of bundledLanguagesInfo) {
const language = languages.find(item => item.alias.includes(info.id))!
expect(language, info.id).toBeDefined()
expect(language.name).toBe(info.id)
expect(language.alias).toContain(info.name.toLowerCase())
for (const alias of info.aliases ?? []) expect(language.alias).toContain(alias.toLowerCase())
}
})
it('loads every bundled grammar and produces tokens with both GitHub themes', async () => {
for (const info of bundledLanguagesInfo) {
for (const theme of ['github-light', 'github-dark'] as const) {
const tokenize = await getCodeTokenizer(theme, info.id)
const tokens = tokenize('example = 42', info.id).flat()
expect(tokens.map(token => token.content).join(''), info.id).toBe('example = 42')
expect(tokens.every(token => token.color), info.id).toBe(true)
}
}
}, 120000)
@@ -0,0 +1,64 @@
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { bundledLanguagesInfo } from 'shiki/langs'
import { getCodeTokenizer } from '@/utils/markdown'
type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme, language)
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) { this.decorations = this.highlight(view) }
update(update: ViewUpdate) {
if (update.docChanged) this.decorations = this.highlight(update.view)
}
highlight(view: EditorView): DecorationSet {
const tokens = tokenize(view.state.doc.toString(), language)
const ranges = tokens.flatMap((line, index) => {
let offset = view.state.doc.line(index + 1).from
return line.flatMap(token => {
const from = offset
offset += token.content.length
if (from === offset) return []
const fontStyle = token.fontStyle ?? 0
return [Decoration.mark({
class: 'shiki-token',
attributes: { style: `color:${token.color};font-style:${fontStyle & 1 ? 'italic' : 'normal'};font-weight:${fontStyle & 2 ? 'bold' : 'normal'};text-decoration:${fontStyle & 4 ? 'underline' : 'none'}` },
}).range(from, offset)]
})
})
return Decoration.set(ranges)
}
}, { decorations: value => value.decorations })
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
return new LanguageSupport(parser, highlights)
}
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
return [
...bundledLanguagesInfo.map(info => LanguageDescription.of({
name: info.id,
alias: [info.name, ...(info.aliases ?? [])],
load: () => shikiLanguage(info.id, theme),
})),
LanguageDescription.of({ name: 'text', alias: ['Plain text', 'txt', 'plaintext'], load: () => shikiLanguage('text', theme) }),
]
}
const languageLabels = new Map(bundledLanguagesInfo.flatMap(info =>
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.name] as const),
))
export function renderCodeLanguage(language: string): string {
return languageLabels.get(language.toLowerCase()) ?? (['text', 'txt', 'plaintext'].includes(language.toLowerCase()) ? 'Plain text' : language)
}
export function shikiEditorTheme(theme: CodeTheme) {
return EditorView.theme({
'&': { color: 'var(--color-code-text)', backgroundColor: 'var(--color-code-background)' },
'.cm-gutters': { color: 'var(--color-code-muted)', backgroundColor: 'var(--color-code-background)' },
}, { dark: theme === 'github-dark' })
}
+34 -33
View File
@@ -5,6 +5,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
import * as service from '@/services/mcpServerService'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
import { t } from '@/i18n'
const servers = ref<McpServer[]>([])
const busy = ref('')
@@ -24,12 +25,12 @@ const secretDrafts = reactive<Record<string, string>>({})
const form = reactive<McpServerInput>(emptyMcpConfig())
const importedSecrets = ref<ImportedSecret[]>([])
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
const dialogTitle = computed(() => editingId.value ? t('编辑 MCP 服务器', 'Edit MCP Server') : t('新增 MCP 服务器', 'Add MCP Server'))
async function load() {
error.value = ''
try { servers.value = await service.listMcpServers() }
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') }
catch (cause) { error.value = message(cause, t('读取 MCP 服务器失败', 'Failed to load MCP servers')) }
}
function resetEditor(input: McpServerInput) {
@@ -84,8 +85,8 @@ function applyTemplate(transport: McpServerTransport) {
function parseObject(value: string, label: string): Record<string, string> {
let parsed: unknown
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}必须是 JSON 对象`) }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`) }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`)
return parsed as Record<string, string>
}
@@ -97,8 +98,8 @@ function formPayload(): McpServerInput {
command: stdio ? form.command?.trim() : null,
args: stdio ? argsText.value.split('\n').map(value => value.trim()).filter(Boolean) : [],
url: stdio ? null : form.url?.trim(),
headers: stdio ? {} : parseObject(headersText.value, '普通 Header'),
environment: stdio ? parseObject(environmentText.value, '普通环境变量') : {},
headers: stdio ? {} : parseObject(headersText.value, t('普通 Header', 'Headers')),
environment: stdio ? parseObject(environmentText.value, t('普通环境变量', 'Environment variables')) : {},
secret_environment_keys: stdio ? splitKeys(secretKeysText.value) : [],
secret_header_keys: stdio ? [] : splitKeys(secretHeaderKeysText.value),
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
@@ -131,7 +132,7 @@ function switchMode(mode: 'form' | 'json') {
if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
else resetEditor(payload(false))
editorMode.value = mode
} catch (cause) { error.value = message(cause, '配置转换失败') }
} catch (cause) { error.value = message(cause, t('配置转换失败', 'Configuration conversion failed')) }
}
async function save() {
@@ -140,8 +141,8 @@ async function save() {
try {
error.value = ''
const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error(t('请填写服务器名称和连接地址', 'Enter a server name and connection address'))
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?'))) return
busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can
@@ -157,7 +158,7 @@ async function save() {
await load()
} catch (cause) {
if (saved) await load()
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}`
error.value = `${saved ? t('服务器配置已保存,但密钥保存失败;可点击保存重试。', 'Server settings were saved, but saving secrets failed. Save again to retry.') : ''}${message(cause, t('保存失败', 'Save failed'))}`
}
finally { busy.value = '' }
}
@@ -189,8 +190,8 @@ function executionChanged(server: McpServer, input: McpServerInput) {
async function approve(server: McpServer): Promise<McpServer | null> {
if (server.trusted) return server
const localWarning = server.transport === 'stdio' ? '\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。' : '\n\n连接可能向该地址发送配置的 Header。'
if (!confirm(`请确认 MCP 连接:\n\n${server.command_summary}${localWarning}\n\n是否继续?`)) return null
const localWarning = server.transport === 'stdio' ? t('\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。', '\n\nLocal processes have no system-level sandbox. Run trusted servers only.') : t('\n\n连接可能向该地址发送配置的 Header。', '\n\nThe connection may send configured headers to this address.')
if (!confirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`)) return null
return service.trustMcpServer(server)
}
@@ -199,14 +200,14 @@ async function toggle(server: McpServer) { await act(server, 'toggle', current =
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
busy.value = `${action}:${server.server_id}`; error.value = ''
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
catch (cause) { error.value = message(cause, '操作失败') }
catch (cause) { error.value = message(cause, t('操作失败', 'Operation failed')) }
finally { busy.value = '' }
}
async function remove(server: McpServer) {
if (!confirm(`删除“${server.name}”及其加密凭据?`)) return
if (!confirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`))) return
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' }
catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' }
}
async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
@@ -214,7 +215,7 @@ async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
const value = secretDrafts[draftKey]?.trim()
if (!value) return
try { busy.value = `secret:${draftKey}`; await service.putMcpServerSecret(server.server_id, key, value, kind); secretDrafts[draftKey] = ''; await load() }
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' }
catch (cause) { error.value = message(cause, t('保存密钥失败', 'Failed to save secret')) } finally { busy.value = '' }
}
function splitKeys(value: string) { return value.split(/[\n,]/).map(item => item.trim()).filter(Boolean) }
@@ -224,20 +225,20 @@ onMounted(load)
<template>
<section class="feature-page mcp-page">
<header class="feature-header"><div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接凭据与工具生命周期</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div></header>
<div class="notice-banner">stdio 本机进程仅在开发环境开放Streamable HTTP 为首选远程传输SSE 仅用于兼容旧服务器uvx 隔离依赖但不是安全沙箱</div>
<header class="feature-header"><div><h1>{{ t('MCP 服务器', 'MCP Servers') }}</h1><p>{{ t('管理独立 MCP Server 的连接、凭据与工具生命周期。', 'Manage standalone MCP server connections, credentials, and tool lifecycles.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> {{ t('刷新', 'Refresh') }}</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> {{ t('新增服务器', 'Add server') }}</button></div></header>
<div class="notice-banner">{{ t('stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。', 'Local stdio processes are available only in development. Streamable HTTP is the preferred remote transport; SSE supports legacy servers. uvx isolates dependencies but is not a security sandbox.') }}</div>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加 Server,测试连接成功后才能启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div>
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>{{ t('尚未配置 MCP 服务器', 'No MCP servers configured') }}</h2><p>{{ t('添加 Server,测试连接成功后才能启用工具。', 'Add a server and test its connection before enabling its tools.') }}</p><button class="button-primary" @click="openCreate">{{ t('新增服务器', 'Add server') }}</button></div>
<div v-else class="server-list">
<article v-for="server in servers" :key="server.server_id" class="panel server-card">
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '连接已确认' : '等待确认连接' }}</span><span v-if="server.last_test_succeeded">当前配置测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} {{ t('个工具', 'tools') }}</span><span>{{ server.trusted ? t('连接已确认', 'Connection confirmed') : t('等待确认连接', 'Awaiting confirmation') }}</span><span v-if="server.last_test_succeeded">{{ t('当前配置测试成功', 'Current configuration passed') }}</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
<div v-if="Object.keys(server.secret_environment).length || Object.keys(server.secret_headers).length" class="secrets">
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>环境变量 · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">保存</button></span></label>
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'header')">保存</button></span></label>
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>{{ t('环境变量', 'Environment variable') }} · {{ key }} <small>{{ configured ? t('已加密保存', 'Encrypted and saved') : t('未配置', 'Not configured') }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" :placeholder="t('输入后保存(不会回显)', 'Enter and save (never displayed)')"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">{{ t('保存', 'Save') }}</button></span></label>
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? t('已加密保存', 'Encrypted and saved') : t('未配置', 'Not configured') }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" :placeholder="t('输入后保存(不会回显)', 'Enter and save (never displayed)')"><button class="button-secondary" @click="saveSecret(server, key, 'header')">{{ t('保存', 'Save') }}</button></span></label>
</div>
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? '请先测试当前配置' : ''" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer>
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> {{ t('测试连接', 'Test connection') }}</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> {{ t('编辑', 'Edit') }}</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> {{ t('删除', 'Delete') }}</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? t('请先测试当前配置', 'Test the current configuration first') : ''" @click="toggle(server)">{{ server.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button></footer>
</article>
</div>
@@ -246,18 +247,18 @@ onMounted(load)
<fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div v-if="importedSecrets.length" class="notice-banner">已识别 {{ importedSecrets.length }} 项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div>
<div v-if="importedSecrets.length" class="notice-banner">{{ t('已识别', 'Detected') }} {{ importedSecrets.length }} {{ t('项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥', 'secrets. They will be encrypted separately and excluded from regular server settings. Canceling clears unsaved secrets.') }}</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">{{ t('表单配置', 'Form') }}</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">{{ t('JSON 配置', 'JSON') }}</button></div>
<template v-if="editorMode === 'form'">
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
<div class="template-row"><span>服务器配置</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE兼容</button></div>
<template v-if="form.transport === 'stdio'"><label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label><label>参数(每行一项)<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>普通环境变量(JSON<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>普通 HeaderJSON<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>敏感 Header 名(每行一项)<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>声明权限逗号分隔可选<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>启动超时<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
<label>{{ t('服务器名称', 'Server name') }}<input v-model="form.name" maxlength="80" :placeholder="t('例如:文件系统工具', 'For example: Filesystem tools')"></label>
<div class="template-row"><span>{{ t('服务器配置', 'Server configuration') }}</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio {{ t('模板', 'template') }}</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE {{ t('兼容', '(legacy)') }}</button></div>
<template v-if="form.transport === 'stdio'"><label>{{ t('可执行命令', 'Executable command') }}<input v-model="form.command" :placeholder="t('uvx、npx 或可信可执行文件路径', 'uvx, npx, or a trusted executable path')"></label><label>{{ t('参数(每行一项)', 'Arguments (one per line)') }}<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>{{ t('普通环境变量(JSON', 'Environment variables (JSON)') }}<textarea v-model="environmentText" rows="5"></textarea></label><label>{{ t('敏感环境变量名(每行一项)', 'Secret environment names (one per line)') }}<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>{{ t('声明权限(逗号分隔,可选)', 'Declared permissions (comma-separated, optional)') }}<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>{{ t('启动超时(秒)', 'Startup timeout (seconds)') }}<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template>
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置command/args/env 和单服务器 mcpServers 配置已声明的 Secret 及常见 API KeyTokenAuthorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数</small><small>兼容导入 timeout 为启动超时sse_read_timeout 为工具等待上限不保留 SSE 读取超时语义</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
<label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数。', 'Supports NotesAgent, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset>
</form>
</div>
+30 -29
View File
@@ -1,4 +1,5 @@
import type { McpServerInput } from '@/contracts'
import { t } from '@/i18n'
export type SecretKind = 'environment' | 'header'
export interface ImportedSecret { kind: SecretKind; key: string; value: string }
@@ -26,38 +27,38 @@ export function emptyMcpConfig(): McpServerInput {
}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`)
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`)
return value as Record<string, unknown>
}
function strings(value: unknown, label: string): string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`)
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串数组', ' must be a string array')}`)
return [...value]
}
function entries(value: unknown, label: string): Record<string, string> {
if (value === undefined) return {}
const result = object(value, label)
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`)
return { ...result } as Record<string, string>
}
function timeout(value: unknown, fallback: number, max: number, label: string): number {
if (value === undefined) return fallback
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1${max} 秒之间的数字`)
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}${t(`必须是 1${max} 秒之间的数字`, ` must be a number from 1 to ${max} seconds`)}`)
return value
}
// Do not silently rewrite executable arguments or secret values copied from chat.
function checkUrl(value: string, label: string) {
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`)
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`)
}
export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) {
let parsed: unknown
try { parsed = JSON.parse(raw) }
catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') }
catch { throw new Error(t('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义', 'The server configuration is not valid JSON. Check commas, quotes, and invalid \\_ escapes.')) }
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
}
@@ -65,54 +66,54 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection =
* Inline secrets leave the public config here and are sent only to the Secret API.
*/
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
let raw = object(parsed, '服务器配置')
let raw = object(parsed, t('服务器配置', 'Server configuration'))
if ('mcpServers' in raw) {
const servers = Object.entries(object(raw.mcpServers, 'mcpServers'))
if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器')
if (servers.length !== 1) throw new Error(t('请一次导入一个 MCP 服务器', 'Import one MCP server at a time'))
fallbackName = servers[0]![0]
raw = object(servers[0]![1], '服务器配置')
raw = object(servers[0]![1], t('服务器配置', 'Server configuration'))
}
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
if (Object.keys(raw).some(key => !allowed.has(key))) {
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层')
throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.'))
}
if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置')
if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both'))
const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio')
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse')
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error(t('transport 必须是 stdio、streamable_http 或 sse', 'transport must be stdio, streamable_http, or sse'))
const config = emptyMcpConfig()
config.transport = transport as McpServerInput['transport']
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器'))
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 180 个字符')
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : t('MCP 服务器', 'MCP Server')))
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error(t('服务器名称必须为 180 个字符', 'The server name must contain 180 characters'))
config.name = name.trim()
for (const key of ['command', 'url'] as const) {
const value = raw[key]
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`)
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}${t('必须是字符串', ' must be a string')}`)
config[key] = typeof value === 'string' ? value.trim() : null
}
config.args = strings(raw.args, 'args')
if (config.args.length > 64) throw new Error('args 最多允许 64 项')
for (const value of config.args) checkUrl(value, 'args 中的地址')
if (config.args.length > 64) throw new Error(t('args 最多允许 64 项', 'args allows at most 64 items'))
for (const value of config.args) checkUrl(value, t('args 中的地址', 'URL in args'))
config.environment = entries(raw.environment ?? raw.env, 'environment/env')
config.headers = entries(raw.headers, 'headers')
config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))]
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
config.permissions = strings(raw.permissions, 'permissions')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout'))
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时')
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout'))
if (config.transport === 'stdio') {
if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command')
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header')
if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command'))
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error(t('stdio 配置不能包含 URL 或 HTTP Header', 'stdio configuration cannot contain a URL or HTTP headers'))
} else {
if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url')
if (requireConnection && !config.url) throw new Error(t('HTTP/SSE 配置必须填写 url', 'HTTP/SSE configuration requires a URL'))
if (config.url) {
checkUrl(config.url, 'url')
let url: URL
try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址')
try { url = new URL(config.url) } catch { throw new Error(t('url 必须是有效的 HTTP(S) 地址', 'url must be a valid HTTP(S) address')) }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(t('url 必须为不含账号密码或片段的 HTTP(S) 地址', 'url must be an HTTP(S) address without credentials or a fragment'))
}
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量')
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error(t('HTTP/SSE 配置不能包含 command、args 或环境变量', 'HTTP/SSE configuration cannot contain command, args, or environment variables'))
}
const secrets: ImportedSecret[] = []
for (const kind of ['environment', 'header'] as const) {
@@ -120,19 +121,19 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys
const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key
const allKeys = [...Object.keys(values), ...keys]
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明')
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error(t('HTTP Header 名称不能仅大小写不同而重复声明', 'HTTP header names cannot be duplicated with case-only differences'))
const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`)
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? t('环境变量', 'Environment variable') : 'Header'}${t('名称无效;敏感变量名只能填名称,不能填密钥值', ' name is invalid; secret variable declarations accept names only, not secret values')}`)
for (const [key, value] of Object.entries(values)) {
const declared = keys.find(item => identity(item) === identity(key))
const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key)
if (declared || sensitive) {
if (!value || value.length > 32768) throw new Error('密钥值必须为 132768 个字符')
if (!value || value.length > 32768) throw new Error(t('密钥值必须为 132768 个字符', 'Secret values must contain 132768 characters'))
const secretKey = declared ?? key
if (!declared) keys.push(key)
secrets.push({ kind, key: secretKey, value })
delete values[key]
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址')
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, t('环境变量或 Header 地址', 'Environment variable or Header URL'))
}
}
return { config, secrets }
+52 -42
View File
@@ -1,14 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { mediaService, type MediaJob } from '@/services/mediaService'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const route = useRoute()
const submission = createMediaSubmission()
const updateExisting = ref(false)
const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null)
const file = ref<File | null>(null)
const reference = ref<File | null>(null)
const matchResult = ref('')
const terminologyPlaceholder = computed(() => t('{"错误术语": "正确术语"}', '{"incorrect term": "correct term"}'))
const localOnly = ref(false)
const diarization = ref(true)
const terminology = ref('')
@@ -16,17 +21,22 @@ const busy = ref(false)
const error = ref('')
const notice = ref('')
const dirty = ref(false)
const title = ref('课堂转写')
const title = ref(t('课堂转写', 'Class transcript'))
const player = ref<HTMLAudioElement | null>(null)
const position = ref(0)
const speed = ref(1)
const history = ref<MediaJob[]>([])
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
const labels = {queued: '排队中', running: '转写中', processing: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消'}
const labels = computed(() => ({queued: t('排队中', 'Queued'), running: t('转写中', 'Transcribing'), processing: t('处理中', 'Processing'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', 'Cancelled')}))
const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])])
const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status)
const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`
const warningLabel = (warning: string) => ({
DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'),
DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'),
} as Record<string, string>)[warning] || warning
async function refresh() {
try {
@@ -36,10 +46,11 @@ async function refresh() {
if (!stopped) timer = setTimeout(refresh, 2000)
}
async function choose(job: MediaJob) {
if (dirty.value && !window.confirm('当前校对尚未保存,切换后放弃修改?')) return
if (dirty.value && !window.confirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?'))) return
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
}
async function action(work: () => Promise<void>) {
if (busy.value) return
busy.value = true; error.value = ''; notice.value = ''
try { await work() } catch (e) { error.value = (e as Error).message } finally { busy.value = false }
}
@@ -49,13 +60,12 @@ async function submit() {
let terms = {}
if (terminology.value.trim()) {
terms = JSON.parse(terminology.value)
if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。')
if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error(t('术语表需要 JSON 对象,值为替换后的文本。', 'The terminology map must be a JSON object whose values are replacement text.'))
}
const uploaded = await mediaService.upload(file.value!)
selected.value = await mediaService.create({attachment_id: uploaded.attachment_id, local_only: localOnly.value,
diarization: diarization.value, idempotency_key: crypto.randomUUID(), terminology: terms})
selected.value = await submission.submit(file.value!, {local_only: localOnly.value,
diarization: diarization.value, terminology: terms})
dirty.value = false
jobs.value.unshift(selected.value)
jobs.value = [selected.value, ...jobs.value.filter(job => job.job_id !== selected.value?.job_id)]
})
}
function seek(seconds: number) { if (player.value) { player.value.currentTime = seconds; position.value = seconds } }
@@ -63,10 +73,10 @@ async function purge() {
if (!selected.value) return
await action(async () => {
const impact = await mediaService.impact(selected.value!.attachment_id)
if (!window.confirm(`${impact.message}\n将保留 ${impact.retained_note_ids.length} 篇已保存笔记。确定清理?`)) return
if (!window.confirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`)) return
await mediaService.purge(selected.value!.attachment_id)
selected.value = await mediaService.get(selected.value!.job_id)
dirty.value = false; history.value = []; notice.value = '附件与转写内容已清理'
dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed')
})
}
async function compareSpeaker() {
@@ -77,10 +87,10 @@ async function compareSpeaker() {
const sample = await mediaService.upload(file.value!); temporary.push(sample.attachment_id)
const known = await mediaService.upload(reference.value!); temporary.push(known.attachment_id)
const result = await mediaService.match(sample.attachment_id, known.attachment_id, localOnly.value)
matchResult.value = `相似度 ${result.score.toFixed(3)} · ${result.source === 'local' ? '本地模型' : 'API'}${result.fallback_reason ? ` · 回退:${result.fallback_reason}` : ''}`
matchResult.value = `${t('相似度', 'Similarity')} ${result.score.toFixed(3)} · ${result.source === 'local' ? t('本地模型', 'Local model') : 'API'}${result.fallback_reason ? ` · ${t('回退:', 'Fallback: ')}${result.fallback_reason}` : ''}`
} finally {
const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id)))
if (cleanup.some(result => result.status === 'rejected')) notice.value = '部分临时参考附件清理失败,请检查后端连接。'
if (cleanup.some(result => result.status === 'rejected')) notice.value = t('部分临时参考附件清理失败,请检查后端连接。', 'Some temporary reference files could not be removed. Check the backend connection.')
}
})
}
@@ -96,56 +106,56 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template>
<section class="media-page">
<header><h1>音视频转写</h1><p class="subtle">上传音频或视频音轨转写校对后保存到知识库单个文件最多 25 MiB</p></header>
<header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库单个文件最多 25 MiB。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Maximum file size: 25 MiB.') }}</p></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit">
<label>选择附件<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @change="file = ($event.target as HTMLInputElement).files?.[0] || null" /></label>
<label><input v-model="localOnly" type="checkbox" />仅本地处理</label>
<label><input v-model="diarization" type="checkbox" />识别不同说话人</label>
<p class="subtle">{{ localOnly ? '本次任务不调用远程模型 API,模型需预先下载。' : '若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。' }}</p>
<details><summary>术语校对</summary><p class="subtle">在识别完成后替换文本原始识别结果会保留</p><textarea v-model="terminology" class="input" rows="3" placeholder='{"错误术语": "正确术语"}' /></details>
<button class="button-primary" :disabled="busy || !file">{{ busy ? '处理中…' : '上传并转写' }}</button>
<details><summary>声纹参考比对</summary><p class="subtle">将所选附件与参考音频比对至少各含 1 秒语音分数是相似度不是身份认证概率临时参考文件在比对后清理</p>
<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a" aria-label="声纹参考音频" @change="reference = ($event.target as HTMLInputElement).files?.[0] || null" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">比对声纹</button><p v-if="matchResult">{{ matchResult }}</p></details>
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
<div class="upload-options"><label><input v-model="localOnly" type="checkbox" />{{ t('仅本地处理', 'Process locally only') }}</label>
<label><input v-model="diarization" type="checkbox" />{{ t('识别不同说话人', 'Identify different speakers') }}</label></div>
<p class="subtle">{{ localOnly ? t('本次任务不调用远程模型 API,模型需预先下载。', 'This job will not call a remote model API; models must already be downloaded.') : t('若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。', 'When a transcription API is configured, the selected file is uploaded; failures fall back to the local model.') }}</p>
<details class="ui-disclosure"><summary>{{ t('术语校对', 'Terminology corrections') }}</summary><p class="subtle">{{ t('在识别完成后替换文本原始识别结果会保留。', 'Replace text after recognition while retaining the original result.') }}</p><textarea v-model="terminology" class="input" rows="3" :placeholder="terminologyPlaceholder" /></details>
<div class="inline-actions upload-actions"><button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = t('下一次提交将作为新任务处理', 'The next submission will be processed as a new job')">{{ t('重新处理为新任务', 'Process as new job') }}</button><button class="button-primary" :disabled="busy || !file">{{ busy ? t('处理中…', 'Processing…') : t('上传并转写', 'Upload and transcribe') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('声纹参考比对', 'Speaker reference comparison') }}</summary><p class="subtle">{{ t('将所选附件与参考音频比对。至少各含 1 秒语音;分数是相似度不是身份认证概率临时参考文件在比对后清理。', 'Compare the selected file with reference audio. Each must contain at least one second of speech. The score is similarity, not an identity probability. Temporary files are removed afterward.') }}</p>
<FilePicker :file="reference" :label="t('选择参考音频', 'Choose reference audio')" :empty-label="t('尚未选择参考音频', 'No reference audio selected')" accept=".wav,.mp3,.flac,.ogg,.m4a" @select="reference = $event" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">{{ t('比对声纹', 'Compare speakers') }}</button><p v-if="matchResult">{{ matchResult }}</p></details>
</form>
<div class="media-columns">
<aside class="panel"><h2>转写任务</h2><p v-if="!jobs.length" class="subtle">暂无转写任务</p>
<aside class="panel"><h2>{{ t('转写任务', 'Transcription Jobs') }}</h2><p v-if="!jobs.length" class="subtle">{{ t('暂无转写任务', 'No transcription jobs') }}</p>
<button v-for="job in jobs" :key="job.job_id" class="job-row" :class="{ selected: selected?.job_id === job.job_id }" @click="choose(job)">
<strong>{{ labels[job.status] }}</strong><span>{{ new Date(job.created_at).toLocaleString() }}</span><small>{{ job.attachment_id }}</small>
<strong>{{ labels[job.status] }}</strong><span>{{ new Date(job.created_at).toLocaleString(localeTag()) }}</span><small>{{ job.attachment_id }}</small>
</button>
</aside>
<article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">修订 {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" aria-label="转写进度" />
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" :aria-label="t('转写进度', 'Transcription progress')" />
<audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<label>播放速度<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
<label>{{ t('播放速度', 'Playback speed') }}<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
<p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p>
<p v-if="selected.fallback_reason" class="subtle">已回退{{ selected.fallback_reason }}</p>
<p v-for="warning in selected.warnings" :key="warning" class="subtle">{{ ({DIARIZATION_UNAVAILABLE: '当前无法分离说话人', WORD_TIMESTAMPS_UNAVAILABLE: '未提供逐字时间戳', DIARIZATION_SEGMENT_LEVEL: '说话人按音频段估计同段多人或重叠发言需人工校对'} as Record<string,string>)[warning] || warning }}</p>
<button v-if="active(selected)" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.cancel(selected!.job_id) })">取消任务</button>
<button v-if="['failed', 'cancelled'].includes(selected.status) && selected.error_code !== 'MEDIA_PURGED'" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.retry(selected!.job_id) })">重新处理</button>
<button v-if="!active(selected) && selected.error_code !== 'MEDIA_PURGED'" class="button-danger" :disabled="busy" @click="purge">清理原附件与转写</button>
<p v-if="selected.fallback_reason" class="subtle">{{ t('已回退', 'Fallback: ') }}{{ selected.fallback_reason }}</p>
<p v-for="warning in selected.warnings" :key="warning" class="subtle">{{ warningLabel(warning) }}</p>
<button v-if="active(selected)" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.cancel(selected!.job_id) })">{{ t('取消任务', 'Cancel job') }}</button>
<button v-if="['failed', 'cancelled'].includes(selected.status) && selected.error_code !== 'MEDIA_PURGED'" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.retry(selected!.job_id) })">{{ t('重新处理', 'Process again') }}</button>
<button v-if="!active(selected) && selected.error_code !== 'MEDIA_PURGED'" class="button-danger" :disabled="busy" @click="purge">{{ t('清理原附件与转写', 'Remove attachment and transcript') }}</button>
<template v-if="selected.status === 'completed'">
<div class="speaker-names"><label v-for="speaker in speakers" :key="speaker">{{ speaker }}<input v-model="selected.speaker_names[speaker]" class="input" placeholder="说话人显示名" @input="dirty = true" /></label></div>
<p v-if="selected.segments.length" class="subtle">时间戳对应音频分段边界可点击定位播放</p>
<div class="speaker-names"><label v-for="speaker in speakers" :key="speaker">{{ speaker }}<input v-model="selected.speaker_names[speaker]" class="input" :placeholder="t('说话人显示名', 'Speaker display name')" @input="dirty = true" /></label></div>
<p v-if="selected.segments.length" class="subtle">{{ t('时间戳对应音频分段边界可点击定位播放', 'Timestamps mark segment boundaries; click one to seek playback.') }}</p>
<div v-for="segment in selected.segments" :key="segment.segment_id" class="segment" :class="{ current: position >= segment.start_time && position < segment.end_time }">
<button class="button-secondary" @click="seek(segment.start_time)">{{ stamp(segment.start_time) }}</button><small>{{ selected.speaker_names[segment.speaker || ''] || segment.speaker }}</small>
<textarea v-model="segment.text" class="input" rows="2" @input="dirty = true; selected.text = selected.segments.map(s => s.text).join('\n')" />
</div>
<textarea v-if="!selected.segments.length" v-model="selected.text" class="input" rows="12" @input="dirty = true" />
<div class="inline-actions"><button class="button-primary" :disabled="busy || !dirty" @click="action(async () => { selected = await mediaService.save(selected!); dirty = false; notice = '校对已保存' })">保存校对</button>
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">修订历史</button></div>
<details><summary>原始识别文本</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision"><summary>修订 {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><input v-model="title" class="input" aria-label="笔记标题" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title); notice = `已保存笔记:${note.title}` })">保存为笔记</button></div>
<div class="inline-actions"><button class="button-primary" :disabled="busy || !dirty" @click="action(async () => { selected = await mediaService.save(selected!); dirty = false; notice = t('校对已保存', 'Corrections saved') })">{{ t('保存校对', 'Save corrections') }}</button>
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('更新上次导出的笔记(已手动修改则拒绝)', 'Update the previously exported note (refuse if manually edited)') }}</label><input v-model="title" class="input" :aria-label="t('笔记标题', 'Note title')" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `${t('已保存笔记:', 'Saved note: ')}${note.title}` })">{{ t('保存为笔记', 'Save as note') }}</button></div>
</template>
</article>
<div v-else class="panel subtle">选择任务查看转写结果</div>
<div v-else class="panel subtle">{{ t('选择任务查看转写结果。', 'Select a job to view its transcript.') }}</div>
</div>
</section>
</template>
<style scoped>
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { Refresh, VideoPlay } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -51,7 +52,7 @@ async function load() {
for (const command of mine) next[command.command_id] = initialArguments(command)
argumentsByCommand.value = next
} catch (reason) {
if (version === loadVersion) error.value = reason instanceof Error ? reason.message : '命令加载失败'
if (version === loadVersion) error.value = reason instanceof Error ? reason.message : t('命令加载失败', 'Failed to load commands')
} finally {
if (version === loadVersion) loading.value = false
}
@@ -125,7 +126,7 @@ async function execute(command: PluginCommand) {
notify: (text) => { notice.value = text },
})
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '命令执行失败'
error.value = reason instanceof Error ? reason.message : t('命令执行失败', 'Command failed')
} finally {
busy.value = ''
}
@@ -136,11 +137,11 @@ async function execute(command: PluginCommand) {
<div class="command-panel">
<div class="section-head">
<div>
<h3>Plugin 命令</h3>
<p>执行该 Plugin 注册的受控 Command Contribution参数表单由后端声明的 JSON Schema 生成</p>
<h3>{{ t('Plugin 命令', 'Plugin commands') }}</h3>
<p>{{ t('执行该 Plugin 注册的受控 Command Contribution;参数表单由后端声明的 JSON Schema 生成。', 'Run controlled plugin commands using the parameter form defined by the plugin.') }}</p>
</div>
<button class="button-secondary" :disabled="loading" @click="load">
<AppIcon :icon="Refresh" :size="15" />刷新
<AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}
</button>
</div>
@@ -160,14 +161,14 @@ async function execute(command: PluginCommand) {
success: commandAvailable(command),
warning: command.enabled && !commandAvailable(command),
}"
>{{ commandAvailable(command) ? '可执行' : command.enabled ? '缺少上下文' : '不可用' }}</span>
>{{ commandAvailable(command) ? t('可执行', 'Available') : command.enabled ? t('缺少上下文', 'Missing context') : t('不可用', 'Unavailable') }}</span>
</div>
<div v-if="commandFields(command).length" class="command-fields">
<label v-for="field in commandFields(command)" :key="field.key" class="field">
<span>
{{ field.title }}
<em v-if="field.required">必填</em>
<em v-if="field.required">{{ t('必填', 'Required') }}</em>
</span>
<select
v-if="field.enum"
@@ -175,7 +176,7 @@ async function execute(command: PluginCommand) {
:value="fieldValue(command.command_id, field)"
@change="updateArgument(command.command_id, field, ($event.target as HTMLSelectElement).value)"
>
<option value="">请选择</option>
<option value="">{{ t('请选择', 'Select') }}</option>
<option v-for="option in field.enum" :key="option" :value="option">{{ option }}</option>
</select>
<select
@@ -184,8 +185,8 @@ async function execute(command: PluginCommand) {
:value="fieldValue(command.command_id, field)"
@change="updateArgument(command.command_id, field, ($event.target as HTMLSelectElement).value)"
>
<option value="false"></option>
<option value="true"></option>
<option value="false">{{ t('否', 'No') }}</option>
<option value="true">{{ t('是', 'Yes') }}</option>
</select>
<input
v-else
@@ -209,12 +210,12 @@ async function execute(command: PluginCommand) {
@click="execute(command)"
>
<AppIcon :icon="VideoPlay" :size="15" />
{{ busy === command.command_id ? '执行中…' : '执行命令' }}
{{ busy === command.command_id ? t('执行中…', 'Running…') : t('执行命令', 'Run command') }}
</button>
</article>
</div>
<div v-else-if="!loading" class="empty-state">
<div><strong>没有可用命令</strong><p>启用 Plugin 已注册的命令会出现在这里</p></div>
<div><strong>{{ t('没有可用命令', 'No available commands') }}</strong><p>{{ t('启用 Plugin 后,已注册的命令会出现在这里。', 'Registered commands appear here after the Plugin is enabled.') }}</p></div>
</div>
</div>
</template>
@@ -6,6 +6,8 @@ import PluginCommandPanel from './PluginCommandPanel.vue'
import type { Plugin, PluginHostStatus, PluginSettingField, PluginSettingsSchema } from '@/contracts'
import * as pluginService from '@/services/pluginService'
import { usePluginStore } from '@/stores/plugin'
import { useWorkspaceStore } from '@/stores/workspace'
import { t, localeTag } from '@/i18n'
const props = defineProps<{ plugin: Plugin }>()
const pluginStore = usePluginStore()
@@ -24,8 +26,8 @@ let loadVersion = 0
const hasSettings = computed(() => props.plugin.contributions.some((item) => item.type === 'settings_section'))
const tabs = computed(() => [
...(props.plugin.backend_type === 'mcp' ? [{ id: 'host' as const, label: 'MCP Host' }] : []),
...(hasSettings.value ? [{ id: 'settings' as const, label: '设置与密钥' }] : []),
{ id: 'commands' as const, label: '插件命令' },
...(hasSettings.value ? [{ id: 'settings' as const, label: t('设置与密钥', 'Settings and secrets') }] : []),
{ id: 'commands' as const, label: t('插件命令', 'Plugin commands') },
])
watch(() => props.plugin.plugin_id, () => {
@@ -40,7 +42,7 @@ watch(() => props.plugin.plugin_id, () => {
function feedback(message = '') { error.value = message; notice.value = '' }
function message(reason: unknown, fallback: string) { return reason instanceof Error ? reason.message : fallback }
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString() : '—' }
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString(localeTag()) : '—' }
async function selectTab(tab: typeof activeTab.value) {
activeTab.value = tab
@@ -66,7 +68,7 @@ async function loadActive() {
}
// commands PluginCommandPanel
} catch (reason) {
if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败'))
if (version === loadVersion) feedback(message(reason, t('MCP 数据加载失败', 'Failed to load MCP data')))
} finally {
if (version === loadVersion) loading.value = false
}
@@ -85,8 +87,8 @@ async function restartHost() {
await pluginService.restartPluginHost(props.plugin.plugin_id)
host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id)
await pluginStore.loadPlugins()
notice.value = 'MCP Host 已重启。'
} catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' }
notice.value = t('MCP Host 已重启。', 'MCP Host restarted.')
} catch (reason) { feedback(message(reason, t('MCP Host 重启失败', 'Failed to restart MCP Host'))) } finally { busy.value = '' }
}
function updateValue(field: PluginSettingField, raw: string | boolean) {
values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw
@@ -98,72 +100,72 @@ async function saveSettings() {
try {
schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value)
values.value = { ...schema.value.values }
notice.value = '普通设置已保存。'
} catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' }
notice.value = t('普通设置已保存。', 'Settings saved.')
} catch (reason) { feedback(message(reason, t('设置保存失败', 'Failed to save settings'))) } finally { busy.value = '' }
}
async function saveSecret(field: PluginSettingField) {
const secret = secrets.value[field.key]?.trim()
if (!secret) { feedback('请输入' + field.label); return }
if (!secret) { feedback(t('请输入', 'Enter ') + field.label); return }
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.putPluginSecret(props.plugin.plugin_id, field.key, secret)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + '已加密保存。'
} catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' }
notice.value = field.label + t('已加密保存。', ' encrypted and saved.')
} catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' }
}
async function deleteSecret(field: PluginSettingField) {
if (!confirm('删除已保存的' + field.label + '')) return
if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + '已删除。'
} catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' }
notice.value = field.label + t('已删除。', ' deleted.')
} catch (reason) { feedback(message(reason, t('密钥删除失败', 'Failed to delete secret'))) } finally { busy.value = '' }
}
</script>
<template>
<section class="mcp-panel">
<nav class="mcp-tabs" aria-label="MCP Plugin 配置">
<nav class="mcp-tabs" :aria-label="t('MCP Plugin 配置', 'MCP and Plugin settings')">
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
</nav>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="notice" class="notice-banner">{{ notice }}</div>
<div v-if="activeTab === 'host'" class="mcp-section">
<div class="section-head"><div><h3>MCP Host 状态</h3><p>查看协议协商运行状态与 Host 错误</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? '重启中' : '重启 Host' }}</button></div></div>
<div class="section-head"><div><h3>{{ t('MCP Host 状态', 'MCP Host status') }}</h3><p>{{ t('查看协议协商、运行状态与 Host 错误。', 'Inspect protocol negotiation, runtime status, and Host errors.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? t('重启中', 'Restarting') : t('重启 Host', 'Restart Host') }}</button></div></div>
<div v-if="host" class="status-grid">
<div><span>状态</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
<div><span>服务</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
<div><span>协议版本</span><strong>{{ host.protocol_version || '—' }}</strong></div>
<div><span>工具数量</span><strong>{{ host.tools_count }}</strong></div>
<div><span>启动时间</span><strong>{{ formatTime(host.started_at) }}</strong></div>
<div><span>最后心跳</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
<div><span>{{ t('状态', 'Status') }}</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
<div><span>{{ t('服务', 'Server') }}</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
<div><span>{{ t('协议版本', 'Protocol version') }}</span><strong>{{ host.protocol_version || '—' }}</strong></div>
<div><span>{{ t('工具数量', 'Tools') }}</span><strong>{{ host.tools_count }}</strong></div>
<div><span>{{ t('启动时间', 'Started') }}</span><strong>{{ formatTime(host.started_at) }}</strong></div>
<div><span>{{ t('最后心跳', 'Last heartbeat') }}</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
</div>
<div v-else-if="loading" class="empty-state">正在读取 Host 状态</div>
<div v-else-if="loading" class="empty-state">{{ t('正在读取 Host 状态', 'Loading Host status') }}</div>
<div v-if="host?.error" class="error-banner host-error">{{ host.error }}</div>
<p class="security-hint">当前仅运行插件清单声明的 stdio MCP Server不开放任意 Shell 命令和环境变量编辑</p>
<p class="security-hint">{{ t('当前仅运行插件清单声明的 stdio MCP Server,不开放任意 Shell 命令和环境变量编辑。', 'Only stdio MCP servers declared by the plugin manifest can run. Arbitrary shell commands and environment variable editing are unavailable.') }}</p>
</div>
<div v-else-if="activeTab === 'settings'" class="mcp-section">
<div class="section-head"><div><h3>设置与密钥</h3><p>表单由后端 Schema 生成密钥不会被读取或回显</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? '保存中' : '保存普通设置' }}</button></div>
<div class="section-head"><div><h3>{{ t('设置与密钥', 'Settings and secrets') }}</h3><p>{{ t('表单由后端 Schema 生成;密钥不会被读取或回显。', 'The backend schema generates this form. Secrets are never read back or displayed.') }}</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? t('保存中', 'Saving') : t('保存普通设置', 'Save settings') }}</button></div>
<div v-if="schema" class="settings-list">
<div v-for="field in schema.fields" :key="field.key" class="setting-row">
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">必填</em></label><p>{{ field.description || (field.type === 'secret' ? '加密保存,不在页面回显。' : '') }}</p></div>
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">{{ t('必填', 'Required') }}</em></label><p>{{ field.description || (field.type === 'secret' ? t('加密保存,不在页面回显。', 'Encrypted and never displayed.') : '') }}</p></div>
<template v-if="field.type === 'secret'">
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? '已配置;输入新值可替换' : '输入密钥'" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">安全保存</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">删除</button></div>
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? '已配置' : '未配置' }}</span>
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? t('已配置;输入新值可替换', 'Configured; enter a new value to replace') : t('输入密钥', 'Enter secret')" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">{{ t('安全保存', 'Save securely') }}</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">{{ t('删除', 'Delete') }}</button></div>
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? t('已配置', 'Configured') : t('未配置', 'Not configured') }}</span>
</template>
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? '开启' : '关闭' }}</label></template>
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? t('开启', 'On') : t('关闭', 'Off') }}</label></template>
<template v-else-if="field.type === 'select'"><select :id="'plugin-setting-' + field.key" class="select" :value="values[field.key]" @change="updateValue(field, ($event.target as HTMLSelectElement).value)"><option v-for="option in field.options" :key="option" :value="option">{{ option }}</option></select></template>
<template v-else><input :id="'plugin-setting-' + field.key" class="input" :type="field.type === 'number' ? 'number' : 'text'" :min="field.minimum ?? undefined" :max="field.maximum ?? undefined" :required="field.required" :value="values[field.key] ?? ''" @input="updateValue(field, ($event.target as HTMLInputElement).value)"></template>
</div>
</div>
<div v-else-if="loading" class="empty-state">正在读取 Plugin 设置</div>
<div v-else-if="loading" class="empty-state">{{ t('正在读取 Plugin 设置', 'Loading Plugin settings') }}</div>
</div>
<div v-else class="mcp-section">
@@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import type { PluginSettingsSchema } from '@/contracts'
import * as service from '@/services/pluginService'
import PluginSettingsPanel from './PluginSettingsPanel.vue'
vi.mock('@/services/pluginService', () => ({ getPluginSettings: vi.fn(), updatePluginSettings: vi.fn(), putPluginSecret: vi.fn(), deletePluginSecret: vi.fn() }))
const schema = (value = ''): PluginSettingsSchema => ({ plugin_id: 'demo', schema_version: 1, fields: [{ key: 'name', label: 'Name', type: 'string', description: '', required: false, options: [] }], values: { name: value }, secrets: {} })
let wrapper: VueWrapper
beforeEach(() => { vi.resetAllMocks(); vi.mocked(service.getPluginSettings).mockResolvedValue(schema()) })
afterEach(() => wrapper?.unmount())
it('retains edits made during a save and submits them on the next save', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
vi.mocked(service.updatePluginSettings).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('first edit')
await wrapper.get('.form-actions button').trigger('click')
expect(service.updatePluginSettings).toHaveBeenLastCalledWith('demo', 1, { name: 'first edit' })
await wrapper.get('input').setValue('second edit')
resolveSave(schema('first edit'))
await flushPromises()
expect(wrapper.find('.unsaved-hint').exists()).toBe(true)
expect(wrapper.get('.form-actions button').attributes('disabled')).toBeUndefined()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('second edit')
vi.mocked(service.updatePluginSettings).mockResolvedValueOnce(schema('second edit'))
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(service.updatePluginSettings).toHaveBeenLastCalledWith('demo', 1, { name: 'second edit' })
expect(wrapper.find('.unsaved-hint').exists()).toBe(false)
})
it('keeps input and permits retry after a failed save', async () => {
vi.mocked(service.updatePluginSettings).mockRejectedValueOnce(new Error('Save failed'))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('retry me')
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(wrapper.find('.unsaved-hint').exists()).toBe(true)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('retry me')
vi.mocked(service.updatePluginSettings).mockResolvedValueOnce(schema('retry me'))
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(service.updatePluginSettings).toHaveBeenCalledTimes(2)
expect(wrapper.find('.unsaved-hint').exists()).toBe(false)
})
it('ignores a save response after switching to another plugin', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
vi.mocked(service.updatePluginSettings).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('old plugin')
await wrapper.get('.form-actions button').trigger('click')
vi.mocked(service.getPluginSettings).mockResolvedValueOnce(schema('new plugin'))
await wrapper.setProps({ pluginId: 'other' })
await flushPromises()
resolveSave(schema('old plugin'))
await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('new plugin')
expect(wrapper.emitted('saved')).toBeUndefined()
})
@@ -24,6 +24,8 @@ const isLoading = ref(false)
const isSaving = ref(false)
const saveError = ref('')
const hasChanges = ref(false)
let editVersion = 0
let loadVersion = 0
const nonSecretFields = computed(() =>
schema.value?.fields.filter((f) => f.type !== 'secret') ?? []
@@ -34,36 +36,49 @@ const secretFields = computed(() =>
)
async function load() {
const version = ++loadVersion
const pluginId = props.pluginId
isLoading.value = true
isSaving.value = false
saveError.value = ''
schema.value = null
Object.keys(secrets).forEach(key => delete secrets[key])
try {
schema.value = await getPluginSettings(props.pluginId)
const loaded = await getPluginSettings(pluginId)
if (version !== loadVersion) return
schema.value = loaded
Object.keys(values).forEach((k) => delete values[k])
Object.assign(values, schema.value.values)
hasChanges.value = false
editVersion = 0
} catch (error) {
emit('error', error instanceof Error ? error.message : '设置加载失败')
if (version === loadVersion) emit('error', error instanceof Error ? error.message : '设置加载失败')
} finally {
isLoading.value = false
if (version === loadVersion) isLoading.value = false
}
}
async function save() {
if (!schema.value) return
if (!schema.value || isSaving.value) return
const version = loadVersion
const submittedEditVersion = editVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
schema.value = await updatePluginSettings(
props.pluginId,
const saved = await updatePluginSettings(
pluginId,
schema.value.schema_version,
{ ...values }
)
hasChanges.value = false
if (version !== loadVersion) return
schema.value = saved
hasChanges.value = editVersion !== submittedEditVersion
emit('saved')
} catch (error) {
saveError.value = error instanceof Error ? error.message : '保存失败'
if (version === loadVersion) saveError.value = error instanceof Error ? error.message : '保存失败'
} finally {
isSaving.value = false
if (version === loadVersion) isSaving.value = false
}
}
@@ -108,6 +123,7 @@ function setFieldValue(key: string, value: unknown, field: PluginSettingField) {
values[key] = value
}
hasChanges.value = true
editVersion++
}
onMounted(load)
+21 -20
View File
@@ -8,6 +8,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { usePluginStore } from '@/stores/plugin'
import * as pluginService from '@/services/pluginService'
import type { PluginCommand } from '@/contracts'
import { t } from '@/i18n'
const pluginStore = usePluginStore()
const actionError = ref('')
@@ -29,28 +30,28 @@ watch(() => pluginStore.selectedPluginId, async (pluginId) => {
})
async function install() {
const path = prompt('请输入 Plugin Package 路径')?.trim()
const path = prompt(t('请输入 Plugin Package 路径', 'Enter the Plugin Package path'))?.trim()
if (!path) return
try { await pluginStore.installPlugin(path) }
catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' }
catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
}
async function toggle(id: string, enabled: boolean) {
try {
enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id)
} catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
} catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
}
async function grant(id: string, permissions: string[]) {
if (!confirm(`将授权:${permissions.join('')}是否继续?`)) return
if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return
try { await pluginStore.grantPermissions(id, permissions) }
catch (error) { actionError.value = error instanceof Error ? error.message : '授权失败' }
catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') }
}
async function uninstall(id: string, name: string) {
if (!confirm(`卸载"${name}"将移除其全部 Contribution,是否继续?`)) return
if (!confirm(t(`卸载${name}将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return
try { await pluginStore.uninstallPlugin(id) }
catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' }
catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
}
const hasSettingsContribution = computed(() =>
@@ -65,8 +66,8 @@ const hasCommandContribution = computed(() =>
<template>
<section class="feature-page">
<header class="feature-header">
<div><h1>Plugin MCP</h1><p>管理插件生命周期MCP Host权限和受控 Contribution</p></div>
<button class="button-primary" @click="install">安装 Plugin</button>
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
<button class="button-primary" @click="install">{{ t('安装 Plugin', 'Install Plugin') }}</button>
</header>
<div v-if="pluginStore.error || actionError" class="error-banner">
@@ -95,15 +96,15 @@ const hasCommandContribution = computed(() =>
v-if="pluginStore.selectedPlugin.status === 'permission_required'"
class="button-primary"
@click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)"
>授权权限</button>
>{{ t('授权权限', 'Grant permissions') }}</button>
<button
class="button-secondary"
@click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)"
>{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button>
>{{ pluginStore.selectedPlugin.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button>
<button
class="button-danger"
@click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)"
>卸载</button>
>{{ t('卸载', 'Uninstall') }}</button>
</div>
</div>
@@ -114,25 +115,25 @@ const hasCommandContribution = computed(() =>
class="tab-btn"
:class="{ active: activeTab === 'info' }"
@click="activeTab = 'info'"
>概览</button>
>{{ t('概览', 'Overview') }}</button>
<button
v-if="hasCommandContribution"
class="tab-btn"
:class="{ active: activeTab === 'commands' }"
@click="activeTab = 'commands'"
>命令 ({{ pluginCommands.length }})</button>
>{{ t('命令', 'Commands') }} ({{ pluginCommands.length }})</button>
<button
v-if="hasSettingsContribution || pluginCommands.some(c => c.enabled)"
class="tab-btn"
:class="{ active: activeTab === 'settings' }"
@click="activeTab = 'settings'"
>设置</button>
>{{ t('设置', 'Settings') }}</button>
</div>
<div v-if="activeTab === 'info'" class="tab-content">
<div class="detail-grid">
<div>
<h3>权限</h3>
<h3>{{ t('权限', 'Permissions') }}</h3>
<div class="tag-list">
<span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">
{{ permission }}
@@ -158,7 +159,7 @@ const hasCommandContribution = computed(() =>
{{ pluginStore.selectedPlugin.last_error }}
</div>
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner">
依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}
{{ t('依赖此插件的 Skill', 'Skills that depend on this plugin: ') }}{{ pluginStore.selectedPlugin.dependent_skills.join(', ') }}
</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
</div>
@@ -175,8 +176,8 @@ const hasCommandContribution = computed(() =>
<div v-else-if="!pluginStore.plugins.length" class="empty-state">
<div>
<strong>{{ pluginStore.isLoading ? '正在加载…' : pluginStore.error ? '加载失败' : '尚未安装' }}</strong>
<button class="button-secondary" @click="pluginStore.loadPlugins">重新加载</button>
<strong>{{ pluginStore.isLoading ? t('正在加载…', 'Loading…') : pluginStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'No plugins installed') }}</strong>
<button class="button-secondary" @click="pluginStore.loadPlugins">{{ t('重新加载', 'Reload') }}</button>
</div>
</div>
@@ -204,7 +205,7 @@ const hasCommandContribution = computed(() =>
</div>
<p class="muted">{{ plugin.description }}</p>
<p class="subtle">
{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} Contribution
{{ plugin.permissions.length }} {{ t('项权限', 'permissions') }} · {{ plugin.contributions.length }} {{ t('项 Contribution', 'contributions') }}
</p>
</article>
</div>
@@ -1,24 +1,26 @@
<script setup lang="ts">
import { useSearchStore } from '@/stores/search'
import { computed } from 'vue'
import { t } from '@/i18n'
const searchStore = useSearchStore()
const modes = [
{ value: 'hybrid', label: '混合检索' },
{ value: 'fts', label: '全文检索' },
{ value: 'vector', label: '向量检索' },
] as const
const modes = computed(() => [
{ value: 'hybrid' as const, label: t('混合检索', 'Hybrid search') },
{ value: 'fts' as const, label: t('全文检索', 'Full-text search') },
{ value: 'vector' as const, label: t('向量检索', 'Vector search') },
])
</script>
<template>
<div class="sidebar-panel">
<p class="subtle">检索模式</p>
<p class="subtle">{{ t('检索模式', 'Search mode') }}</p>
<div class="sidebar-list mode-list">
<button v-for="item in modes" :key="item.value" class="sidebar-list-item"
:class="{ active: searchStore.mode === item.value }" @click="searchStore.setMode(item.value)">
{{ item.label }}
</button>
</div>
<p class="subtle section-title">最近搜索</p>
<p class="subtle section-title">{{ t('最近搜索', 'Recent searches') }}</p>
<div class="sidebar-list">
<button v-for="query in searchStore.recentQueries" :key="query" class="sidebar-list-item recent"
@click="searchStore.doSearch({ query, mode: searchStore.mode })">{{ query }}</button>
+12 -11
View File
@@ -5,6 +5,7 @@ import type { SearchResult } from '@/contracts'
import { useEditorStore } from '@/stores/editor'
import { useSearchStore } from '@/stores/search'
import { useWorkspaceStore } from '@/stores/workspace'
import { t } from '@/i18n'
const searchStore = useSearchStore()
onMounted(() => { void searchStore.loadHistory() })
@@ -34,28 +35,28 @@ async function openResult(result: SearchResult) {
<template>
<section class="feature-page search-page">
<header class="feature-header">
<div><h1>搜索知识库</h1><p>在当前 Vault 中进行全文向量或混合检索</p></div>
<div><h1>{{ t('搜索知识库', 'Search Knowledge Base') }}</h1><p>{{ t('在当前 Vault 中进行全文、向量或混合检索。', 'Run full-text, vector, or hybrid search in the current Vault.') }}</p></div>
</header>
<form class="search-form panel" @submit.prevent="submitSearch">
<input v-model="searchStore.query" class="input search-input" placeholder="搜索笔记内容、标题或标签" autofocus />
<input v-model="searchStore.query" class="input search-input" :placeholder="t('搜索笔记内容、标题或标签', 'Search note content, titles, or tags')" autofocus />
<button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching">
{{ searchStore.isSearching ? '搜索中…' : '搜索' }}
{{ searchStore.isSearching ? t('搜索中…', 'Searching…') : t('搜索', 'Search') }}
</button>
<div class="form-grid advanced">
<div class="field"><label>文件夹范围</label><input v-model="folder" class="input" placeholder="例如 /数据结构" /></div>
<div class="field"><label>标签</label><input v-model="tag" class="input" placeholder="例如 算法" /></div>
<div class="field"><label>{{ t('文件夹范围', 'Folder scope') }}</label><input v-model="folder" class="input" :placeholder="t('例如 /数据结构', 'For example /Data Structures')" /></div>
<div class="field"><label>{{ t('标签', 'Tag') }}</label><input v-model="tag" class="input" :placeholder="t('例如 算法', 'For example algorithms')" /></div>
</div>
</form>
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
<div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div>
<div v-if="searchStore.recentQueries.length" class="search-history">
<span class="subtle">最近搜索保存在应用数据中</span>
<span class="subtle">{{ t('最近搜索(保存在应用数据中)', 'Recent searches (stored in application data)') }}</span>
<button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button>
<button class="button-secondary" @click="searchStore.clearHistory">清空记录</button>
<button class="button-secondary" @click="searchStore.clearHistory">{{ t('清空记录', 'Clear history') }}</button>
</div>
<div v-if="searchStore.vectorUnavailable" class="notice-banner">向量索引不可用已保留全文检索能力</div>
<div v-if="searchStore.vectorUnavailable" class="notice-banner">{{ t('向量索引不可用已保留全文检索能力', 'Vector search is unavailable; full-text search remains active.') }}</div>
<div v-if="searchStore.results.length" class="results-header">
<span>找到 {{ searchStore.total }} 条结果</span><span class="badge info">{{ searchStore.mode }}</span>
<span>{{ t('找到', 'Found') }} {{ searchStore.total }} {{ t('条结果', 'results') }}</span><span class="badge info">{{ searchStore.mode }}</span>
</div>
<div v-if="searchStore.results.length" class="result-list">
<article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`"
@@ -63,11 +64,11 @@ async function openResult(result: SearchResult) {
<div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div>
<p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p>
<p class="snippet">{{ result.snippet }}</p>
<div class="result-meta"><span>相关度 {{ Math.round(result.score * 100) }}%</span><span>点击定位原文 </span></div>
<div class="result-meta"><span>{{ t('相关度', 'Relevance') }} {{ Math.round(result.score * 100) }}%</span><span>{{ t('点击定位原文 →', 'Open source →') }}</span></div>
</article>
</div>
<div v-else-if="!searchStore.isSearching" class="empty-state">
<div><strong>{{ searchStore.query ? '没有找到匹配内容' : '从你的知识库开始搜索' }}</strong><p>可切换检索模式或缩小文件夹标签范围</p></div>
<div><strong>{{ searchStore.query ? t('没有找到匹配内容', 'No matching content') : t('从你的知识库开始搜索', 'Start searching your knowledge base') }}</strong><p>{{ t('可切换检索模式或缩小文件夹、标签范围。', 'Try another search mode or narrow the folder and tag scope.') }}</p></div>
</div>
</section>
</template>
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { mount, flushPromises } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { apiClient } from '@/services/apiClient'
import LocalModelSettings from './LocalModelSettings.vue'
vi.mock('@/services/apiClient', () => ({apiClient:{get:vi.fn(),post:vi.fn()}}))
it('shows an optional CUDA installer and live installation stage', async () => {
vi.mocked(apiClient.get).mockImplementation(async (url) => url.includes('runtime-components')
? {status:'not_installed', stage:'尚未安装', supported:true, cuda_available:null,custom_interpreter:false}
: {items:[],config:null,runtime_installed:true,last_inference:null})
vi.mocked(apiClient.post).mockResolvedValue({status:'installing',stage:'下载并安装 PyTorch CUDA(约 3 GB',supported:true})
const wrapper = mount(LocalModelSettings)
try {
await flushPromises()
const button = wrapper.findAll('button').find(b => b.text() === '下载并安装 CUDA 组件')!
expect(button.exists()).toBe(true)
expect(apiClient.post).not.toHaveBeenCalled()
await button.trigger('click')
await flushPromises()
expect(apiClient.post).toHaveBeenCalledWith('/api/local-models/runtime-components/cuda')
expect(wrapper.text()).toContain('下载并安装 PyTorch CUDA')
expect(wrapper.get('progress').attributes('value')).toBeUndefined()
expect(button.attributes('disabled')).toBeDefined()
} finally {wrapper.unmount()}
})
@@ -1,20 +1,32 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
interface Config {device: 'cpu'|'cuda'; cpu_threads: number; memory_limit_mb: number; gpu_memory_limit_mb: number; timeout_seconds: number; embedding_model: string; version: number}
interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; downloaded_bytes: number; total_bytes: number|null; error_code?: string}
interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; disk_bytes: number|null; downloaded_bytes: number; total_bytes: number|null; error_code?: string}
const items = ref<Model[]>([])
const config = ref<Config | null>(null)
const installed = ref(false)
const lastInference = ref<{actual_device:string;requested_device:string;inference_seconds:number}|null>(null)
interface CudaComponent {status:string;stage:string;cuda_available:boolean|null;supported:boolean;custom_interpreter:boolean;error?:string;torch?:string}
const cuda = ref<CudaComponent|null>(null)
const cudaError = ref('')
async function loadCuda() {
try { cuda.value = await apiClient.get<CudaComponent>('/api/local-models/runtime-components/cuda'); cudaError.value = '' }
catch(e) { cudaError.value = (e as Error).message }
}
async function installCuda() {
await act(async () => { cuda.value = await apiClient.post<CudaComponent>('/api/local-models/runtime-components/cuda') })
}
const lastInference = ref<{actual_device:string;requested_device:string;inference_seconds?:number;elapsed_seconds?:number;status?:string;error_code?:string}|null>(null)
const error = ref('')
const dirty = ref(false)
const busy = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
const size = (bytes: number | null) => bytes === null ? '未知' : `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const labels: Record<string,string> = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'}
const size = (bytes: number | null) => bytes === null ? t('未知', 'Unknown') : `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const labels = computed<Record<string,string>>(() => ({not_installed:t('未下载','Not downloaded'),downloading:t('下载中','Downloading'),installed:t('已下载并校验','Downloaded and verified'),failed:t('下载失败','Download failed'),interrupted:t('已中断,可续传','Interrupted; resumable')}))
async function load() {
await loadCuda()
try {
const data = await apiClient.get<{items:Model[];config:Config;runtime_installed:boolean;last_inference:typeof lastInference.value}>('/api/local-models')
items.value = data.items; installed.value = data.runtime_installed
@@ -41,25 +53,39 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script>
<template>
<section class="local-models">
<h3>本地模型</h3><p class="subtle">默认 CPU下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证</p>
<h3>{{ t('本地模型', 'Local Models') }}</h3><p class="subtle">{{ t('默认 CPU。下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证。', 'CPU is the default. Downloads require network access; inference reads local weights only. File verification does not mean the current device passed inference validation.') }}</p>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<p v-if="lastInference" class="subtle">最近实际运行{{ lastInference.actual_device }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ lastInference.inference_seconds.toFixed(2) }} </p>
<p v-if="!installed" class="subtle">尚未安装模型运行环境在项目根目录执行 <code>./backend/scripts/install-model-runtime.ps1</code>CUDA 选装追加 <code>-Device cuda</code></p>
<p v-if="lastInference" class="subtle">{{ t('最近实际运行', 'Last actual run: ') }}{{ lastInference.actual_device || t('未开始推理', 'No inference yet') }} · {{ t('请求设备', 'requested device') }} {{ lastInference.requested_device }} · {{ t('推理', 'inference') }} {{ (lastInference.inference_seconds ?? lastInference.elapsed_seconds ?? 0).toFixed(2) }} {{ t('', 'sec') }} · {{ lastInference.status }} {{ lastInference.error_code || '' }}</p>
<p v-if="!installed" class="subtle">{{ t('尚未安装模型运行环境在项目根目录执行', 'The model runtime is not installed. Run this from the project root:') }} <code>./backend/scripts/install-model-runtime.ps1</code>; {{ t('CUDA 选装追加', 'for optional CUDA, append') }} <code>-Device cuda</code>.</p>
<article class="item-card cuda-components" :aria-label="t('CUDA 运行组件', 'CUDA runtime components')">
<h4>{{ t('CUDA 运行组件(可选)', 'CUDA Runtime Components (Optional)') }}</h4>
<p class="subtle">{{ t('默认使用 CPU。需要 NVIDIA GPU 加速时下载此组件,约 3 GB,安装时还需要额外磁盘空间;不包含显卡驱动和模型权重。', 'CPU is used by default. Download this component for NVIDIA GPU acceleration. It is about 3 GB and needs extra installation space; drivers and model weights are not included.') }}</p>
<p v-if="cudaError" class="error-text" role="alert">{{ cudaError }} <button class="button-secondary" @click="loadCuda">{{ t('重新检查', 'Check again') }}</button></p>
<template v-if="cuda">
<p role="status">{{ cuda.stage }} {{ cuda.torch || '' }}</p>
<progress v-if="['checking','installing'].includes(cuda.status)" :aria-label="t('CUDA 组件安装进度', 'CUDA component installation progress')" />
<p v-if="cuda.error" class="error-text">{{ cuda.error }}</p>
<p v-if="!cuda.supported" class="subtle">{{ t('当前平台暂不支持页面安装请使用对应平台的模型运行环境', 'This platform does not support in-app installation. Use the model runtime for your platform.') }}</p>
<button v-else-if="cuda.status !== 'installed'" class="button-primary" :disabled="busy || ['checking','installing'].includes(cuda.status)" @click="installCuda">{{ cuda.status === 'installing' ? t('正在下载并安装', 'Downloading and installing') : ['failed','interrupted'].includes(cuda.status) ? t('重试安装 CUDA 组件', 'Retry CUDA installation') : t('下载并安装 CUDA 组件', 'Download and install CUDA components') }}</button>
<p v-if="cuda.status === 'installed'" class="subtle">{{ cuda.cuda_available ? t('组件已就绪在下方选择 CUDA 并保存即可启用', 'Components are ready. Select CUDA below and save to enable it.') : t('组件已安装但当前未检测到可用 CUDA 设备将回退 CPU', 'Components are installed, but no CUDA device is available; CPU fallback will be used.') }}</p>
<p v-if="cuda.custom_interpreter" class="subtle">{{ t('当前后端设置了 APP_MODEL_PYTHON优先使用指定环境要使用页面安装的组件请移除该覆盖并重启后端', 'APP_MODEL_PYTHON is set and takes priority. Remove the override and restart the backend to use components installed from this page.') }}</p>
</template>
</article>
<form v-if="config" @submit.prevent="save" @input="dirty = true" @change="dirty = true">
<div class="runtime-grid"><label>请求设备<select v-model="config.device" class="select"><option value="cpu">CPU(默认)</option><option value="cuda">CUDA不可用则 CPU</option></select></label>
<label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M 多语言</option></select></label>
<label>CPU 线程<input v-model.number="config.cpu_threads" class="input" type="number" min="1" max="32" /></label>
<label>内存预算 MiB<input v-model.number="config.memory_limit_mb" class="input" type="number" min="1024" max="131072" /></label>
<label>显存预算 MiB<input v-model.number="config.gpu_memory_limit_mb" class="input" type="number" min="512" max="65536" /></label></div>
<p class="subtle">修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放</p><button class="button-primary" :disabled="busy || !dirty">保存运行设置</button>
<div class="runtime-grid"><label>{{ t('请求设备', 'Requested device') }}<select v-model="config.device" class="select"><option value="cpu">{{ t('CPU(默认)', 'CPU (default)') }}</option><option value="cuda">{{ t('CUDA不可用则 CPU', 'CUDA (CPU fallback)') }}</option></select></label>
<label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M {{ t('多语言', 'Multilingual') }}</option></select></label>
<label>{{ t('CPU 线程', 'CPU threads') }}<input v-model.number="config.cpu_threads" class="input" type="number" min="1" max="32" /></label>
<label>{{ t('内存预算 MiB', 'Memory budget MiB') }}<input v-model.number="config.memory_limit_mb" class="input" type="number" min="1024" max="131072" /></label>
<label>{{ t('显存预算 MiB', 'GPU memory budget MiB') }}<input v-model.number="config.gpu_memory_limit_mb" class="input" type="number" min="512" max="65536" /></label></div>
<p class="subtle">{{ t('修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放。', 'Changing the embedding model requires rebuilding the index. Jobs run serially within the resource budget, and models are released when each job finishes.') }}</p><button class="button-primary" :disabled="busy || !dirty">{{ t('保存运行设置', 'Save runtime settings') }}</button>
</form>
<div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">版本 {{ model.revision.slice(0,12) }}</small>
<p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" />
<div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">{{ t('版本', 'Revision') }} {{ model.revision.slice(0,12) }}</small>
<p>{{ t('实际磁盘占用', 'Disk usage') }} {{ size(model.disk_bytes) }}</p><p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" />
<p v-if="model.error_code" class="error-text">{{ model.error_code }}</p><div class="inline-actions">
<button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? '下载模型' : '重试 / 续传' }}</button>
<button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">暂停</button>
<button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">删除权重</button></div>
</article></div><button class="button-secondary" @click="diagnostics">导出本次运行诊断</button><p class="subtle">诊断仅包含模型设备耗时和资源信息不包含正文音频和密钥</p>
<button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? t('下载模型', 'Download model') : t('重试 / 续传', 'Retry / Resume') }}</button>
<button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">{{ t('暂停', 'Pause') }}</button>
<button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">{{ t('删除权重', 'Delete weights') }}</button></div>
</article></div><button class="button-secondary" @click="diagnostics">{{ t('导出最近运行诊断', 'Export recent runtime diagnostics') }}</button><p class="subtle">{{ t('诊断仅包含模型、设备、耗时和资源信息,不包含正文、音频和密钥。', 'Diagnostics include only model, device, timing, and resource data. Note content, audio, and secrets are excluded.') }}</p>
</section>
</template>
<style scoped>.local-models{display:grid;gap:16px}.runtime-grid,.model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}label{display:grid;gap:6px}.item-card{padding:16px}progress{width:100%}</style>
@@ -4,14 +4,15 @@ import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderCo
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
import { t } from '@/i18n'
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '本地支持 Bekko / Granite,安装权重后可离线运行。' },
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '本地采用 Qwen3-ASR 0.6B,默认 CPU。' },
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '本地采用 ERes2NetV2,比对结果是相似度。' },
]
const capabilities = computed<Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }>>(() => [
{ id: 'embedding', name: t('向量嵌入 · Embedding', 'Embedding'), endpoint: '/embeddings', placeholder: t('例如 text-embedding-3-small', 'For example, text-embedding-3-small'), local: t('本地支持 Bekko / Granite,安装权重后可离线运行。', 'Local Bekko / Granite can run offline after weights are installed.') },
{ id: 'transcription', name: t('语音转写 · Transcription', 'Transcription'), endpoint: '/audio/transcriptions', placeholder: t('输入转写模型 ID', 'Enter a transcription model ID'), local: t('本地采用 Qwen3-ASR 0.6B,默认 CPU。', 'Local Qwen3-ASR 0.6B uses CPU by default.') },
{ id: 'speaker_matching', name: t('说话人匹配 · Speaker matching', 'Speaker matching'), endpoint: '/audio/speaker-matches', placeholder: t('输入说话人匹配模型 ID', 'Enter a speaker matching model ID'), local: t('本地采用 ERes2NetV2,比对结果是相似度。', 'Local ERes2NetV2 returns a similarity score.') },
])
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
const drafts = reactive(Object.fromEntries(capabilities.value.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
const providers = ref<ProviderConfig[]>([])
const response = ref<ModelRoutingResponse | null>(null)
const loading = ref(false)
@@ -26,7 +27,7 @@ const unavailable = computed(() => providers.value.filter(provider => !eligible(
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
const localLabel = (capability: RoutingCapability) => {
const status = localBackend(capability)?.status
return status === 'ready' ? '已安装' : status === 'placeholder' ? '测试占位实现' : '未安装'
return status === 'ready' ? t('已安装', 'Installed') : status === 'placeholder' ? t('测试占位实现', 'Test placeholder') : t('未安装', 'Not installed')
}
const protocols = [
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
@@ -35,7 +36,7 @@ const protocols = [
function applyResponse(result: ModelRoutingResponse) {
response.value = result
for (const item of capabilities) {
for (const item of capabilities.value) {
const binding = result.config[item.id]
Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' })
}
@@ -53,7 +54,7 @@ async function load() {
applyResponse(routing)
conflict.value = false
} catch (reason) {
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}`
if (active) error.value = `${t('加载失败:', 'Load failed: ')}${reason instanceof Error ? reason.message : t('无法读取模型路由或提供商', 'Could not read model routes or providers')}`
} finally { loading.value = false }
}
@@ -64,20 +65,20 @@ function changeProvider(capability: RoutingCapability) {
const draft = drafts[capability]
draft.model = ''
draft.dimensions = ''
draft.endpoint = capabilities.find(item => item.id === capability)!.endpoint
draft.endpoint = capabilities.value.find(item => item.id === capability)!.endpoint
saved.value = false
}
function bindingFor(capability: RoutingCapability): ModelBinding | null {
const draft = drafts[capability]
if (!draft.provider_id) return null
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error('请选择已启用且协议可用的提供商,或切换到本地。')
if (!draft.model.trim()) throw new Error('请填写所选 API 的模型 ID。')
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。')
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error(t('请选择已启用且协议可用的提供商,或切换到本地。', 'Select an enabled provider with a supported protocol, or switch to local.'))
if (!draft.model.trim()) throw new Error(t('请填写所选 API 的模型 ID。', 'Enter the model ID for the selected API.'))
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error(t('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。', 'Endpoint must be a relative path beginning with / and containing only letters, numbers, underscores, hyphens, and /.'))
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
if (capability === 'embedding') {
const dimension = String(draft.dimensions).trim()
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。')
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error(t('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。', 'Embedding dimensions must be an integer from 1 to 16384, or blank to use the API default.'))
binding.dimensions = dimension ? Number(dimension) : null
}
return binding
@@ -99,47 +100,47 @@ async function save() {
if (!active) return
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
error.value = conflict.value
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。'
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}`
? t('配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。', 'Configuration conflict: another window changed these routes. Your input is unsaved; reload the latest settings before editing.')
: `${t('保存失败:', 'Save failed: ')}${reason instanceof Error ? reason.message : t('请重试', 'please retry')}`
} finally { saving.value = false }
}
</script>
<template>
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入语音转写和说话人匹配分别选择提供商与模型独立于默认聊天模型API Key 模型提供商中管理</p></div>
<p class="subtle">未选择提供商即使用本地模型API 请求失败配置不可用或响应无效时回退到本地使用前请下载对应权重并安装运行环境</p>
<p v-if="loading" role="status">正在加载模型路由</p>
<div><h2 id="routing-title">{{ t('能力模型路由', 'Capability model routing') }}</h2><p class="subtle">{{ t('向量嵌入、语音转写和说话人匹配分别选择提供商与模型独立于默认聊天模型。API Key 在「模型提供商」中管理。', 'Choose providers and models separately for embeddings, transcription, and speaker matching. API keys are managed under Model Providers.') }}</p></div>
<p class="subtle">{{ t('未选择提供商即使用本地模型。API 请求失败、配置不可用或响应无效时回退到本地使用前请下载对应权重并安装运行环境。', 'With no provider selected, the local model is used. Failed API requests, invalid settings, or invalid responses fall back to local. Download the required weights and runtime first.') }}</p>
<p v-if="loading" role="status">{{ t('正在加载模型路由', 'Loading model routes') }}</p>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载放弃未保存更改' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div>
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? t('放弃当前输入并加载最新配置', 'Discard input and load latest settings') : response ? t('重新加载放弃未保存更改', 'Reload (discard unsaved changes)') : t('重试加载', 'Retry loading') }}</button><span v-if="response" class="subtle">{{ t('配置版本', 'Configuration version') }} {{ response.config.version }}</span></div>
<form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false">
<fieldset :disabled="loading || saving || conflict">
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
<h3>{{ capability.name }}</h3>
<p v-if="capability.id === 'embedding'" class="embedding-notice">保存配置或更换模型接口后请重建全部索引配置成功不代表已有笔记的向量索引已更新重建完成前可使用全文检索混合检索会回退到全文检索</p>
<div class="protocols" aria-label="协议可用性">
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span>
<p v-if="capability.id === 'embedding'" class="embedding-notice">{{ t('保存配置或更换模型接口后请重建全部索引配置成功不代表已有笔记的向量索引已更新重建完成前可使用全文检索混合检索会回退到全文检索', 'Rebuild all indexes after saving or changing the model or endpoint. Saving settings does not update existing note vectors. Full-text search remains available, and hybrid search falls back to it until rebuilding completes.') }}</p>
<div class="protocols" :aria-label="t('协议可用性', 'Protocol availability')">
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? t(' · 可用', ' · Available') : t(' · 不可用', ' · Unavailable') }}</span>
</div>
<label class="field"><span>处理方式 / 提供商</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
<option value="">本地 · {{ localLabel(capability.id) }}</option>
<label class="field"><span>{{ t('处理方式 / 提供商', 'Processing / Provider') }}</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
<option value="">{{ t('本地', 'Local') }} · {{ localLabel(capability.id) }}</option>
<option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option>
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? '协议不可用' : '未启用' }}</option>
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>原提供商已不可用 · {{ drafts[capability.id].provider_id }}</option>
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? t('协议不可用', 'Protocol unavailable') : t('未启用', 'Disabled') }}</option>
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>{{ t('原提供商已不可用', 'Previous provider is unavailable') }} · {{ drafts[capability.id].provider_id }}</option>
</select></label>
<div v-if="drafts[capability.id].provider_id" class="routing-fields">
<label class="field"><span>模型 ID</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
<label class="field"><span>Endpoint相对 Base URL</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
<label v-if="capability.id === 'embedding'" class="field"><span>向量维度(可选)</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" placeholder="留空使用 API 默认维度" /><small class="subtle">填写模型支持的 116384 整数维度或留空使用 API 默认值</small></label>
<label class="field"><span>{{ t('模型 ID', 'Model ID') }}</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
<label class="field"><span>{{ t('Endpoint(相对 Base URL', 'Endpoint (relative to Base URL)') }}</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
<label v-if="capability.id === 'embedding'" class="field"><span>{{ t('向量维度(可选)', 'Vector dimensions (optional)') }}</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" :placeholder="t('留空使用 API 默认维度', 'Blank uses the API default')" /><small class="subtle">{{ t('填写模型支持的 116384 整数维度或留空使用 API 默认值', 'Enter an integer from 1 to 16384 supported by the model, or leave blank for the API default.') }}</small></label>
</div>
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约该端点不是 OpenAI 标准接口服务需实现对应的说话人匹配请求和响应</p>
<p v-if="capability.id === 'speaker_matching'" class="subtle">{{ t('说话人匹配使用本应用自定义 HTTP multipart 契约该端点不是 OpenAI 标准接口服务需实现对应的说话人匹配请求和响应', 'Speaker matching uses this apps custom HTTP multipart contract. It is not an OpenAI-standard endpoint; the service must implement the corresponding request and response.') }}</p>
<div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }">
<strong>{{ drafts[capability.id].provider_id ? '本地回退状态' : '当前本地状态' }}</strong>
<p>{{ localBackend(capability.id)?.status === 'ready' ? '本地后端已就绪。' : capability.local }}</p>
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? '已就绪' : backend.status === 'placeholder' ? '占位实现' : '未安装 / 未接入' }}</span> {{ backend.message }}</p>
<strong>{{ drafts[capability.id].provider_id ? t('本地回退状态', 'Local fallback status') : t('当前本地状态', 'Current local status') }}</strong>
<p>{{ localBackend(capability.id)?.status === 'ready' ? t('本地后端已就绪。', 'The local backend is ready.') : capability.local }}</p>
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? t('已就绪', 'Ready') : backend.status === 'placeholder' ? t('占位实现', 'Placeholder') : t('未安装 / 未接入', 'Not installed / connected') }}</span> {{ backend.message }}</p>
</div>
</article>
</fieldset>
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? '保存中…' : '保存模型路由' }}</button><span v-if="saved" role="status">模型路由已保存</span></div>
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? t('保存中…', 'Saving…') : t('保存模型路由', 'Save model routes') }}</button><span v-if="saved" role="status">{{ t('模型路由已保存', 'Model routes saved.') }}</span></div>
</form>
</section>
</template>
@@ -5,8 +5,11 @@ import type { ProviderConfig, ProviderPreset } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderForm from './ProviderForm.vue'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
vi.mock('@/services/apiClient', () => ({ apiClient: { post: vi.fn() } }))
const presets: ProviderPreset[] = [
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
@@ -30,6 +33,21 @@ beforeEach(() => {
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ProviderForm', () => {
it('invalidates a pending inference result when JSON becomes invalid', async () => {
const wrapper = await render(existing)
let finish!: (value: {message: string}) => void
vi.mocked(apiClient.post).mockReturnValue(new Promise(resolve => { finish = resolve }))
const probe = wrapper.findAll('button').find(button => button.text() === '发送测试推理请求')!
await probe.trigger('click')
expect(apiClient.post).toHaveBeenCalledWith('/api/providers/request-probe', expect.objectContaining({stream:true}))
wrapper.getComponent(RequestJsonEditor).vm.$emit('valid', false)
await flushPromises()
finish({message:'旧配置验证通过'})
await flushPromises()
expect(wrapper.text()).not.toContain('旧配置验证通过')
expect(probe.attributes('disabled')).toBeDefined()
})
it('filters compact preset chips and resolves bundled logos', async () => {
const wrapper = await render()
await wrapper.get('#provider-search').setValue('通义')
+50 -23
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, watch, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOverride } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
@@ -27,16 +28,39 @@ const error = ref('')
const requestOverrides = ref<RequestOverride[]>(JSON.parse(JSON.stringify(props.provider?.request_overrides || [])))
const requestJsonValid = ref(true)
const requestPreview = ref('')
const probeResult = ref('')
const probing = ref(false)
const previewCapability = ref('chat')
const previewStream = ref(true)
let draftGeneration = 0
watch([form, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
async function previewRequest() {
const generation = draftGeneration
error.value = ''
try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:true,
provider: {provider_type:form.provider_type,name:form.name || t('预览', 'Preview'),base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
})
requestPreview.value = JSON.stringify(response.body, null, 2)
} catch(e) { error.value = (e as Error).message }
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2)
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
}
async function probeRequest() {
if (probing.value) return
error.value = ''; probeResult.value = ''; probing.value = true
const generation = draftGeneration
try {
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.'))
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || t('推理验证', 'Inference test'),base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
})
if (active && generation === draftGeneration) probeResult.value = result.message
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
finally { probing.value = false }
}
const contextChanged = ref(false)
const dialog = ref<HTMLElement>()
@@ -52,7 +76,7 @@ async function loadPresets() {
try {
presets.value = await service.listProviderPresets()
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' }
} catch { presetsError.value = t('预设加载失败,请重试,或填写自定义服务。', 'Preset loading failed. Retry or enter a custom service.') }
finally { presetsLoading.value = false }
}
@@ -65,7 +89,7 @@ onMounted(async () => {
const result = await service.getCredentialStatus(credentialId.value)
if (active && generation === credentialGeneration) configured.value = result
} catch {
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。'
if (active && generation === credentialGeneration) credentialError.value = t('无法检查已保存的凭据。可输入新密钥,或关闭后重试。', 'Could not check the saved credential. Enter a new key or close and retry.')
} finally {
if (generation === credentialGeneration) credentialLoading.value = false
}
@@ -125,9 +149,9 @@ async function save() {
error.value = ''
saving.value = true
try {
if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。')
if (!requestJsonValid.value) throw new Error('请先修正自定义请求 JSON。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
if (!form.name.trim() || !form.base_url.trim()) throw new Error(t('请填写名称和 Base URL。', 'Enter a name and Base URL.'))
if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value }
if (apiKey.value.trim()) {
@@ -148,7 +172,7 @@ async function save() {
: await service.createProvider({ ...data, credential_id: reference })
if (active) { emit('saved', saved); close() }
} catch (reason) {
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。'
if (active) error.value = reason instanceof Error ? reason.message : t('Provider 保存失败,请重试。', 'Provider save failed. Please retry.')
} finally { apiKey.value = ''; saving.value = false }
}
</script>
@@ -156,29 +180,32 @@ async function save() {
<template>
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? t('编辑 Provider', 'Edit Provider') : t('新增 Provider', 'Add Provider') }}</h2><button type="button" class="button-secondary" :aria-label="t('关闭提供商表单', 'Close provider form')" @click="close">{{ t('关闭', 'Close') }}</button></div>
<p v-if="presetsLoading" class="subtle" role="status">{{ t('正在加载提供商预设', 'Loading provider presets') }}</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">{{ t('重试', 'Retry') }}</button></div>
<form @submit.prevent="save" @input="requestPreview = ''" @change="requestPreview = ''">
<fieldset :disabled="saving">
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
<div class="form-grid">
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field"><span>{{ t('接入协议', 'Protocol') }}</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
<label class="field"><span>{{ t('名称', 'Name') }}</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" required @change="changeConnection" /></label>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? t('已配置,留空表示不修改', 'Configured; leave blank to keep it') : t('请输入 API Key(无鉴权服务可留空)', 'Enter an API key (optional for unauthenticated services)')" /><small class="subtle">{{ t('密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用', 'The local AI Core encrypts the key; provider settings store only its credential reference.') }}</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">{{ t('正在检查凭据状态', 'Checking credential status') }}</p>
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
<label class="field wide"><span>{{ t('默认聊天模型', 'Default chat model') }}</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" :placeholder="t('输入模型 ID,或保存后获取模型列表', 'Enter a model ID, or save to fetch the model list')" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
</div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> {{ t('启用', 'Enabled') }}</label>
<RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" />
<button type="button" class="button-secondary" @click="previewRequest">预览最终流式请求隐藏正文</button>
<div class="inline-actions"><label>{{ t('预览能力', 'Preview capability') }}<select v-model="previewCapability" class="select"><option value="chat">{{ t('聊天', 'Chat') }}</option><option value="embedding">Embedding</option><option value="transcription">{{ t('转写', 'Transcription') }}</option><option value="speaker_matching">{{ t('声纹', 'Speaker') }}</option></select></label><label><input v-model="previewStream" type="checkbox" />{{ t('流式聊天', 'Streaming chat') }}</label></div>
<button type="button" class="button-secondary" @click="previewRequest">{{ t('预览最终请求隐藏正文', 'Preview final request (content hidden)') }}</button>
<button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? t('推理验证中', 'Testing inference') : t('发送测试推理请求', 'Send test inference request') }}</button>
<p class="subtle">{{ t('推理验证会向当前模型发送固定短消息,并计入实际用量。媒体参数请通过真实转写或声纹操作验证。', 'The inference test sends a fixed short message to the current model and counts toward usage. Validate media parameters through an actual transcription or speaker operation.') }}</p><p v-if="probeResult" role="status">{{ probeResult }}</p>
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
</fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? t('保存中…', 'Saving…') : t('保存提供商', 'Save provider') }}</button><button type="button" class="button-secondary" @click="close">{{ t('取消', 'Cancel') }}</button></div>
</form>
</div>
</div>
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import type { ProviderPreset } from '@/contracts'
import ProviderLogo from './ProviderLogo.vue'
import { t } from '@/i18n'
const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
@@ -15,14 +16,14 @@ const filtered = computed(() => {
<template>
<div class="preset-selector">
<label class="field" for="provider-search"><span>提供商预设</span><input id="provider-search" v-model="search" class="input" type="search" placeholder="搜索提供商,例如 通义千问 / DeepSeek" /></label>
<div class="preset-grid" role="group" aria-label="提供商预设">
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>自定义</span></button>
<label class="field" for="provider-search"><span>{{ t('提供商预设', 'Provider presets') }}</span><input id="provider-search" v-model="search" class="input" type="search" :placeholder="t('搜索提供商,例如 通义千问 / DeepSeek', 'Search providers, such as Qwen / DeepSeek')" /></label>
<div class="preset-grid" role="group" :aria-label="t('提供商预设', 'Provider presets')">
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>{{ t('自定义', 'Custom') }}</span></button>
<button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)">
<ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span>
</button>
</div>
<p v-if="search && !filtered.length" class="subtle" role="status">没有匹配的预设可以使用自定义服务</p>
<p v-if="search && !filtered.length" class="subtle" role="status">{{ t('没有匹配的预设可以使用自定义服务', 'No matching preset. You can use a custom service.') }}</p>
</div>
</template>
@@ -1,7 +1,10 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
vi.mock('@/services/apiClient', () => ({apiClient:{post:vi.fn()}}))
it('validates object JSON and prevents host-owned fields from being saved', async () => {
const wrapper = mount(RequestJsonEditor, {props: {modelValue: []}})
@@ -18,3 +21,47 @@ it('validates object JSON and prevents host-owned fields from being saved', asyn
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
wrapper.unmount()
})
it('ignores an imported configuration that finishes after a newer edit', async () => {
let finish!: (value: {request_overrides: unknown[]}) => void
vi.mocked(apiClient.post).mockReturnValue(new Promise(resolve => { finish = resolve }))
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[]}})
const input = wrapper.get('input[type="file"]')
const file = new File(['{"version":1,"request_overrides":[]}'], 'rules.json', {type:'application/json'})
Object.defineProperty(input.element, 'files', {value:[file], configurable:true})
await input.trigger('change')
await wrapper.findAll('button').find(button => button.text() === '添加请求规则')!.trigger('click')
finish({request_overrides:[{capability:'embedding',body:{dimensions:384}}]})
await Promise.resolve(); await Promise.resolve()
expect(wrapper.findAll('textarea')).toHaveLength(1)
expect(wrapper.get('textarea').element.value).toBe('{}')
wrapper.unmount()
})
it('ignores an old import failure after a newer edit', async () => {
let fail!: (reason: Error) => void
vi.mocked(apiClient.post).mockReturnValue(new Promise((_resolve, reject) => { fail = reject }))
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[]}})
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', {value:[new File(['{}'], 'old.json')], configurable:true})
await input.trigger('change')
await wrapper.findAll('button').find(button => button.text() === '添加请求规则')!.trigger('click')
fail(new Error('旧导入失败'))
await flushPromises()
expect(wrapper.text()).not.toContain('旧导入失败')
expect(wrapper.findAll('textarea')).toHaveLength(1)
wrapper.unmount()
})
it('restores defaults even from an invalid draft and reflects replacement configurations', async () => {
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[{capability:'chat', body:{enable_thinking:false}}]}})
await wrapper.get('textarea').setValue('{invalid')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
await wrapper.findAll('button').find(button => button.text() === '恢复默认请求')!.trigger('click')
expect(wrapper.findAll('textarea')).toHaveLength(0)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[]])
await wrapper.setProps({modelValue:[{capability:'embedding', body:{dimensions:384}}]})
expect(wrapper.get('textarea').element.value).toContain('384')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([true])
wrapper.unmount()
})
@@ -1,42 +1,85 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient'
import type { RequestOverride } from '@/contracts'
import { t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const props = defineProps<{modelValue: RequestOverride[]}>()
const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>()
const transferError = ref('')
let published = JSON.stringify(props.modelValue)
let generation = 0
const rules = ref(props.modelValue.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''})))
const protectedFields = new Set(['model','messages','input','system','instructions','tools','tool_choice','parallel_tool_calls','functions','function_call','file','audio','reference_file','stream','previous_response_id','conversation','background','store'])
function publish() {
generation++
let valid = true
const result: RequestOverride[] = []
for (const rule of rules.value) {
try {
const body = JSON.parse(rule.draft)
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('顶层必须为 JSON 对象')
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error(t('顶层必须为 JSON 对象', 'The top level must be a JSON object'))
const conflicts = Object.keys(body).filter(key => protectedFields.has(key))
if (conflicts.length) throw new Error(`运行请求管理字段不可覆盖:${conflicts.join(', ')}`)
if (conflicts.length) throw new Error(`${t('运行请求管理字段不可覆盖:', 'Runtime-managed fields cannot be overridden: ')}${conflicts.join(', ')}`)
rule.error = ''
result.push({capability:rule.capability,model:rule.model || null,stream:rule.stream ?? null,body})
} catch(e) { rule.error = (e as Error).message; valid = false }
}
emit('valid', valid)
if(valid) emit('update:modelValue', result)
if(valid) { published = JSON.stringify(result); emit('update:modelValue', result) }
}
function add() { rules.value.push({capability:'chat',model:null,stream:null,body:{},draft:'{}',error:''}); publish() }
function format(index:number) { try { rules.value[index].draft = JSON.stringify(JSON.parse(rules.value[index].draft), null, 2); publish() } catch { publish() } }
watch(() => props.modelValue.length, length => { if (length === 0 && rules.value.length && rules.value.every(r => !r.error)) rules.value = [] })
watch(() => props.modelValue, value => {
if (JSON.stringify(value) !== published) {
generation++
rules.value = value.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
published = JSON.stringify(value)
emit('valid', true)
}
}, {deep: true})
function reset() { rules.value = []; transferError.value = ''; publish() }
async function importRules(file: File | null) {
if (!file) return
const current = ++generation
transferError.value = ''
try {
if (file.size > 1024 * 1024) throw new Error(t('配置文件不得超过 1 MiB', 'The configuration file must not exceed 1 MiB'))
const parsed = JSON.parse(await file.text())
const validated = await apiClient.post<{request_overrides: RequestOverride[]}>('/api/providers/request-rules/validate', parsed)
if (current !== generation) return
rules.value = validated.request_overrides.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
publish()
} catch(e) { if (current === generation) transferError.value = (e as Error).message }
}
async function exportRules() {
transferError.value = ''
try {
publish()
if (rules.value.some(rule => rule.error)) throw new Error(t('请先修正 JSON', 'Fix the JSON first'))
const validated = await apiClient.post('/api/providers/request-rules/validate', {version:1, request_overrides:JSON.parse(published)})
const url = URL.createObjectURL(new Blob([JSON.stringify(validated, null, 2)], {type:'application/json'}))
const link = document.createElement('a'); link.href = url; link.download = 'model-request-rules.json'; link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch(e) { transferError.value = (e as Error).message }
}
</script>
<template>
<details class="request-json"><summary>高级自定义请求 JSON</summary>
<p class="subtle">提供商通用规则先应用再应用模型规则对象递归合并数组整体替换null 作为实际值删除键后恢复继承密钥继续使用独立 API Key 配置</p>
<details class="request-json ui-disclosure"><summary>{{ t('高级:自定义请求 JSON', 'Advanced: Custom request JSON') }}</summary>
<p class="subtle">{{ t('提供商通用规则先应用再应用模型规则对象递归合并数组整体替换null 作为实际值;删除键后恢复继承密钥继续使用独立 API Key 配置。', 'Provider-wide rules are applied before model rules. Objects merge recursively, arrays replace whole values, and null is kept as a value. Delete a key to inherit it again. API keys remain in the separate credential setting.') }}</p>
<div v-for="(rule,index) in rules" :key="index" class="rule">
<div class="rule-selectors"><label>能力<select v-model="rule.capability" class="select" @change="publish"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">音频转写</option><option value="speaker_matching">声纹比对</option></select></label>
<label>模型<input v-model="rule.model" class="input" placeholder="留空:全部模型" @input="publish" /></label>
<label>请求模式<select v-model="rule.stream" class="select" @change="publish"><option :value="null">全部</option><option :value="true">仅流式</option><option :value="false">仅非流式</option></select></label></div>
<textarea v-model="rule.draft" class="input json-body" rows="6" aria-label="自定义请求 JSON" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" />
<div class="rule-selectors"><label>{{ t('能力', 'Capability') }}<select v-model="rule.capability" class="select" @change="publish"><option value="chat">{{ t('聊天', 'Chat') }}</option><option value="embedding">Embedding</option><option value="transcription">{{ t('音频转写', 'Transcription') }}</option><option value="speaker_matching">{{ t('声纹比对', 'Speaker matching') }}</option></select></label>
<label>{{ t('模型', 'Model') }}<input v-model="rule.model" class="input" :placeholder="t('留空:全部模型', 'Blank: all models')" @input="publish" /></label>
<label>{{ t('请求模式', 'Request mode') }}<select v-model="rule.stream" class="select" @change="publish"><option :value="null">{{ t('全部', 'All') }}</option><option :value="true">{{ t('仅流式', 'Streaming only') }}</option><option :value="false">{{ t('仅非流式', 'Non-streaming only') }}</option></select></label></div>
<textarea v-model="rule.draft" class="input json-body" rows="6" :aria-label="t('自定义请求 JSON', 'Custom request JSON')" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" />
<p v-if="rule.error" class="error-text" role="alert">{{ rule.error }}</p>
<div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">格式化</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">删除规则</button></div>
<div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">{{ t('格式化', 'Format') }}</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">{{ t('删除规则', 'Delete rule') }}</button></div>
</div>
<button type="button" class="button-secondary" @click="add">添加请求规则</button>
<button type="button" class="button-secondary" @click="add">{{ t('添加请求规则', 'Add request rule') }}</button>
<div class="transfer-actions"><div class="inline-actions"><button type="button" class="button-secondary" @click="reset">{{ t('恢复默认请求', 'Restore default request') }}</button><button type="button" class="button-secondary" @click="exportRules">{{ t('导出请求配置', 'Export request settings') }}</button></div><FilePicker :file="null" :label="t('导入请求配置', 'Import request settings')" :empty-label="t('选择 JSON 文件', 'Choose a JSON file')" accept=".json,application/json" @select="importRules" /></div>
<p v-if="transferError" class="error-text" role="alert">{{ transferError }}</p>
<p class="subtle">{{ t('导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。', 'Importing replaces the current request rules and takes effect after saving the provider. Exports contain rules only, without credential references or API keys.') }}</p>
</details>
</template>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--border-color);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}</style>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--color-border-default);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}.transfer-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm);justify-content:space-between}</style>
+27 -26
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue'
import ProviderLogo from './ProviderLogo.vue'
@@ -9,12 +9,13 @@ import UsageCard from './UsageCard.vue'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { t } from '@/i18n'
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core'
const sections: Array<{ id: Section; label: string }> = [
{ id: 'general', label: '通用' }, { id: 'editor', label: '编辑器' }, { id: 'providers', label: '模型提供商' },
{ id: 'index', label: '索引与模型' }, { id: 'permissions', label: '权限' }, { id: 'ai-core', label: 'AI Core 诊断' },
]
const sections = computed<Array<{ id: Section; label: string }>>(() => [
{ id: 'general', label: t('通用', 'General') }, { id: 'editor', label: t('编辑器', 'Editor') }, { id: 'providers', label: t('模型提供商', 'Model Providers') },
{ id: 'index', label: t('索引与模型', 'Index and Models') }, { id: 'permissions', label: t('权限', 'Permissions') }, { id: 'ai-core', label: t('AI Core 诊断', 'AI Core Diagnostics') },
])
const activeSection = ref<Section>('general')
const settingsStore = useSettingsStore()
const providerStore = useProviderStore()
@@ -47,66 +48,66 @@ async function providerSaved(provider: ProviderConfig) {
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` }
async function removeProvider(provider: ProviderConfig) { if (!confirm(`${t('确定删除 Provider', 'Delete Provider')} ${provider.name}?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : t('删除失败', 'Delete failed') } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = t('测试中…', 'Testing…'); const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `${t('连接成功', 'Connection succeeded')}${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `${t('连接失败:', 'Connection failed: ')}${result.error}` }
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
const defaultModel = (event.target as HTMLSelectElement).value
try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) }
catch (error) { providerAction.value = error instanceof Error ? error.message : '默认模型更新失败' }
catch (error) { providerAction.value = error instanceof Error ? error.message : t('默认模型更新失败', 'Failed to update the default model') }
}
</script>
<template>
<section class="feature-page settings-page">
<header class="feature-header"><div><h1>设置</h1><p>管理应用偏好模型索引权限和本地 AI Core</p></div></header>
<header class="feature-header"><div><h1>{{ t('设置', 'Settings') }}</h1><p>{{ t('管理应用偏好、模型、索引、权限和本地 AI Core。', 'Manage application preferences, models, indexing, permissions, and the local AI Core.') }}</p></div></header>
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>通用</h2><label class="setting-row"><span><strong>恢复上次 Vault</strong><small>启动后自动打开最近使用的知识库</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>自动保存间隔</strong><small>编辑停止后等待多久写入文件</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 </option><option :value="1500">1.5 </option><option :value="3000">3 </option></select></div><div class="setting-row"><span><strong>界面语言</strong><small>当前阶段支持中文和英文入口</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>版本</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">写作与预览</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>Markdown 预览最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'providers'" class="settings-section">
<div class="section-head">
<div><h2>模型提供商</h2><p class="subtle">选择国内外提供商预设或配置自定义 API 与独立密钥</p></div>
<button class="button-primary" @click="openProvider()">新增 Provider</button>
<div><h2>{{ t('模型提供商', 'Model Providers') }}</h2><p class="subtle">{{ t('选择国内外提供商预设或配置自定义 API 与独立密钥。', 'Choose a provider preset or configure a custom API with separate credentials.') }}</p></div>
<button class="button-primary" @click="openProvider()">{{ t('新增 Provider', 'Add Provider') }}</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<LocalModelSettings />
<UsageCard />
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? '正在加载提供商' : '尚无可用提供商请添加真实 API 或本地 Ollama 配置' }}</p>
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? t('正在加载提供商', 'Loading providers') : t('尚无可用提供商请添加真实 API 或本地 Ollama 配置', 'No providers are available. Add a real API or local Ollama configuration.') }}</p>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main">
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
<p class="subtle">{{ provider.base_url || t('本地内置', 'Built in locally') }} · {{ t('默认模型', 'Default model') }} {{ provider.default_model || t('未设置', 'Not set') }}</p>
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
<label :for="`default-model-${provider.provider_id}`">默认模型</label>
<label :for="`default-model-${provider.provider_id}`">{{ t('默认模型', 'Default model') }}</label>
<select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)">
<option value="">未设置</option>
<option value="">{{ t('未设置', 'Not set') }}</option>
<option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
</select>
<span class="subtle">已获取 {{ providerStore.modelsByProvider[provider.provider_id].length }} 个模型</span>
<span class="subtle">{{ t('已获取', 'Loaded') }} {{ providerStore.modelsByProvider[provider.provider_id].length }} {{ t('个模型', 'models') }}</span>
</div>
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">模型获取失败{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">{{ t('模型获取失败', 'Failed to load models: ') }}{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
</div>
<div class="inline-actions provider-actions">
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中' : '刷新模型' }}</button>
<button class="button-secondary" @click="testProvider(provider)">测试</button>
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
<button class="button-danger" @click="removeProvider(provider)">删除</button>
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? t('获取中', 'Loading') : t('刷新模型', 'Refresh models') }}</button>
<button class="button-secondary" @click="testProvider(provider)">{{ t('测试', 'Test') }}</button>
<button class="button-secondary" @click="openProvider(provider)">{{ t('编辑', 'Edit') }}</button>
<button class="button-danger" @click="removeProvider(provider)">{{ t('删除', 'Delete') }}</button>
</div>
</article>
</div>
</div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? '未获取' }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? '未获取' }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><span class="subtle">当前后端支持全量重建</span></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>{{ t('索引与模型', 'Index and Models') }}</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>{{ t('待处理任务', 'Pending jobs') }} {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? t('未获取', 'Unavailable') }}</strong><small>{{ t('笔记', 'Notes') }}</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? t('未获取', 'Unavailable') }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">{{ t('重建全部', 'Rebuild all') }}</button><span class="subtle">{{ t('当前后端支持全量重建', 'The current backend supports a full rebuild.') }}</span></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">尚未获取权限策略,请检查后端连接并重新检测。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? '允许' : policy === 'confirm' ? '每次确认' : '拒绝' }}</span></div></div></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>{{ t('权限策略', 'Permission Policy') }}</h2><p class="muted section-description">{{ t('以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。', 'These policies are active in the backend. Global policy editing is not yet available; runtime requests are confirmed as needed.') }}</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">{{ t('尚未获取权限策略,请检查后端连接并重新检测。', 'Permission policy is unavailable. Check the backend connection and try again.') }}</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? t('允许', 'Allow') : policy === 'confirm' ? t('每次确认', 'Confirm each time') : t('拒绝', 'Deny') }}</span></div></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>AI Core 连接状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><span class="subtle">当前 Web 端不支持重启后端进程请在运行后端的终端中操作</span></div></div>
<div v-else class="panel settings-section"><h2>{{ t('AI Core 诊断', 'AI Core Diagnostics') }}</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>{{ t('AI Core 连接状态', 'AI Core connection') }}</h3><p class="subtle">{{ t('AI Core 不可用时,Markdown 编辑仍可继续使用。', 'Markdown editing remains available when AI Core is offline.') }}</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>{{ t('开发 API 地址', 'Development API address') }}</h3><p class="subtle">{{ t('正式桌面环境由 Sidecar Manager 动态提供。', 'The desktop build will provide this through Sidecar Manager.') }}</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">{{ t('重新检测', 'Check again') }}</button><span class="subtle">{{ t('当前 Web 端不支持重启后端进程请在运行后端的终端中操作', 'The web build cannot restart the backend. Use the terminal running it.') }}</span></div></div>
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
</section>
+17 -15
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient'
interface Usage {totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
import { t } from '@/i18n'
interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
const data = ref<Usage | null>(null)
const period = ref('7')
const provider = ref('')
@@ -11,7 +12,7 @@ const start = ref('')
const end = ref('')
const busy = ref(false)
const error = ref('')
const metrics: Record<string,string> = {input_tokens:'输入 Token',output_tokens:'输出 Token',total_tokens:'总 Token',cache_hit_tokens:'缓存命中',cache_miss_tokens:'缓存未命中',cache_write_tokens:'缓存写入',reasoning_tokens:'推理 Token'}
const metrics = computed<Record<string,string>>(() => ({input_tokens:t('输入 Token','Input tokens'),output_tokens:t('输出 Token','Output tokens'),total_tokens:t('总 Token','Total tokens'),cache_hit_tokens:t('缓存命中','Cache hits'),cache_miss_tokens:t('缓存未命中','Cache misses'),cache_write_tokens:t('缓存写入','Cache writes'),reasoning_tokens:t('推理 Token','Reasoning tokens')}))
async function load() {
busy.value = true; error.value = ''
try {
@@ -19,26 +20,27 @@ async function load() {
const from = period.value === 'custom' ? new Date(start.value) : new Date(until)
if (period.value === 'today') from.setHours(0,0,0,0)
else if (period.value !== 'custom') from.setDate(from.getDate() - Number(period.value))
if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error('请选择有效的开始与结束时间。')
if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error(t('请选择有效的开始与结束时间。', 'Choose a valid start and end time.'))
data.value = await apiClient.get<Usage>('/api/usage', {params: {start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
} catch(e) { error.value = (e as Error).message } finally { busy.value = false }
}
onMounted(load)
</script>
<template>
<section class="panel usage-card"><header><h3>Token 消耗情况</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? '加载中' : '刷新统计' }}</button></header>
<div class="filters"><label>时间<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">今日</option><option value="7">近 7 天</option><option value="30">近 30 天</option><option value="custom">自定义</option></select></label>
<label>提供商<select v-model="provider" class="select" @change="model = ''; load()"><option value="">全部</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label>
<label>模型<select v-model="model" class="select" @change="load"><option value="">全部</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label>
<label>来源<select v-model="source" class="select" @change="load"><option value="">全部</option><option value="api">远程 API</option><option value="local">本地服务</option></select></label>
<section class="panel usage-card"><header><h3>{{ t('Token 消耗情况', 'Token Usage') }}</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? t('加载中', 'Loading') : t('刷新统计', 'Refresh') }}</button></header>
<div class="filters"><label>{{ t('时间', 'Period') }}<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">{{ t('今日', 'Today') }}</option><option value="7">{{ t('近 7 天', 'Last 7 days') }}</option><option value="30">{{ t('近 30 天', 'Last 30 days') }}</option><option value="custom">{{ t('自定义', 'Custom') }}</option></select></label>
<label>{{ t('提供商', 'Provider') }}<select v-model="provider" class="select" @change="model = ''; load()"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label>
<label>{{ t('模型', 'Model') }}<select v-model="model" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label>
<label>{{ t('来源', 'Source') }}<select v-model="source" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option value="api">{{ t('远程 API', 'Remote API') }}</option><option value="local">{{ t('本地服务', 'Local service') }}</option></select></label>
</div>
<div v-if="period === 'custom'" class="filters"><label>开始<input v-model="start" class="input" type="datetime-local" /></label><label>结束<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">应用时间段</button></div>
<div v-if="period === 'custom'" class="filters"><label>{{ t('开始', 'Start') }}<input v-model="start" class="input" type="datetime-local" /></label><label>{{ t('结束', 'End') }}<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">{{ t('应用时间段', 'Apply period') }}</button></div>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<template v-if="data"><p v-if="!data.request_count" class="subtle">该时间段没有已记录的模型请求</p>
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? '未提供' : data.totals[key]?.toLocaleString() }}</strong><small>覆盖 {{ data.coverage[key] }} / {{ data.request_count }} </small></div>
<div><small>缓存命中率</small><strong>{{ data.cache_hit_rate === null ? '未提供' : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>覆盖 {{ data.cache_covered_requests }} </small></div></div>
<p class="subtle">请求 {{ data.request_count }} 其中完整结束 {{ data.complete_requests }} 输入总量包含厂商已报告的缓存推理 Token 不重复加入输出</p>
</template><p class="subtle">统计为本应用观测值不是厂商账户账单缺失指标显示未提供历史未记录的数据不补估</p>
<template v-if="data"><p v-if="!data.request_count" class="subtle">{{ t('该时间段没有已记录的模型请求', 'No model requests were recorded during this period.') }}</p>
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? t('未提供', 'Unavailable') : data.totals[key]?.toLocaleString() }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.coverage[key] }} / {{ data.request_count }} {{ t('次', 'requests') }}</small></div>
<div><small>{{ t('缓存命中率', 'Cache hit rate') }}</small><strong>{{ data.cache_hit_rate === null ? t('未提供', 'Unavailable') : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.cache_covered_requests }}</small></div></div>
<p class="subtle">{{ t('音频调用', 'Audio calls') }} {{ data.audio_request_count ?? 0 }} · {{ t('时长', 'Duration') }} {{ data.audio_seconds == null ? t('未提供', 'Unavailable') : `${data.audio_seconds.toFixed(2)} ${t('秒', 'sec')}` }} ({{ t('覆盖', 'coverage') }} {{ data.audio_covered_requests ?? 0 }}; {{ t('重试分别计数', 'retries counted separately') }})</p>
<p class="subtle">{{ t('请求', 'Requests') }} {{ data.request_count }}, {{ t('其中完整结束', 'completed') }} {{ data.complete_requests }}. {{ t('输入总量包含厂商已报告的缓存,推理 Token 不重复加入输出。', 'Input totals include provider-reported cache tokens; reasoning tokens are not added to output twice.') }}</p>
</template><p class="subtle">{{ t('统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。', 'Statistics are application observations, not provider billing. Missing metrics stay unavailable and historical gaps are not estimated.') }}</p>
</section>
</template>
<style scoped>.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>
+11 -10
View File
@@ -3,36 +3,37 @@ import { Lightning } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { onMounted, ref } from 'vue'
import { useSkillStore } from '@/stores/skill'
import { t } from '@/i18n'
const skillStore = useSkillStore()
const actionError = ref('')
onMounted(() => { void skillStore.loadSkills() })
async function install() {
const path = prompt('请输入 Skill Package 路径')?.trim()
const path = prompt(t('请输入 Skill Package 路径', 'Enter the Skill package path'))?.trim()
if (!path) return
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' }
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
}
async function toggle(skillId: string, enabled: boolean) {
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
}
async function uninstall(skillId: string, name: string) {
if (!confirm(`确定卸载 Skill${name}吗?`)) return
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' }
if (!confirm(`${t('确定卸载 Skill', 'Uninstall Skill')} ${name}?`)) return
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
}
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>Skill 管理</h1><p>查看工作流使用的 Tool权限检索配置和模型要求</p></div><button class="button-primary" @click="install">安装 Skill</button></header>
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="install">{{ t('安装 Skill', 'Install Skill') }}</button></header>
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
<div v-if="skillStore.selectedSkill" class="panel detail-panel">
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || '未知作者' }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">卸载</button></div></div>
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || t('未知作者', 'Unknown author') }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">{{ t('卸载', 'Uninstall') }}</button></div></div>
<p class="description">{{ skillStore.selectedSkill.description }}</p>
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖{{ skillStore.selectedSkill.missing_dependencies.join('') }}</div>
<div class="detail-grid"><div><h3>{{ t('工具', 'Tools') }}</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>{{ t('权限', 'Permissions') }}</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>{{ t('检索配置', 'Retrieval Settings') }}</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>{{ t('模型能力', 'Model Capabilities') }}</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">{{ t('缺失依赖', 'Missing dependencies: ') }}{{ skillStore.selectedSkill.missing_dependencies.join(', ') }}</div>
</div>
<div v-else-if="!skillStore.skills.length" class="empty-state"><div><strong>{{ skillStore.isLoading ? '正在加载…' : skillStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="skillStore.loadSkills">重新加载</button></div></div>
<div v-else-if="!skillStore.skills.length" class="empty-state"><div><strong>{{ skillStore.isLoading ? t('正在加载…', 'Loading…') : skillStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'Not installed') }}</strong><button class="button-secondary" @click="skillStore.loadSkills">{{ t('重新加载', 'Reload') }}</button></div></div>
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
</section>
</template>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { useTaskStore } from '@/stores/task'
import { t } from '@/i18n'
const taskStore = useTaskStore()
</script>
<template>
<div class="sidebar-panel filters">
<div class="field"><label>状态</label><select v-model="taskStore.filterStatus" class="select"><option value="all">全部</option><option value="todo">待办</option><option value="in_progress">进行中</option><option value="done">已完成</option><option value="cancelled">已取消</option></select></div>
<div class="task-counts"><p><span>待办</span><strong>{{ taskStore.todoTasks.length }}</strong></p><p><span>进行中</span><strong>{{ taskStore.inProgressTasks.length }}</strong></p><p><span>已完成</span><strong>{{ taskStore.doneTasks.length }}</strong></p></div>
<div class="field"><label>{{ t('状态', 'Status') }}</label><select v-model="taskStore.filterStatus" class="select"><option value="all">{{ t('全部', 'All') }}</option><option value="todo">{{ t('待办', 'To do') }}</option><option value="in_progress">{{ t('进行中', 'In progress') }}</option><option value="done">{{ t('已完成', 'Completed') }}</option><option value="cancelled">{{ t('已取消', 'Cancelled') }}</option></select></div>
<div class="task-counts"><p><span>{{ t('待办', 'To do') }}</span><strong>{{ taskStore.todoTasks.length }}</strong></p><p><span>{{ t('进行中', 'In progress') }}</span><strong>{{ taskStore.inProgressTasks.length }}</strong></p><p><span>{{ t('已完成', 'Completed') }}</span><strong>{{ taskStore.doneTasks.length }}</strong></p></div>
</div>
</template>
+11 -10
View File
@@ -2,6 +2,7 @@
import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts'
import { useTaskStore } from '@/stores/task'
import { localeTag, t } from '@/i18n'
const taskStore = useTaskStore()
const showForm = ref(false)
@@ -20,32 +21,32 @@ async function saveTask() {
if (editingId.value) await taskStore.updateTask(editingId.value, { ...form, due_date: form.due_date || undefined, note_id: form.note_id || null })
else await taskStore.createTask({ ...form, due_date: form.due_date || undefined, note_id: form.note_id || undefined })
showForm.value = false; resetForm()
} catch (error) { actionError.value = error instanceof Error ? error.message : '任务保存失败' }
} catch (error) { actionError.value = error instanceof Error ? error.message : t('任务保存失败', 'Failed to save task') }
}
async function setStatus(task: TaskItem, status: TaskStatus) {
try { await taskStore.updateTask(task.task_id, { status }) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
try { await taskStore.updateTask(task.task_id, { status }) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Failed to update status') }
}
async function remove(task: TaskItem) {
if (!confirm(`确定删除任务${task.title}吗?`)) return
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : '任务删除失败' }
if (!confirm(`${t('确定删除任务', 'Delete task')} ${task.title}?`)) return
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('任务删除失败', 'Failed to delete task') }
}
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>任务</h1><p>管理用户笔记和 Agent 产生的行动项</p></div><button class="button-primary" @click="resetForm(); showForm = true"> 新建任务</button></header>
<header class="feature-header"><div><h1>{{ t('任务', 'Tasks') }}</h1><p>{{ t('管理用户、笔记和 Agent 产生的行动项。', 'Manage action items created by users, notes, and agents.') }}</p></div><button class="button-primary" @click="resetForm(); showForm = true"> {{ t('新建任务', 'New task') }}</button></header>
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
<div v-if="taskStore.filteredTasks.length" class="task-list">
<article v-for="task in taskStore.filteredTasks" :key="task.task_id" class="item-card task-card">
<button class="status-check" :class="{ done: task.status === 'done' }" title="切换完成状态" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '' : '' }}</button>
<div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">截止 {{ new Date(task.due_date).toLocaleString() }}</span><span v-if="task.note_id">关联 Note{{ task.note_id }}</span></div></div>
<div class="inline-actions"><button class="icon-button" @click="editTask(task)">编辑</button><button class="button-danger" @click="remove(task)">删除</button></div>
<button class="status-check" :class="{ done: task.status === 'done' }" :title="t('切换完成状态', 'Toggle completion')" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '' : '' }}</button>
<div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">{{ t('截止', 'Due') }} {{ new Date(task.due_date).toLocaleString(localeTag()) }}</span><span v-if="task.note_id">{{ t('关联 Note', 'Linked Note') }}: {{ task.note_id }}</span></div></div>
<div class="inline-actions"><button class="icon-button" @click="editTask(task)">{{ t('编辑', 'Edit') }}</button><button class="button-danger" @click="remove(task)">{{ t('删除', 'Delete') }}</button></div>
</article>
</div>
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? '正在加载任务…' : '没有符合条件的任务' }}</strong><p>创建一项任务或调整左侧筛选条件</p></div></div>
<div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? '编辑任务' : '新建任务' }}</h2><form @submit.prevent="saveTask"><div class="field"><label>标题</label><input v-model="form.title" class="input" required /></div><div class="field"><label>描述</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>截止时间</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>关联 Note ID</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">保存</button><button type="button" class="button-secondary" @click="showForm = false">取消</button></div></form></div></div>
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? t('正在加载任务…', 'Loading tasks…') : t('没有符合条件的任务', 'No matching tasks') }}</strong><p>{{ t('创建一项任务或调整左侧筛选条件。', 'Create a task or adjust the filters.') }}</p></div></div>
<div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? t('编辑任务', 'Edit task') : t('新建任务', 'New task') }}</h2><form @submit.prevent="saveTask"><div class="field"><label>{{ t('标题', 'Title') }}</label><input v-model="form.title" class="input" required /></div><div class="field"><label>{{ t('描述', 'Description') }}</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>{{ t('截止时间', 'Due date') }}</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>{{ t('关联 Note ID', 'Linked Note ID') }}</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">{{ t('保存', 'Save') }}</button><button type="button" class="button-secondary" @click="showForm = false">{{ t('取消', 'Cancel') }}</button></div></form></div></div>
</section>
</template>
+13 -43
View File
@@ -4,6 +4,7 @@ import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes } from '@/services/themePackageService'
import type { ThemePackageInspection } from '@/contracts'
import { t } from '@/i18n'
const themeStore = useThemeStore()
@@ -85,12 +86,12 @@ onMounted(() => {
<section class="feature-page">
<header class="feature-header">
<div>
<h1>主题</h1>
<h1>{{ t('主题', 'Themes') }}</h1>
<p>浏览导入和管理主题打造你的知识工作流</p>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = true">导入主题</button>
<button class="button-secondary" @click="themeStore.resetToDefault()">恢复默认</button>
<button class="button-secondary" @click="themeStore.resetToDefault()">{{ t('恢复默认', 'Reset defaults') }}</button>
</div>
</header>
@@ -131,10 +132,10 @@ onMounted(() => {
<strong>{{ theme.name }}</strong>
<p class="subtle">{{ theme.description }}</p>
</div>
<span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">使用中</span>
<span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">{{ t('使用中', 'Active') }}</span>
</div>
<p class="subtle">
v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}
v{{ theme.version }} · {{ theme.builtin ? t('内置主题', 'Built-in theme') : theme.author }}
<span v-if="!theme.builtin"> · 自定义</span>
</p>
<div v-if="!theme.builtin" class="theme-actions" @click.stop>
@@ -178,47 +179,16 @@ onMounted(() => {
</div>
<div class="panel preference-panel">
<h2 class="panel-title">编辑器外观</h2>
<h2 class="panel-title">{{ t('编辑器外观', 'Editor Appearance') }}</h2>
<div class="form-grid">
<div class="field">
<label>字号{{ themeStore.fontEditorSize }}px</label>
<input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" />
</div>
<div class="field">
<label>行高{{ themeStore.lineHeight }}</label>
<input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" />
</div>
<div class="field">
<label>字体</label>
<select v-model="themeStore.fontEditorFamily" class="select">
<option value="system-ui">系统字体</option>
<option value="serif">衬线字体</option>
<option value="var(--font-ui-mono)">等宽字体</option>
</select>
</div>
<div class="field">
<label>代码块样式</label>
<select v-model="themeStore.codeBlockTheme" class="select">
<option value="auto">跟随主题</option>
<option value="github-light">GitHub Light</option>
<option value="github-dark">GitHub Dark</option>
</select>
<small>Markdown 渲染使用对应的 Shiki GitHub 主题</small>
</div>
<div class="field"><label>{{ t('字号', 'Font size') }}: {{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div>
<div class="field"><label>{{ t('行高', 'Line height') }}: {{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div>
<div class="field"><label>{{ t('字体', 'Font') }}</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">{{ t('系统字体', 'System font') }}</option><option value="serif">{{ t('衬线字体', 'Serif') }}</option><option value="var(--font-ui-mono)">{{ t('等宽字体', 'Monospace') }}</option></select></div>
<div class="field"><label>{{ t('代码块样式', 'Code block style') }}</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">{{ t('跟随主题', 'Follow theme') }}</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>{{ t('Markdown 渲染使用对应的 Shiki GitHub 主题', 'Markdown rendering uses the matching Shiki GitHub theme') }}</small></div>
</div>
<div
class="editor-preview"
:style="{
fontSize: `${themeStore.fontEditorSize}px`,
lineHeight: themeStore.lineHeight,
fontFamily: themeStore.fontEditorFamily,
}"
>
<div class="preview-heading">
<h3>主题预览</h3>
<span class="badge info">{{ codeThemeLabel }}</span>
</div>
<p>知识的价值不只在于保存更在于被重新发现和使用</p>
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
<div class="preview-heading"><h3>{{ t('主题预览', 'Theme Preview') }}</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
<p>{{ t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.') }}</p>
<MarkdownContent class="code-theme-preview" :source="shikiPreview" />
</div>
</div>
+10 -9
View File
@@ -6,6 +6,7 @@ import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings'
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
@@ -55,15 +56,15 @@ async function openFolderPicker() {
<div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
<h1 class="app-title">NotesAgent</h1>
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
<p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div>
<div class="vault-card">
<h2 class="card-title">选择知识库</h2>
<p class="card-desc">Web 联调模式连接 AI Core 当前配置的 Vault</p>
<h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
<p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
<div class="section-label">最近打开</div>
<div class="section-label">{{ t('最近打开', 'Recently opened') }}</div>
<div class="vault-list">
<button
v-for="vault in workspaceStore.recentVaults"
@@ -84,15 +85,15 @@ async function openFolderPicker() {
<div class="actions">
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
<AppIcon :icon="FolderOpened" /> 打开后端 Vault
<AppIcon :icon="FolderOpened" /> {{ t('打开后端 Vault', 'Open backend Vault') }}
</button>
</div>
<div class="ai-core-status">
<span class="status-dot" :class="aiCoreStatus" />
<span v-if="aiCoreStatus === 'checking'">正在检查 AI Core 状态...</span>
<span v-else-if="aiCoreStatus === 'running'" class="status-running">AI Core 运行正常</span>
<span v-else class="status-stopped">AI Core 未启动编辑功能仍可用</span>
<span v-if="aiCoreStatus === 'checking'">{{ t('正在检查 AI Core 状态...', 'Checking AI Core status...') }}</span>
<span v-else-if="aiCoreStatus === 'running'" class="status-running">{{ t('AI Core 运行正常', 'AI Core is running') }}</span>
<span v-else class="status-stopped">{{ t('AI Core 未启动(编辑功能仍可用)', 'AI Core is offline (editing remains available)') }}</span>
</div>
</div>
@@ -100,7 +101,7 @@ async function openFolderPicker() {
<span>v0.1.0</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
{{ themeStore.isDark ? '浅色' : '深色' }}
{{ themeStore.isDark ? t('浅色', 'Light') : t('深色', 'Dark') }}
</button>
</div>
</div>
@@ -8,6 +8,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
import FileTreeNode from './FileTreeNode.vue'
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
@@ -91,7 +92,7 @@ function closeContextMenu() { contextTarget.value = null }
async function renameTarget() {
const node = contextTarget.value
if (!node) return
const newName = window.prompt('新名称', node.name)?.trim()
const newName = window.prompt(t('新名称', 'New name'), node.name)?.trim()
if (newName && newName !== node.name) {
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
const oldPath = node.path
@@ -113,7 +114,7 @@ async function renameTarget() {
async function deleteTarget() {
const node = contextTarget.value
if (!node) return
if (!window.confirm(`确定要删除${node.name}吗?`)) return closeContextMenu()
if (!window.confirm(`${t('确定要删除', 'Delete')} ${node.name}?`)) return closeContextMenu()
await workspaceService.deleteFile(node.path)
const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.removeFromTree(node.path)
@@ -137,13 +138,13 @@ function containingFolder(path: string): string {
<template>
<section class="file-tree-panel" @click="closeContextMenu">
<div class="toolbar">
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
<button type="button" :title="t('新建笔记', 'New note')" :aria-label="t('新建笔记', 'New note')" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" :title="t('新建文件夹', 'New folder')" :aria-label="t('新建文件夹', 'New folder')" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
</div>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
<button type="submit">创建</button>
<button type="button" @click="newItemType = null">取消</button>
<input v-model="newItemName" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" autofocus />
<button type="submit">{{ t('创建', 'Create') }}</button>
<button type="button" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
</form>
<div class="tree">
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
@@ -152,8 +153,8 @@ function containingFolder(path: string): string {
<Teleport to="body">
<div v-if="contextTarget" class="context-menu"
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
<button @click="renameTarget">重命名</button>
<button class="danger" @click="deleteTarget">删除</button>
<button @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
<button class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
</div>
</Teleport>
</section>
@@ -4,6 +4,7 @@ import EditorHeader from '@/features/editor/EditorHeader.vue'
import EditorPane from '@/features/editor/EditorPane.vue'
import { EditPen } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const workspaceStore = useWorkspaceStore()
</script>
@@ -17,8 +18,8 @@ const workspaceStore = useWorkspaceStore()
<div v-else class="empty-workspace">
<div class="empty-content">
<AppIcon class="empty-icon" :icon="EditPen" :size="48" />
<h2>开始写作</h2>
<p>从左侧文件树选择笔记或创建新的笔记</p>
<h2>{{ t('开始写作', 'Start writing') }}</h2>
<p>{{ t('从左侧文件树选择笔记或创建新的笔记', 'Select a note from the file tree or create a new one') }}</p>
</div>
</div>
</div>
+41
View File
@@ -0,0 +1,41 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { nextTick } from 'vue'
import PrimarySidebar from '@/components/common/PrimarySidebar.vue'
import { appLocale, t } from '@/i18n'
import { useSettingsStore } from '@/stores/settings'
beforeEach(() => {
localStorage.clear()
appLocale.value = 'zh-CN'
setActivePinia(createPinia())
})
afterEach(() => {
appLocale.value = 'zh-CN'
localStorage.clear()
})
describe('interface locale', () => {
it('changes shared labels and the document language immediately', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'workspace', component: { template: '<div />' } }],
})
await router.push('/')
await router.isReady()
const wrapper = mount(PrimarySidebar, { global: { plugins: [router] } })
const settings = useSettingsStore()
expect(wrapper.text()).toContain('工作区')
settings.language = 'en'
await nextTick()
expect(t('工作区', 'Workspace')).toBe('Workspace')
expect(wrapper.text()).toContain('Workspace')
expect(document.documentElement.lang).toBe('en')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { ref, watch } from 'vue'
export type AppLocale = 'zh-CN' | 'en'
function storedLocale(): AppLocale {
if (typeof localStorage === 'undefined') return 'zh-CN'
try {
const saved = JSON.parse(localStorage.getItem('app-settings') ?? '{}') as { language?: unknown }
return saved.language === 'en' ? 'en' : 'zh-CN'
} catch {
return 'zh-CN'
}
}
export const appLocale = ref<AppLocale>(storedLocale())
watch(appLocale, (value) => {
if (typeof document !== 'undefined') document.documentElement.lang = value
}, { immediate: true })
/** Keep the Chinese source beside its English translation while the UI is migrated. */
export function t(zh: string, en: string): string {
return appLocale.value === 'en' ? en : zh
}
export function localeTag(): string {
return appLocale.value === 'en' ? 'en' : 'zh-CN'
}
+10 -1
View File
@@ -5,6 +5,10 @@ import router from './router'
import './styles/tokens.css'
import './styles/features.css'
import { useThemeStore } from './stores/theme'
import { useSettingsStore } from './stores/settings'
import { watch } from 'vue'
import { appLocale } from './i18n'
import { updateDocumentTitle } from './router'
const app = createApp(App)
const pinia = createPinia()
@@ -13,7 +17,12 @@ app.use(pinia)
app.use(router)
const themeStore = useThemeStore()
// initTheme 先同步落内置主题兜底,自定义主题恢复是异步的,不阻塞挂载。
const settingsStore = useSettingsStore()
void themeStore.initTheme()
watch(appLocale, () => updateDocumentTitle())
watch(() => settingsStore.spellCheck, (enabled) => {
document.body.spellcheck = enabled
document.body.setAttribute('spellcheck', String(enabled))
}, { immediate: true })
app.mount('#app')
+20 -3
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { t } from '@/i18n'
const routes = [
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
@@ -87,10 +88,26 @@ router.beforeEach((to) => {
return true
})
router.afterEach((to) => {
export function updateDocumentTitle(to = router.currentRoute.value) {
const baseTitle = 'NotesAgent'
const title = to.meta.title as string | undefined
const titles: Record<string, string> = {
media: t('音视频转写', 'Media Transcription'),
'vault-entry': t('选择知识库', 'Select Knowledge Base'),
workspace: t('工作区', 'Workspace'),
search: t('搜索', 'Search'),
chat: t('AI 对话', 'AI Chat'),
agent: 'Agent Trace',
tasks: t('任务', 'Tasks'),
skills: t('Skill 管理', 'Skill Management'),
'mcp-servers': t('MCP 服务器', 'MCP Servers'),
plugins: t('Plugin 与 MCP', 'Plugins and MCP'),
themes: t('主题管理', 'Theme Management'),
settings: t('设置', 'Settings'),
}
const title = titles[String(to.name ?? '')] ?? (to.meta.title as string | undefined)
document.title = title ? `${title} · ${baseTitle}` : baseTitle
})
}
router.afterEach(updateDocumentTitle)
export default router
+24 -1
View File
@@ -1,10 +1,14 @@
import { SseClient } from './sseClient'
import type { ModelEvent } from '@/contracts'
import { apiClient } from './apiClient'
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
export interface ChatRequest {
provider_id: string
model: string
conversation_id?: string
user_message_id?: string
assistant_message_id?: string
conversation_title?: string
system?: string
messages: Array<{
role: 'system' | 'user' | 'assistant' | 'tool'
@@ -18,6 +22,25 @@ export interface ChatRequest {
max_tokens?: number
}
export function listConversations(offset = 0, limit = 100) {
return apiClient.get<{ items: Conversation[]; page: PageMeta }>('/api/chat/conversations', { params: { limit, offset } })
}
export function createConversation(conversation: Pick<Conversation, 'conversation_id' | 'title'>) {
return apiClient.post<Conversation>('/api/chat/conversations', {
conversation_id: conversation.conversation_id,
title: conversation.title,
})
}
export function listConversationMessages(conversationId: string, offset = 0, limit = 500) {
return apiClient.get<{ items: ChatMessage[]; page: PageMeta }>(`/api/chat/conversations/${encodeURIComponent(conversationId)}/messages`, { params: { limit, offset } })
}
export function removeConversation(conversationId: string) {
return apiClient.delete(`/api/chat/conversations/${encodeURIComponent(conversationId)}`)
}
export function streamChat(
request: ChatRequest,
handlers: {
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { createMediaSubmission, mediaService, type MediaJob } from './mediaService'
afterEach(() => vi.restoreAllMocks())
it('reuses upload and job identities after lost responses, until explicitly reset', async () => {
const upload = vi.spyOn(mediaService, 'upload').mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValue({attachment_id:'uploaded'})
const create = vi.spyOn(mediaService, 'create').mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValue({job_id:'same-job'} as MediaJob)
const submission = createMediaSubmission()
const file = new File(['audio'], 'lecture.wav')
const options = {local_only:true}
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
expect(await submission.submit(file, options)).toEqual({job_id:'same-job'})
expect(upload).toHaveBeenCalledTimes(2)
expect(upload.mock.calls[0][1]).toBe(upload.mock.calls[1][1])
expect(create.mock.calls[0][0]).toEqual(create.mock.calls[1][0])
submission.reset()
await submission.submit(file, options)
expect(upload.mock.calls[2][1]).not.toBe(upload.mock.calls[1][1])
expect(create.mock.calls[2][0]).not.toEqual(create.mock.calls[1][0])
})
it('freezes options across upload and treats changed options as a new request', async () => {
let release!: (value:{attachment_id:string}) => void
vi.spyOn(mediaService, 'upload').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
.mockResolvedValue({attachment_id:'next'})
const create = vi.spyOn(mediaService, 'create').mockResolvedValue({job_id:'job'} as MediaJob)
const submission = createMediaSubmission()
const file = new File(['audio'], 'lecture.wav')
const options = {local_only:true}
const pending = submission.submit(file, options)
options.local_only = false
release({attachment_id:'first'})
await pending
expect(create.mock.calls[0][0]).toMatchObject({local_only:true})
await submission.submit(file, options)
expect(create.mock.calls[1][0]).toMatchObject({local_only:false})
})
+23 -4
View File
@@ -1,4 +1,5 @@
import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n'
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob {
@@ -18,15 +19,33 @@ export const mediaService = {
revision: job.revision, text: job.text, segments: job.segments, speaker_names: job.speaker_names,
}),
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
note: (id: string, title: string) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title }),
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }),
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
async upload(file: File) {
async upload(file: File, idempotencyKey?: string) {
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: file,
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
})
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败')
if (!response.ok) throw new Error((await response.json())?.error?.message || t('附件上传失败', 'Attachment upload failed'))
return await response.json() as {attachment_id: string}
},
}
// Keep one identity until the input/options change, including a lost HTTP response.
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
export function createMediaSubmission() {
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
return {
reset() { pending = null },
async submit(file: File, options: Record<string, unknown>) {
const serialized = JSON.stringify(options)
if (!pending || pending.file !== file || pending.options !== serialized) {
pending = {file, options: serialized, uploadKey: crypto.randomUUID(), jobKey: crypto.randomUUID()}
}
const current = pending
if (!current.attachmentId) current.attachmentId = (await mediaService.upload(file, current.uploadKey)).attachment_id
return mediaService.create({...JSON.parse(current.options), attachment_id: current.attachmentId, idempotency_key: current.jobKey})
},
}
}
Binary file not shown.
+6 -9
View File
@@ -209,7 +209,6 @@ export async function installTheme(
if (warnings.length > 0) {
console.warn('[theme] CSS validation warnings:', warnings)
}
applyThemeCss(manifest.theme_id, cssContent)
const installed: InstalledTheme = {
theme_id: manifest.theme_id,
name: manifest.name,
@@ -233,14 +232,7 @@ export async function installTheme(
}
export async function listInstalledThemes(): Promise<InstalledTheme[]> {
const themes = loadStoredThemes()
for (const theme of themes) {
if (!theme.builtin) {
const css = localStorage.getItem(`${STORAGE_KEY}-css-${theme.theme_id}`)
if (css) applyThemeCss(theme.theme_id, css)
}
}
return themes
return loadStoredThemes()
}
export async function enableTheme(themeId: string): Promise<InstalledTheme> {
@@ -279,6 +271,11 @@ export function getActiveCustomTheme(): string | null {
}
export function setActiveCustomTheme(themeId: string | null) {
const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
// Validate before changing the current page. Only the selected theme owns a style node.
if (css) validateCssSafety(css)
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
if (themeId && css) applyThemeCss(themeId, css)
if (themeId) localStorage.setItem(ACTIVE_CUSTOM_KEY, themeId)
else localStorage.removeItem(ACTIVE_CUSTOM_KEY)
}

Some files were not shown because too many files have changed in this diff Show More