diff --git a/.gitignore b/.gitignore index f83769f..5e1932b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 60ca8e2..3e555c2 100644 --- a/README.md +++ b/README.md @@ -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.1(stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。 +NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 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 -``` -后端地址: - -- 健康检查: -- 服务状态: -- API 文档: -- OpenAPI JSON: - -#### 开发环境使用外部模型 - -在“设置 → 模型提供商”中选择 DeepSeek 或 OpenAI 预设后,直接在密码输入框填写 API Key。前端只在提交期间持有该值,不写入 Pinia 或 localStorage;AI Core 将其加密保存到本机 `backend/data/credentials/`,Provider 配置只保留内部 Credential ID。 - -该目录同时包含本地开发用主密钥和密文,并已加入 `.gitignore`。这提供本地静态加密和完整性校验,但不能替代操作系统凭据库。开始 Tauri 桌面集成后,应将存储实现迁移到 Stronghold,保留现有 Credential API 与 Provider 接口边界。 - -无界面或自动化环境仍可使用 `DEEPSEEK_API_KEY`、`OPENAI_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;设置页保存的本地密钥优先,环境变量仅在本地未保存对应 Credential ID 时作为回退。密钥不得写入仓库文件、README、Issue、提交信息或聊天记录。 - -### 终端二:启动前端 - -```powershell +# 终端二 cd frontend pnpm dev ``` -前端地址: +前端地址为 ,Vite 将 `/api` 和 `/health` 代理到 。后端提供健康检查 `/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.1,CPU 使用官方 CPU wheel,CUDA 使用 cu128 wheel;脚本不会安装或修改 NVIDIA 驱动。模型权重需要在设置页显式下载,不会在推理时自动下载。 + +## 模型提供商与凭据 + +在“设置 → 模型提供商”中选择预设或创建自定义提供商。API Key 只在前端提交期间存在,不写入 Pinia 或 `localStorage`;后端将密文和开发主密钥保存到已忽略的 `backend/data/credentials/`,Provider 配置只保存 Credential ID。 + +无界面环境可使用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_`。当前 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` 为准;规划能力必须在文档中明确标注。 diff --git a/backend/README.md b/backend/README.md index 9884ac7..9e33bd0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,34 +1,103 @@ -# Notes Agent Backend +# NotesAgent Backend -FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。 +NotesAgent Backend 是基于 Python 3.11+、FastAPI、Pydantic 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、声纹模型默认 CPU,CUDA 显式选装。操作系统级 Plugin 沙箱仍属于后续阶段。 +当前实现包含 Knowledge/Retrieval、Chat、Agent、Tool/Permission、Skill/Plugin、MCP、模型提供商、RAG Benchmark、多模态任务、本地模型调度、Token/音频用量和运行诊断。数据持久化位于后端 SQLite 与 Vault;Tauri 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`,无需手动激活环境。启动后可访问: - 健康检查: +- 服务状态: - API 文档: - OpenAPI: -运行回归测试: +## 核心模块 + +| 目录 | 职责 | +| --- | --- | +| `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.1,CUDA 使用 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_` 注入。开发环境使用 Fernet 密文存储,接口不返回明文;`plugin.*` 是 Plugin Settings 的保留凭据命名空间。 + +## 测试 ```powershell uv run pytest ``` -阶段 F 后端基线为 472 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。`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) +- [阶段 F:Embedding 与知识库问题](../docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md) -Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/development/Knowledge与Retrieval-Core开发说明.md`。 +机器可读接口以运行中的 `/openapi.json` 为准。 diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 10083ee..554f7ea 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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 diff --git a/backend/app/database/migrations.py b/backend/app/database/migrations.py index e6ff960..2bcb133 100644 --- a/backend/app/database/migrations.py +++ b/backend/app/database/migrations.py @@ -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); + """, ] diff --git a/backend/app/local_model_routes.py b/backend/app/local_model_routes.py index 90f090e..fd79d8c 100644 --- a/backend/app/local_model_routes.py +++ b/backend/app/local_model_routes.py @@ -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"} diff --git a/backend/app/local_models/components.py b/backend/app/local_models/components.py new file mode 100644 index 0000000..c908620 --- /dev/null +++ b/backend/app/local_models/components.py @@ -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' diff --git a/backend/app/local_models/manager.py b/backend/app/local_models/manager.py index 215d5f2..a106a87 100644 --- a/backend/app/local_models/manager.py +++ b/backend/app/local_models/manager.py @@ -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): diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py index f31e6ed..ed86af0 100644 --- a/backend/app/local_models/runtime.py +++ b/backend/app/local_models/runtime.py @@ -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) diff --git a/backend/app/local_models/worker.py b/backend/app/local_models/worker.py index 98a652f..e3b5d50 100644 --- a/backend/app/local_models/worker.py +++ b/backend/app/local_models/worker.py @@ -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")) diff --git a/backend/app/main.py b/backend/app/main.py index f7b97c6..d307982 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/media_routes.py b/backend/app/media_routes.py index 57582e1..221ffb1 100644 --- a/backend/app/media_routes.py +++ b/backend/app/media_routes.py @@ -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} diff --git a/backend/app/provider_preview_routes.py b/backend/app/provider_preview_routes.py index 5b24afa..f6f33f5 100644 --- a/backend/app/provider_preview_routes.py +++ b/backend/app/provider_preview_routes.py @@ -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 diff --git a/backend/app/providers/routing.py b/backend/app/providers/routing.py index dcd009e..d4630fe 100644 --- a/backend/app/providers/routing.py +++ b/backend/app/providers/routing.py @@ -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: diff --git a/backend/app/routes.py b/backend/app/routes.py index 642e97a..d4fc4db 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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") diff --git a/backend/app/services/chat_history.py b/backend/app/services/chat_history.py new file mode 100644 index 0000000..76ceedc --- /dev/null +++ b/backend/app/services/chat_history.py @@ -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), + ) diff --git a/backend/app/services/media_notes.py b/backend/app/services/media_notes.py index e2ec13e..cd3a04d 100644 --- a/backend/app/services/media_notes.py +++ b/backend/app/services/media_notes.py @@ -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 diff --git a/backend/app/services/model_diagnostics.py b/backend/app/services/model_diagnostics.py new file mode 100644 index 0000000..3accbaf --- /dev/null +++ b/backend/app/services/model_diagnostics.py @@ -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")] diff --git a/backend/app/services/note_service.py b/backend/app/services/note_service.py index a4f4eeb..efd9931 100644 --- a/backend/app/services/note_service.py +++ b/backend/app/services/note_service.py @@ -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 diff --git a/backend/app/services/transcription_service.py b/backend/app/services/transcription_service.py index 6a816f2..aef3d7c 100644 --- a/backend/app/services/transcription_service.py +++ b/backend/app/services/transcription_service.py @@ -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") diff --git a/backend/app/services/usage_service.py b/backend/app/services/usage_service.py index 097e209..fe13413 100644 --- a/backend/app/services/usage_service.py +++ b/backend/app/services/usage_service.py @@ -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=? AND started_at 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()) diff --git a/backend/tests/test_local_models.py b/backend/tests/test_local_models.py index 08e9ee7..bbd3a96 100644 --- a/backend/tests/test_local_models.py +++ b/backend/tests/test_local_models.py @@ -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' diff --git a/backend/tests/test_multimodal_finalization.py b/backend/tests/test_multimodal_finalization.py new file mode 100644 index 0000000..3ddc0b9 --- /dev/null +++ b/backend/tests/test_multimodal_finalization.py @@ -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 diff --git a/backend/tests/test_runtime_components.py b/backend/tests/test_runtime_components.py new file mode 100644 index 0000000..b0c7901 --- /dev/null +++ b/backend/tests/test_runtime_components.py @@ -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' diff --git a/docs/README.md b/docs/README.md index 7c4f180..056265c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index ce7ce33..e53323a 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.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 A8M,Granite 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 Server:FastAPI + 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-M3,Client B 使用另一种 Embedding Provider。服务器只同步 Markdown。Client B 收到文件后按照自己的 Embedding 配置生成向量,并写入本机 VectorStore。 +例如 Client A 使用本地 Bekko A8M,Client 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 Bridge(stdio 首版已实现) @@ -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 Trace;Provider/Extension Registry 当前仍为内存实现。 +目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引、搜索历史、Provider 配置、任务、多模态记录及 Agent Trace;MCP 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 Contract;Skill 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 Contract;Skill 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 与 Ollama,OpenAI 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-Compatible、OpenAI 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。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 38375e6..7a25c77 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -1573,3 +1573,19 @@ frontend/src/ ### Benchmark Embedding 运行归属(阶段 E 集成修复) `config_snapshot.local_embedding` 仅表示本地基线;`config_snapshot.embedding` 为 `{ "policy": "per_case", "details": "cases[].embedding" }`。报告与 CaseCompleted 事件的逐样本 `embedding` 包含实际 source(api/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),仍只返回隐藏正文的请求体。实际扩展字段是否被供应商接受,以推理响应为准。 diff --git a/docs/development/frontend-phase2-pr23-followup.md b/docs/development/frontend-phase2-pr23-followup.md new file mode 100644 index 0000000..f21f130 --- /dev/null +++ b/docs/development/frontend-phase2-pr23-followup.md @@ -0,0 +1,22 @@ +# Frontend phase2:PR #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 均生成正常内容。 + +未执行生产插件后端的端到端验收;本次不包含后端实现修改。 diff --git a/docs/development/多模态管线与模型运行开发说明.md b/docs/development/多模态管线与模型运行开发说明.md index 2af4461..d6175e5 100644 --- a/docs/development/多模态管线与模型运行开发说明.md +++ b/docs/development/多模态管线与模型运行开发说明.md @@ -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 成功及失败回退策略保持不变。 diff --git a/docs/development/阶段F收尾验收记录.md b/docs/development/阶段F收尾验收记录.md new file mode 100644 index 0000000..53141de --- /dev/null +++ b/docs/development/阶段F收尾验收记录.md @@ -0,0 +1,55 @@ +# 阶段 F 收尾验收记录 + +日期:2026-09-05。对应 `feat/multimodal-finalization`,基于 `6bdba2c`(阶段 F 主分支合并)。 + +## 完成范围 + +本轮补齐运行诊断持久化、真实磁盘占用、后台索引优先级、CUDA 设备失败时 CPU 单次重试、上传与任务重试幂等、跨修订安全更新笔记、音频用量分项,以及请求 JSON 导入/导出/重置和实际聊天推理验证。原有 API 优先、无配置/无效响应使用本地模型、local_only 禁止远程调用的流程继续保留。 + +具体行为见[开发说明](多模态管线与模型运行开发说明.md),接口见[开发版契约](../contracts/第二阶段接口契约-开发版.md),故障与修复见[问题记录 F-13~F-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 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。 diff --git a/docs/guides/代码注释与TODO约定.md b/docs/guides/代码注释与TODO约定.md index e285ec1..c5125d1 100644 --- a/docs/guides/代码注释与TODO约定.md +++ b/docs/guides/代码注释与TODO约定.md @@ -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 中引用代码位置,而不是在源码中记录长篇设计讨论。 diff --git a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md index 9314922..1d74740 100644 --- a/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md +++ b/docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md @@ -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,原先能够保存的笔记被拒绝;库中已有此类文件时全量重建也会失败。 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..794ca01 --- /dev/null +++ b/frontend/README.md @@ -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 +``` + +开发地址为 。Vite 将 `/api` 和 `/health` 转发到 ,因此联调前需要先启动后端。 + +## 页面与能力 + +| 路由 | 当前能力 | +| --- | --- | +| `/`、`/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 6;Markdown 展示使用 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` 为准。 diff --git a/frontend/package.json b/frontend/package.json index ab9bbab..76a1adf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2333cb3..9e942b1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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)) diff --git a/frontend/scripts/generate-language-icons.mjs b/frontend/scripts/generate-language-icons.mjs new file mode 100644 index 0000000..6eb6130 --- /dev/null +++ b/frontend/scripts/generate-language-icons.mjs @@ -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 = `${item.body}` + 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(', ')}`) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ea07804..12a64a5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { 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 } diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index dabcd25..820964e 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -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(null) interface Command { id: string; label: string; hint: string; run: () => void | Promise } const builtinCommands = computed(() => [ - { 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(() => [ @@ -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)) - -
+ +
diff --git a/frontend/src/features/mcp/configuration.ts b/frontend/src/features/mcp/configuration.ts index 4afbab7..a12aee4 100644 --- a/frontend/src/features/mcp/configuration.ts +++ b/frontend/src/features/mcp/configuration.ts @@ -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 { - 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 } 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 { 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 } 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('服务器名称必须为 1–80 个字符') + 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('服务器名称必须为 1–80 个字符', 'The server name must contain 1–80 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('密钥值必须为 1–32768 个字符') + if (!value || value.length > 32768) throw new Error(t('密钥值必须为 1–32768 个字符', 'Secret values must contain 1–32768 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 } diff --git a/frontend/src/features/media/MediaView.vue b/frontend/src/features/media/MediaView.vue index 23f66bb..a5846f0 100644 --- a/frontend/src/features/media/MediaView.vue +++ b/frontend/src/features/media/MediaView.vue @@ -1,14 +1,19 @@