From 844562303c234c6c9b49f13cc2ccc76820a5ceb4 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 1 Jun 2026 20:32:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E6=A0=88=20Logger=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=B3=A8=E5=85=A5=20=E2=80=94=20DB/Route/AI=20?= =?UTF-8?q?=E5=B1=82=E4=BC=A0=E5=8F=82=20+=20=E5=89=8D=E7=AB=AF=20Logger?= =?UTF-8?q?=20+=20=E6=B5=8B=E8=AF=95=E6=9B=B4=E6=96=B0=20+=20=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=20add-frontend-logger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/init-dev-branch.js | 260 ++++++------ eslint.config.js | 2 + .../add-frontend-logger/.openspec.yaml | 2 - .../changes/add-frontend-logger/design.md | 224 ---------- openspec/changes/add-frontend-logger/tasks.md | 45 -- src/server/ai/agents/alfred-agent.ts | 8 +- src/server/ai/registry.ts | 14 +- src/server/ai/tools/get-current-time.ts | 26 +- src/server/db/conversations.ts | 11 +- src/server/db/migrate.ts | 12 +- src/server/db/models.ts | 11 +- src/server/db/projects.ts | 23 +- src/server/db/providers.ts | 11 +- src/server/routes/chat/create.ts | 14 +- src/server/routes/chat/delete.ts | 6 +- src/server/routes/chat/get.ts | 3 +- src/server/routes/chat/list.ts | 3 +- src/server/routes/chat/messages.ts | 3 +- src/server/routes/chat/send.ts | 41 +- src/server/routes/chat/update.ts | 14 +- src/server/routes/meta.ts | 3 +- src/server/routes/models/create.ts | 17 +- src/server/routes/models/delete.ts | 6 +- src/server/routes/models/get.ts | 3 +- src/server/routes/models/list.ts | 3 +- src/server/routes/models/test.ts | 35 +- src/server/routes/models/update.ts | 14 +- src/server/routes/projects/archive.ts | 6 +- src/server/routes/projects/create.ts | 14 +- src/server/routes/projects/delete.ts | 6 +- src/server/routes/projects/get.ts | 3 +- src/server/routes/projects/list.ts | 3 +- src/server/routes/projects/restore.ts | 6 +- src/server/routes/projects/update.ts | 15 +- src/server/routes/providers/create.ts | 17 +- src/server/routes/providers/delete.ts | 6 +- src/server/routes/providers/get.ts | 3 +- src/server/routes/providers/list.ts | 3 +- src/server/routes/providers/options.ts | 3 +- src/server/routes/providers/test.ts | 37 +- src/server/routes/providers/update.ts | 14 +- src/server/server.ts | 54 +-- .../workbench/components/chat/ChatPanel.tsx | 92 ++-- src/web/hooks/use-conversations.ts | 37 +- src/web/hooks/use-models.ts | 11 +- src/web/hooks/use-projects.ts | 17 +- src/web/hooks/use-providers.ts | 11 +- src/web/main.tsx | 33 +- src/web/utils/api.ts | 38 +- tests/server/ai/registry.test.ts | 78 ++-- tests/server/db/models.test.ts | 119 ++++-- tests/server/db/projects.test.ts | 69 +-- tests/server/db/providers.test.ts | 115 +++-- tests/server/routes/chat.test.ts | 49 ++- tests/server/routes/models.test.ts | 46 +- tests/server/routes/projects.test.ts | 22 +- tests/server/routes/providers.test.ts | 48 ++- .../components/query-client-logging.test.tsx | 103 +++++ tests/web/hooks/on-success-logging.test.tsx | 400 ++++++++++++++++++ tests/web/utils/api.test.ts | 134 ++++++ 60 files changed, 1648 insertions(+), 778 deletions(-) delete mode 100644 openspec/changes/add-frontend-logger/.openspec.yaml delete mode 100644 openspec/changes/add-frontend-logger/design.md delete mode 100644 openspec/changes/add-frontend-logger/tasks.md create mode 100644 tests/web/components/query-client-logging.test.tsx create mode 100644 tests/web/hooks/on-success-logging.test.tsx create mode 100644 tests/web/utils/api.test.ts diff --git a/bin/init-dev-branch.js b/bin/init-dev-branch.js index 8bed64c..326fe2e 100644 --- a/bin/init-dev-branch.js +++ b/bin/init-dev-branch.js @@ -7,87 +7,9 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; -import { resolve, relative } from "node:path"; +import { relative, resolve } from "node:path"; import { createInterface } from "node:readline"; -function git(args, opts) { - return execFileSync("git", args, { encoding: "utf-8", stdio: "pipe", ...opts }); -} - -function getRootDir() { - try { - return resolve(git(["rev-parse", "--show-toplevel"]).trim()); - } catch { - console.error("错误: 不在 git 仓库中"); - process.exit(1); - } -} - -function fetchRemote() { - try { - git(["fetch", "--quiet"]); - } catch { - console.warn("警告: 无法获取远端信息,继续使用本地数据"); - } -} - -function listRemoteBranches() { - try { - return git(["branch", "-r"]) - .trim() - .split(/\r?\n/) - .map((l) => l.trim()) - .filter((l) => l && !l.includes(" -> ")); - } catch { - return []; - } -} - -function matchingRemoteBranches(name) { - return listRemoteBranches().filter((l) => l.endsWith(`/${name}`)); -} - -function localBranchExists(name) { - try { - git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`]); - return true; - } catch { - return false; - } -} - -function worktreeExists(worktreeDir) { - try { - const out = git(["worktree", "list"]); - const target = resolve(worktreeDir); - return out - .split(/\r?\n/) - .some((line) => { - const fields = line.trim().split(/\s+/); - return fields.length > 0 && resolve(fields[0]) === target; - }); - } catch { - return false; - } -} - -function shortBranchName(remoteRef) { - const idx = remoteRef.indexOf("/"); - return idx === -1 ? remoteRef : remoteRef.slice(idx + 1); -} - -function assertCanCreate(name, dir) { - if (existsSync(dir)) { - throw new Error(`工作区已存在于 ${dir}`); - } - if (worktreeExists(dir)) { - throw new Error(`工作区 '${name}' 已存在`); - } - if (localBranchExists(name)) { - throw new Error(`本地分支 '${name}' 已存在`); - } -} - function addWorktree(name, dir, base) { const args = ["worktree", "add", "-b", name, dir]; if (base) args.push(base); @@ -104,62 +26,39 @@ function ask(rl, prompt) { return new Promise((resolve) => rl.question(prompt, resolve)); } -async function selectFromList(items, prompt, allowCreate) { - if (items.length === 0) return null; - - console.log(prompt); - items.forEach((item, i) => console.log(` ${i + 1}\t${item}`)); - if (allowCreate) console.log(` ${items.length + 1}\t创建新分支`); - console.log(); - - const max = allowCreate ? items.length + 1 : items.length; - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - let cancelled = false; - rl.on("close", () => { cancelled = true; }); - - while (true) { - const raw = await ask(rl, `请选择 (1-${max}): `); - if (cancelled) { - rl.close(); - process.exit(1); - } - const n = Number.parseInt(raw, 10); - if (Number.isNaN(n) || n < 1 || n > max) { - console.log(`错误: 请输入 1-${max} 之间的数字`); - continue; - } - if (n <= items.length) { - const sel = items[n - 1]; - console.log(`已选择: ${sel}`); - rl.close(); - return sel; - } - rl.close(); - return null; +function assertCanCreate(name, dir) { + if (existsSync(dir)) { + throw new Error(`工作区已存在于 ${dir}`); + } + if (worktreeExists(dir)) { + throw new Error(`工作区 '${name}' 已存在`); + } + if (localBranchExists(name)) { + throw new Error(`本地分支 '${name}' 已存在`); } } -async function inputBranchName() { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - let cancelled = false; - rl.on("close", () => { cancelled = true; }); - - while (true) { - const name = (await ask(rl, "请输入新分支名称: ")).trim(); - if (cancelled) { - rl.close(); - process.exit(1); - } - if (name) { - rl.close(); - return name; - } - console.log("错误: 分支名称不能为空"); +function fetchRemote() { + try { + git(["fetch", "--quiet"]); + } catch { + console.warn("警告: 无法获取远端信息,继续使用本地数据"); } } +function getRootDir() { + try { + return resolve(git(["rev-parse", "--show-toplevel"]).trim()); + } catch { + console.error("错误: 不在 git 仓库中"); + process.exit(1); + } +} + +function git(args, opts) { + return execFileSync("git", args, { encoding: "utf-8", stdio: "pipe", ...opts }); +} + async function handleWithName(name, worktreesDir) { const dir = resolve(worktreesDir, name); @@ -218,6 +117,109 @@ async function handleWithoutName(worktreesDir) { } } +async function inputBranchName() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + let cancelled = false; + rl.on("close", () => { + cancelled = true; + }); + + while (true) { + const name = (await ask(rl, "请输入新分支名称: ")).trim(); + if (cancelled) { + rl.close(); + process.exit(1); + } + if (name) { + rl.close(); + return name; + } + console.log("错误: 分支名称不能为空"); + } +} + +function listRemoteBranches() { + try { + return git(["branch", "-r"]) + .trim() + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.includes(" -> ")); + } catch { + return []; + } +} + +function localBranchExists(name) { + try { + git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`]); + return true; + } catch { + return false; + } +} + +function matchingRemoteBranches(name) { + return listRemoteBranches().filter((l) => l.endsWith(`/${name}`)); +} + +async function selectFromList(items, prompt, allowCreate) { + if (items.length === 0) return null; + + console.log(prompt); + items.forEach((item, i) => console.log(` ${i + 1}\t${item}`)); + if (allowCreate) console.log(` ${items.length + 1}\t创建新分支`); + console.log(); + + const max = allowCreate ? items.length + 1 : items.length; + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + let cancelled = false; + rl.on("close", () => { + cancelled = true; + }); + + while (true) { + const raw = await ask(rl, `请选择 (1-${max}): `); + if (cancelled) { + rl.close(); + process.exit(1); + } + const n = Number.parseInt(raw, 10); + if (Number.isNaN(n) || n < 1 || n > max) { + console.log(`错误: 请输入 1-${max} 之间的数字`); + continue; + } + if (n <= items.length) { + const sel = items[n - 1]; + console.log(`已选择: ${sel}`); + rl.close(); + return sel; + } + rl.close(); + return null; + } +} + +function shortBranchName(remoteRef) { + const idx = remoteRef.indexOf("/"); + return idx === -1 ? remoteRef : remoteRef.slice(idx + 1); +} + +function worktreeExists(worktreeDir) { + try { + const out = git(["worktree", "list"]); + const target = resolve(worktreeDir); + return out.split(/\r?\n/).some((line) => { + const fields = line.trim().split(/\s+/); + return fields.length > 0 && resolve(fields[0]) === target; + }); + } catch { + return false; + } +} + process.on("SIGINT", () => process.exit(1)); async function main() { diff --git a/eslint.config.js b/eslint.config.js index 93022df..735e917 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,8 @@ export default tseslint.config( ".claude/**", ".codex/**", ".agents/**", + ".worktrees/**", + "bin/**", "bun.lock", "data/**", ], diff --git a/openspec/changes/add-frontend-logger/.openspec.yaml b/openspec/changes/add-frontend-logger/.openspec.yaml deleted file mode 100644 index 0d3d137..0000000 --- a/openspec/changes/add-frontend-logger/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: fast-drive -created: 2026-06-01 diff --git a/openspec/changes/add-frontend-logger/design.md b/openspec/changes/add-frontend-logger/design.md deleted file mode 100644 index 411a855..0000000 --- a/openspec/changes/add-frontend-logger/design.md +++ /dev/null @@ -1,224 +0,0 @@ -## 背景 - -当前项目后端通过 `src/server/logger.ts` 提供了统一的 `Logger` 接口和四种实现(PinoLoggerWrapper、ConsoleFallbackLogger、NoopLogger、MemoryLogger),通过依赖注入在运行时代码中使用,并通过 ESLint 的 `no-restricted-syntax` 规则禁止 `src/server/**/*.ts` 直接使用 `console.*`。 - -前端目前没有统一的日志处理。唯一的 console 使用在 `ErrorBoundary.tsx:22` 的 `console.error`,业务错误反馈通过各组件零散调用 `App.useApp().message.error()` 完成。ESLint 对前端 `console.*` 没有任何限制。 - -用户希望: -1. 构造统一的前端日志工具,镜像后端的接口设计和多实现模式 -2. 业务错误(warn 及以上)同时通过 antd notification 和 console 输出 -3. 避免前端调试日志散落为原始 `console.*` 调用 -4. ESLint 按前后端分区管理 console 限制 - -## 讨论记录 - -- **已确认结论**:自己封装前端 Logger,不引入第三方日志库(loglevel / consola 等)。理由:后端已有成熟的接口+多实现模式可直接镜像,核心逻辑不到 100 行,引入库属于过度工程 -- **用户偏好**: - - notification 仅展示 warn 及以上的业务错误信息,debug/info 不给用户看,写入文档作为开发引导 - - `child()` 作用域日志首批实现 - - `data` 参数不做 JSON 序列化,直接透传给 console 以保留 Error 堆栈 - - 生产环境(`import.meta.env.PROD`)屏蔽 warn 以下级别的日志输出 - - Logger 前缀 `[Alfred]` 硬编码在代码中 -- **约束**: - - 不新增依赖 - - 符合 `docs/development/frontend.md` 的前端规约 - - ESLint 保持单文件 + file glob 分区模式 -- **被否决方案**: - - 引入 pino 浏览器版:体积偏大(~15KB gzip),且 pino 的 JSON 结构化在浏览器 DevTools 中不如纯文本直观 - - 引入 loglevel:API 简洁但无原生 Sink 模式,无法优雅集成 antd notification - - 引入 consola:功能全但与后端 pino 风格不一致,增加理解成本 - -## 需求 - -| 需求 | 验收标准 | -| ---- | -------- | -| 统一的前端 Logger 接口 | `src/web/utils/logger.ts` 导出 `Logger` 接口,含 `debug/info/warn/error/child/setLevel` 方法 | -| Console 输出 | 所有级别日志均输出到 console(debug→log, info→log, warn→warn, error→error),带有 `[Alfred:LEVEL]` 前缀和可选附加数据 | -| antd notification 集成 | `useLogger()` hook 自动将 warn 映射为 `message.warning()`,error 映射为 `message.error()`;debug/info 不触发 notification | -| 生产环境静默 | `import.meta.env.PROD` 时 debug/info 不输出到 console 也不触发 notification | -| 作用域日志 | `child(bindings)` 返回带绑定的子 Logger,日志消息自动追加 `[key=value]` 后缀 | -| MemoryLogger 测试替身 | 提供 `createMemoryLogger()`,将日志条目存储在内存数组中以供测试断言 | -| NoopLogger 测试替身 | 提供 `createNoopLogger()`,静默丢弃所有日志 | -| ESLint 前端 console 禁止 | `eslint.config.js` 新增规则:`src/web/**/*.{ts,tsx}` 禁止 `console.*`,`logger.ts` 除外 | -| Error.data 透传 | `logger.error("失败", { error: someError })` 保留 Error 对象的堆栈信息,不做 JSON 序列化 | -| 前缀硬编码 | 日志前缀固定为 `[Alfred]`,不使用模块路径检测 | -| 开发文档补充 | `docs/development/frontend.md` 新增日志模块章节,明确 notification 仅用于 warn+error 的红线 | - -## 目标 / 非目标 - -**目标:** -- 建立与后端一致的 `Logger` 接口和实现模式 -- 提供 `useLogger()` React hook,在组件内自动集成 antd notification -- 通过 ESLint 强制前端使用统一 Logger,杜绝散落的 `console.*` -- 支持测试替身(MemoryLogger / NoopLogger)用于前端单元测试 - -**非目标:** -- 不引入任何第三方日志依赖 -- 不实现前端日志持久化到文件(浏览器无此能力) -- 不改造所有现有组件(渐进式迁移,先建工具再逐步替换) -- 不修改后端 Logger 接口或 ESLint 后端规则 -- 不影响 `message.success()` / `message.info()` 等业务成功反馈的调用方式 - -## 执行约束 - -- **依赖限制**:零新增依赖,纯 TypeScript + React hooks -- **约束**: - - 遵守 `docs/development/frontend.md` 全部规约 - - `useLogger()` 内部使用 `App.useApp()` 获取 antd message 实例 - - 仅 `warn` 和 `error` 级别调用 antd notification - - `data` 参数不加工,直接透传 - - 生产环境 `import.meta.env.PROD` 时 debug/info 静默 -- **质量门禁**: - - `bun test` 全部通过 - - `bun run lint` 零违规 - - `bun run typecheck` 零错误 -- **文档 / 沟通**: - - 更新 `docs/development/frontend.md`,新增"日志模块"章节 - - 新增的 ESLint 规则需在 ESLint 报错信息中包含明确的替代方案(`useLogger()` / `createConsoleLogger()`) - -## 影响范围 - -| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 | -| ---- | -------------------- | -------- | ---- | -| 核心新增 | `src/web/utils/logger.ts` | 新建,Logger 接口 + ConsoleLogger + DefaultLogger + NoopLogger + MemoryLogger + Sink 概念 | 约 150 行 | -| React 集成 | `src/web/hooks/use-logger.ts` | 新建,`useLogger()` hook | 约 30 行 | -| ESLint | `eslint.config.js` | 新增前端 console 禁止区块,排除 `logger.ts` | 约 15 行 | -| 错误边界 | `src/web/components/ErrorBoundary.tsx` | `console.error` 改为 `createConsoleLogger().error()` | 首处改造,只能使用 `createConsoleLogger()` 不可用 `useLogger()` | -| 错误边界测试 | `tests/web/components/ErrorBoundary.test.tsx` | 断言字符串从 `"渲染错误:"` 更新为 `"[Alfred:ERROR] 渲染错误"` | 适配新日志格式 | -| 开发文档 | `docs/development/frontend.md` | 新增"日志模块"章节 | 描述接口、用法、notification 红线 | -| 测试 | `tests/web/utils/logger.test.ts` | 新建,Logger 各实现的单元测试 | 覆盖 ConsoleSink(级别映射/PROD静默/child绑定/data透传)、AntdMessageSink(warn→warning/error→error/debug+info不触发)、MemoryLogger、NoopLogger、setLevel运行时调整、嵌套child追加和覆盖 | -| 测试 | `tests/web/hooks/use-logger.test.ts` | 新建,useLogger hook 单元测试 | 覆盖 antd notification 调用映射 | - -## 决策 - -| 决策 | 理由 | 已否决替代方案 | -| ---- | ---- | -------------- | -| 自己封装而非引入库 | 后端已有成熟模式可镜像,核心逻辑不足 100 行;引入库需额外审计和升级维护;项目原则不引入额外依赖 | pino(浏览器)/loglevel/consola | -| 裸对象透传而不做 JSON 序列化 | 保留 Error 的 `stack` 属性,DevTools 的 Error 对象交互(点击跳转源代码)是重要调试体验 | `JSON.stringify(data)` | -| `useLogger()` 内部使用 `App.useApp()` | 与项目 Ant Design 使用规范一致("需要 message 时在 ConfigProvider 内包裹 App,组件内通过 App.useApp() 获取") | 全局 message 实例 / 独立 Context | -| ESLint 保持单文件 + glob 分区 | 前后端共享 TS/import/perfectionist 规则,分文件产生重复;glob 已提供足够精细的作用域控制 | 拆分为 `eslint.server.js` 和 `eslint.web.js` | -| 生产环境屏蔽 warn 以下级别 | 减少生产环境的 console 噪音,debug 和 info 是面向开发者的调试日志 | 全部保留 / 全部去除 | -| notification 仅 warn + error | 避免过度弹提示干扰用户,debug/info 是开发者调试日志不应暴露给用户 | 全级别都走 notification | - -## 日志模块设计 - -### Logger 接口 - -```ts -type LogLevel = "debug" | "info" | "warn" | "error"; - -interface Logger { - debug(message: string, data?: unknown): void; - info(message: string, data?: unknown): void; - warn(message: string, data?: unknown): void; - error(message: string, data?: unknown): void; - child(bindings: Record): Logger; - setLevel(level: LogLevel): void; -} -``` - -`data` 为 `unknown`,不做 JSON 序列化,直接透传给 console,保留 Error 堆栈和 DevTools 交互能力。 - -### Sink 接口 - -```ts -interface Sink { - write(level: LogLevel, message: string, data: unknown, bindings: Record): void; -} -``` - -每个 Logger 实现持有零个或多个 Sink。DefaultLogger 默认持有 `ConsoleSink` + `AntdMessageSink`。 - -### ConsoleSink - -- 按级别映射:debug/info → `console.log`,warn → `console.warn`,error → `console.error` -- 格式:`[Alfred:LEVEL] message`,后跟 data(如有) -- `bindings` 追加为 ` [key=value][key=value]` 后缀 -- 生产环境(`isProduction=true`)时,debug/info 不输出 - -### AntdMessageSink - -- 接收 antd `MessageInstance`(来自 `App.useApp()`) -- warn → `message.warning(message)`,error → `message.error(message)` -- debug/info 不触发任何 notification -- `data` 和 `bindings` 不传递给 notification,仅传 `message` 字符串 - -### child() 绑定格式 - -```ts -const logger = useLogger().child({ page: "projects", providerId: "abc" }); -logger.warn("供应商删除失败"); -// Console: [Alfred:WARN] 供应商删除失败 [page=projects][providerId=abc] -// Notification: message.warning("供应商删除失败") -``` - -嵌套 child 追加绑定(同 key 覆盖)。 - -### 级别过滤 - -- Logger 构造函数接收 `isProduction: boolean` 参数 -- `createConsoleLogger()` 和 `useLogger()` 内部读取 `import.meta.env.PROD`,使生产环境默认屏蔽 debug/info -- `setLevel(level)` 允许运行时调整最小输出级别,覆盖 `isProduction` 的默认行为,用于开发调试和线上临时排查 - -### 四种实现 - -| 实现 | 构造函数 | Sink | 用途 | -| ---- | -------- | ---- | ---- | -| DefaultLogger | `createDefaultLogger(sinks, isProduction)` | 外部注入 | 生产/开发运行时的双流 Logger (内部类 BaseLogger) | -| ConsoleLogger | `createConsoleLogger()` | ConsoleSink | React 组件外(utils/hooks 纯函数)使用 | -| NoopLogger | `createNoopLogger()` | 无 | 测试静默 | -| MemoryLogger | `createMemoryLogger()` | 内存数组 `entries` | 测试断言,entries 类型 `{ level: LogLevel; message: string; data?: unknown }[]` | - -### 使用场景区分 - -``` -组件内 (有 App.useApp): - const logger = useLogger(); // Console + antd notification - -非组件 (utils / hooks 纯函数): - const logger = createConsoleLogger(); // 仅 console - -测试: - const logger = createMemoryLogger(); // 断言日志内容 - const logger = createNoopLogger(); // 静默丢弃 -``` - -**重要约束**:`useLogger()` 依赖 `App.useApp()`,仅可在 `` 子树内的组件中使用。`ErrorBoundary` 位于 `` 之上(见 `test-utils.tsx:84`),因此 ErrorBoundary 必须使用 `createConsoleLogger()`,不能使用 `useLogger()`。 - -## 执行计划 - -1. 创建 `src/web/utils/logger.ts`:定义 `Logger` 接口、`LogLevel` 类型、`Sink` 概念、`ConsoleSink`、`AntdMessageSink`、`BaseLogger`、`NoopLogger`、`MemoryLogger`、工厂函数 -2. 创建 `src/web/hooks/use-logger.ts`:`useLogger()` hook,组装 BaseLogger + ConsoleSink + AntdMessageSink -3. 修改 `eslint.config.js`:新增前端 `console.*` 禁止区块 -4. 改造 `src/web/components/ErrorBoundary.tsx`:使用 `createConsoleLogger()` -5. 创建 `tests/web/utils/logger.test.ts`:Logger 各实现的单元测试 -6. 创建 `tests/web/hooks/use-logger.test.ts`:useLogger hook 单元测试 -7. 更新 `docs/development/frontend.md`:新增"日志模块"章节 -8. 运行 `bun test && bun run lint && bun run typecheck` 验证 - -## 验证计划 - -| 需求 / 风险 | 验证方式 | -| ----------- | -------- | -| Logger 接口正确性 | `tests/web/utils/logger.test.ts` 覆盖 debug/info/warn/error 级别的 ConsoleLogger/MemoryLogger/NoopLogger | -| 级别过滤 | MemoryLogger 测试:`isProduction=true` 时 debug/info 不记录 | -| child() 作用域 | MemoryLogger 测试:child 绑定后日志包含对应键值 | -| notification 映射 | `tests/web/hooks/use-logger.test.ts`:warn→message.warning,error→message.error | -| notification 不映射 info | use-logger 测试:info 不调用 message 任何方法 | -| ESLint 规则生效 | 手动验证:在 `src/web/` 非 logger.ts 文件写入 `console.log` 触发 lint 报错 | -| 类型检查 | `bun run typecheck` 零错误 | -| Error 堆栈保留 | 手动验证:`logger.error("msg", { error: new Error("test") })` 在 DevTools 中可展开查看堆栈 | -| 文档完整性 | 检查 `docs/development/frontend.md` 包含日志模块章节,含 notification 红线说明 | - -## 风险 / 权衡 - -- [风险] `useLogger()` 依赖 `App.useApp()`,仅能在 `AntApp` 组件内部使用,不可在 utils 纯函数中调用 → 纯函数使用 `createConsoleLogger()`,文档明确说明两种使用场景 -- [风险] antd notification 的 `message` 实例在 React 组件外不可用 → 设计上 `useLogger()` 本身就是 React hook,外部场景用 ConsoleLogger 是预期的 -- [风险] 生产环境屏蔽 debug/info 可能导致线上排查困难 → 可在后续通过 URL 参数或配置开关临时开启,当前不实现以保持简单 -- [风险] 现有组件渐进式改造可能长期不完成 → ESLint 规则确保不再新增 `console.*` 调用,现有代码中的 `console.*`(仅 ErrorBoundary 一处)首批改造 - -## 待解决问题 - -| 状态 | 问题 | 所需决策 | -| ---- | ---- | -------- | -| 无 | 无待解决问题。 | 无需决策 | diff --git a/openspec/changes/add-frontend-logger/tasks.md b/openspec/changes/add-frontend-logger/tasks.md deleted file mode 100644 index 5cf9534..0000000 --- a/openspec/changes/add-frontend-logger/tasks.md +++ /dev/null @@ -1,45 +0,0 @@ -## 1. 上下文审查 - -- [x] 1.1 阅读 design.md,确认范围、需求、决策、执行约束和待解决问题 -- [x] 1.2 审查后端 `src/server/logger.ts` 的 Logger 接口和实现模式,确保前端镜像的一致性 -- [x] 1.3 审查 `eslint.config.js` 当前 rules 结构,确认新增前端 console 禁止规则的位置和写法 - -## 2. 核心日志模块 - -- [x] 2.1 新建 `src/web/utils/logger.ts`:定义 `LogLevel` 类型、`Logger` 接口(debug/info/warn/error/child/setLevel)、`Sink` 接口 -- [x] 2.2 实现 `ConsoleSink`:按级别映射到 console.log/warn/error,前缀 `[Alfred:LEVEL]`,data 透传不序列化 -- [x] 2.3 实现 `NoopLogger`:所有方法空实现,child 返回自身,静默丢弃日志 -- [x] 2.4 实现 `MemoryLogger`:将日志条目存入 `entries` 数组,用于测试断言 -- [x] 2.5 实现 `ConsoleLogger`:组合 ConsoleSink,生产环境(`import.meta.env.PROD`)屏蔽 debug/info -- [x] 2.6 实现 `AntdMessageSink`:接收 antd MessageInstance,warn→message.warning,error→message.error,debug/info 不触发 -- [x] 2.7 实现 `DefaultLogger`:组合多个 Sink(ConsoleSink + AntdMessageSink),支持 `child()` 作用域绑定 - -## 3. React 集成 - -- [x] 3.1 新建 `src/web/hooks/use-logger.ts`:`useLogger()` 内部调用 `App.useApp()` 获取 message 实例,组装 DefaultLogger + ConsoleSink + AntdMessageSink -- [x] 3.2 改造 `src/web/components/ErrorBoundary.tsx`:`console.error` 替换为 `createConsoleLogger().error()` - -## 4. ESLint 规则 - -- [x] 4.1 在 `eslint.config.js` 新增 `src/web/**/*.{ts,tsx}` 的 `no-restricted-syntax` 规则,禁止 `console.*`,排除 `logger.ts` -- [x] 4.2 运行 `bun run lint` 验证规则生效且无其他文件违规 - -## 5. 测试 - -- [x] 5.1 新建 `tests/web/utils/logger.test.ts`:测试 ConsoleLogger/MemoryLogger/NoopLogger 的 debug/info/warn/error 级别输出 -- [x] 5.2 测试生产环境静默:MemoryLogger 在 `isProduction=true` 时 debug/info 不记录 -- [x] 5.3 测试 `child()` 作用域:绑定后的 Logger 在日志消息中携带对应键值,嵌套 child 追加(同 key 覆盖) -- [x] 5.4 新建 `tests/web/hooks/use-logger.test.ts`:测试 useLogger 返回含 ConsoleSink 的 Logger 实例,warn/error 正常输出到 console(AntdMessageSink 映射在 logger.test.ts 的 AntdMessageSink describe 中测试) - -## 6. 文档 - -- [x] 6.1 更新 `docs/development/frontend.md`,新增"日志模块"章节:Logger 接口、使用方式、notification 红线(仅 warn+error) -- [x] 6.2 确认 `docs/development/frontend.md` 新增的日志模块章节包含 notification 红线、使用场景区分和 ErrorBoundary 特殊说明 - -## 7. 质量门禁 - -- [x] 7.1 运行 `bun test` 确保所有新增和已有测试通过 -- [x] 7.2 运行 `bun run typecheck` 确保零类型错误 -- [x] 7.3 运行 `bun run lint` 确保零 lint 违规 -- [ ] 7.4 手动验证:在 `src/web/` 非 logger.ts 文件临时写入 `console.log("test")` 确认 ESLint 报错 -- [ ] 7.5 手动验证:在浏览器 DevTools 中调用 `logger.error("msg", { error: new Error("test") })` 确认 Error 堆栈可展开 diff --git a/src/server/ai/agents/alfred-agent.ts b/src/server/ai/agents/alfred-agent.ts index b94ccfb..31460d5 100644 --- a/src/server/ai/agents/alfred-agent.ts +++ b/src/server/ai/agents/alfred-agent.ts @@ -1,6 +1,8 @@ import { type LanguageModel, stepCountIs, ToolLoopAgent } from "ai"; -import { getCurrentTime } from "../tools/get-current-time"; +import type { Logger } from "../../logger"; + +import { createGetCurrentTime } from "../tools/get-current-time"; const SYSTEM_PROMPT = `你是 Alfred,一个 AI 助手。 @@ -10,11 +12,11 @@ const SYSTEM_PROMPT = `你是 Alfred,一个 AI 助手。 - 给出结论时简洁直接,不要长篇铺垫 - 不确定的事明确说"不确定"`; -export function createAlfredAgent(model: LanguageModel) { +export function createAlfredAgent(model: LanguageModel, logger?: Logger) { return new ToolLoopAgent({ instructions: SYSTEM_PROMPT, model, stopWhen: stepCountIs(20), - tools: { getCurrentTime }, + tools: { getCurrentTime: createGetCurrentTime(logger) }, }); } diff --git a/src/server/ai/registry.ts b/src/server/ai/registry.ts index 44b8dfa..6c29201 100644 --- a/src/server/ai/registry.ts +++ b/src/server/ai/registry.ts @@ -5,6 +5,7 @@ import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createProviderRegistry, generateText } from "ai"; +import type { Logger } from "../logger"; import type { AIProviderConfig } from "./types"; export function buildProviderRegistry(db: Database) { @@ -25,6 +26,7 @@ export function buildProviderRegistry(db: Database) { export async function testModelConnection( config: AIProviderConfig & { modelId: string }, + logger: Logger, ): Promise<{ message: string; ok: boolean }> { try { const provider = createProvider(config); @@ -36,12 +38,16 @@ export async function testModelConnection( return { message: "模型连接成功", ok: true }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); + logger.warn({ error: msg, modelId: config.modelId, providerType: config.type }, "模型连接测试失败"); return { message: `模型连接失败:${msg}`, ok: false }; } } -export async function testProviderConnection(config: AIProviderConfig): Promise<{ message: string; ok: boolean }> { - const baseUrlResult = await probeBaseUrl(config.baseUrl); +export async function testProviderConnection( + config: AIProviderConfig, + logger: Logger, +): Promise<{ message: string; ok: boolean }> { + const baseUrlResult = await probeBaseUrl(config.baseUrl, logger); if (!baseUrlResult.ok) return baseUrlResult; const modelsUrl = buildModelsUrl(config.baseUrl); @@ -82,6 +88,7 @@ export async function testProviderConnection(config: AIProviderConfig): Promise< }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); + logger.warn({ error: msg, providerType: config.type }, "供应商 /models 请求异常"); return { message: `Base URL 可连接,但 /models 请求异常:${msg};可检查 URL 或忽略此提示。`, ok: true }; } } @@ -154,7 +161,7 @@ function getProviders(db: Database): Array<{ }>; } -async function probeBaseUrl(baseUrl: string): Promise<{ message: string; ok: boolean }> { +async function probeBaseUrl(baseUrl: string, logger: Logger): Promise<{ message: string; ok: boolean }> { try { await fetch(baseUrl, { method: "HEAD", @@ -163,6 +170,7 @@ async function probeBaseUrl(baseUrl: string): Promise<{ message: string; ok: boo return { message: "Base URL 可连接", ok: true }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); + logger.warn({ baseUrl, error: msg }, "Base URL 不可达"); return { message: `Base URL 不可达:${msg}`, ok: false }; } } diff --git a/src/server/ai/tools/get-current-time.ts b/src/server/ai/tools/get-current-time.ts index 3355836..4077a8f 100644 --- a/src/server/ai/tools/get-current-time.ts +++ b/src/server/ai/tools/get-current-time.ts @@ -1,7 +1,19 @@ import { tool } from "ai"; import { z } from "zod"; -export function formatCurrentTime(timezone?: string) { +import type { Logger } from "../../logger"; + +export function createGetCurrentTime(logger?: Logger) { + return tool({ + description: "获取当前日期和时间。可选指定时区,默认返回本地时间。", + execute: ({ timezone }) => Promise.resolve(formatCurrentTime(timezone, logger)), + inputSchema: z.object({ + timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"), + }), + }); +} + +export function formatCurrentTime(timezone?: string, logger?: Logger) { const now = new Date(); const iso = now.toISOString(); const timestamp = now.getTime(); @@ -14,7 +26,9 @@ export function formatCurrentTime(timezone?: string) { timeStyle: "long", timeZone: timezone, }).format(now); - } catch { + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger?.warn({ error: msg, timezone }, "无效时区,使用默认格式"); local = now.toString(); } } else { @@ -26,11 +40,3 @@ export function formatCurrentTime(timezone?: string) { return { iso, local, timestamp }; } - -export const getCurrentTime = tool({ - description: "获取当前日期和时间。可选指定时区,默认返回本地时间。", - execute: ({ timezone }) => Promise.resolve(formatCurrentTime(timezone)), - inputSchema: z.object({ - timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"), - }), -}); diff --git a/src/server/db/conversations.ts b/src/server/db/conversations.ts index bc8bd15..c7ae1fd 100644 --- a/src/server/db/conversations.ts +++ b/src/server/db/conversations.ts @@ -3,6 +3,7 @@ import type Database from "bun:sqlite"; import { desc, eq } from "drizzle-orm"; import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api"; +import type { Logger } from "../logger"; import { paginateQuery, wrap } from "./connection"; import { conversations, messages, models } from "./schema"; @@ -10,6 +11,7 @@ import { conversations, messages, models } from "./schema"; export function createConversation( raw: Database, projectId: string, + logger: Logger, defaultModelId?: string, ): { conversation: Conversation } | { error: string; status: number } { const db = wrap(raw); @@ -50,6 +52,7 @@ export function createMessage( parts?: string; role: "assistant" | "system" | "user"; }, + _logger: Logger, ): Message { const db = wrap(raw); const id = crypto.randomUUID(); @@ -78,6 +81,7 @@ export function createMessages( parts?: string; role: "assistant" | "system" | "user"; }>, + _logger: Logger, ): Message[] { const db = wrap(raw); const now = new Date().toISOString(); @@ -102,7 +106,11 @@ export function createMessages( return results; } -export function deleteConversation(raw: Database, id: string): { error: string; status: number } | { success: true } { +export function deleteConversation( + raw: Database, + id: string, + _logger: Logger, +): { error: string; status: number } | { success: true } { const db = wrap(raw); const existing = db.select().from(conversations).where(eq(conversations.id, id)).get(); if (!existing) return { error: "会话不存在", status: 404 }; @@ -154,6 +162,7 @@ export function updateConversation( raw: Database, id: string, data: UpdateConversationRequest, + _logger: Logger, ): { conversation: Conversation } | { error: string; status: number } { const db = wrap(raw); const existing = db.select().from(conversations).where(eq(conversations.id, id)).get(); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 189aaa1..c8108f0 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -35,9 +35,15 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD db.transaction(() => { for (const migration of pending) { - logger.info({ id: migration.id }, "执行 migration"); - db.exec(migration.sql); - insertApplied.run(migration.id, migration.checksum, new Date().toISOString()); + try { + logger.info({ id: migration.id }, "执行 migration"); + db.exec(migration.sql); + insertApplied.run(migration.id, migration.checksum, new Date().toISOString()); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ error: msg, id: migration.id }, "migration 执行失败"); + throw e; + } } })(); diff --git a/src/server/db/models.ts b/src/server/db/models.ts index a7a0f1c..12a3c5e 100644 --- a/src/server/db/models.ts +++ b/src/server/db/models.ts @@ -3,6 +3,7 @@ import type Database from "bun:sqlite"; import { desc, eq, like, or, sql } from "drizzle-orm"; import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api"; +import type { Logger } from "../logger"; import { paginateQuery, wrap } from "./connection"; import { models, providers } from "./schema"; @@ -10,6 +11,7 @@ import { models, providers } from "./schema"; export function createModel( raw: Database, request: CreateModelRequest, + logger: Logger, ): { error: string; status: number } | { model: Model } { const db = wrap(raw); @@ -49,6 +51,7 @@ export function createModel( if (msg.includes("UNIQUE constraint")) { return { error: "该供应商下模型 ID 已存在", status: 409 }; } + logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败"); throw e; } @@ -56,7 +59,11 @@ export function createModel( return { model: toModel(row!) }; } -export function deleteModel(raw: Database, id: string): { error: string; status: number } | { success: true } { +export function deleteModel( + raw: Database, + id: string, + _logger: Logger, +): { error: string; status: number } | { success: true } { const db = wrap(raw); const existing = db.select().from(models).where(eq(models.id, id)).get(); if (!existing) return { error: "模型不存在", status: 404 }; @@ -111,6 +118,7 @@ export function updateModel( raw: Database, id: string, request: UpdateModelRequest, + logger: Logger, ): { error: string; status: number } | { model: Model } { const db = wrap(raw); const existing = db.select().from(models).where(eq(models.id, id)).get(); @@ -164,6 +172,7 @@ export function updateModel( if (msg.includes("UNIQUE constraint")) { return { error: "该供应商下模型 ID 已存在", status: 409 }; } + logger.error({ error: msg, operation: "update", table: "models" }, "数据库操作失败"); throw e; } diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts index 8f05009..ecf7746 100644 --- a/src/server/db/projects.ts +++ b/src/server/db/projects.ts @@ -3,11 +3,16 @@ import type Database from "bun:sqlite"; import { desc, eq, like, or } from "drizzle-orm"; import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api"; +import type { Logger } from "../logger"; import { paginateQuery, wrap } from "./connection"; import { projects } from "./schema"; -export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { +export function archiveProject( + raw: Database, + id: string, + _logger: Logger, +): { error: string; status: number } | { project: Project } { const db = wrap(raw); const existing = db.select().from(projects).where(eq(projects.id, id)).get(); if (!existing) return { error: "项目不存在", status: 404 }; @@ -23,6 +28,7 @@ export function archiveProject(raw: Database, id: string): { error: string; stat export function createProject( raw: Database, request: CreateProjectRequest, + logger: Logger, ): { error: string; status: number } | { project: Project } { const db = wrap(raw); const name = request.name.trim(); @@ -50,6 +56,7 @@ export function createProject( if (msg.includes("UNIQUE constraint")) { return { error: "项目名称已存在", status: 409 }; } + logger.error({ error: msg, operation: "create", table: "projects" }, "数据库操作失败"); throw e; } @@ -57,7 +64,11 @@ export function createProject( return { project: toProject(row!) }; } -export function deleteProject(raw: Database, id: string): { error: string; status: number } | { success: true } { +export function deleteProject( + raw: Database, + id: string, + _logger: Logger, +): { error: string; status: number } | { success: true } { const db = wrap(raw); const existing = db.select().from(projects).where(eq(projects.id, id)).get(); if (!existing) return { error: "项目不存在", status: 404 }; @@ -99,7 +110,11 @@ export function listProjects( }); } -export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { +export function restoreProject( + raw: Database, + id: string, + _logger: Logger, +): { error: string; status: number } | { project: Project } { const db = wrap(raw); const existing = db.select().from(projects).where(eq(projects.id, id)).get(); if (!existing) return { error: "项目不存在", status: 404 }; @@ -116,6 +131,7 @@ export function updateProject( raw: Database, id: string, request: UpdateProjectRequest, + logger: Logger, ): { error: string; status: number } | { project: Project } { const db = wrap(raw); const existing = db.select().from(projects).where(eq(projects.id, id)).get(); @@ -150,6 +166,7 @@ export function updateProject( if (msg.includes("UNIQUE constraint")) { return { error: "项目名称已存在", status: 409 }; } + logger.error({ error: msg, operation: "update", table: "projects" }, "数据库操作失败"); throw e; } diff --git a/src/server/db/providers.ts b/src/server/db/providers.ts index 539d637..f3381de 100644 --- a/src/server/db/providers.ts +++ b/src/server/db/providers.ts @@ -3,6 +3,7 @@ import type Database from "bun:sqlite"; import { desc, eq, like } from "drizzle-orm"; import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api"; +import type { Logger } from "../logger"; import { paginateQuery, wrap } from "./connection"; import { providers } from "./schema"; @@ -10,6 +11,7 @@ import { providers } from "./schema"; export function createProvider( raw: Database, request: CreateProviderRequest, + logger: Logger, ): { error: string; status: number } | { provider: Provider } { const db = wrap(raw); const name = request.name.trim(); @@ -41,6 +43,7 @@ export function createProvider( if (msg.includes("UNIQUE constraint")) { return { error: "供应商名称已存在", status: 409 }; } + logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败"); throw e; } @@ -48,7 +51,11 @@ export function createProvider( return { provider: toProvider(row!) }; } -export function deleteProvider(raw: Database, id: string): { error: string; status: number } | { success: true } { +export function deleteProvider( + raw: Database, + id: string, + _logger: Logger, +): { error: string; status: number } | { success: true } { const db = wrap(raw); const existing = db.select().from(providers).where(eq(providers.id, id)).get(); if (!existing) return { error: "供应商不存在", status: 404 }; @@ -100,6 +107,7 @@ export function updateProvider( raw: Database, id: string, request: UpdateProviderRequest, + logger: Logger, ): { error: string; status: number } | { provider: Provider } { const db = wrap(raw); const existing = db.select().from(providers).where(eq(providers.id, id)).get(); @@ -142,6 +150,7 @@ export function updateProvider( if (msg.includes("UNIQUE constraint")) { return { error: "供应商名称已存在", status: 409 }; } + logger.error({ error: msg, operation: "update", table: "providers" }, "数据库操作失败"); throw e; } diff --git a/src/server/routes/chat/create.ts b/src/server/routes/chat/create.ts index a018d09..c9b6665 100644 --- a/src/server/routes/chat/create.ts +++ b/src/server/routes/chat/create.ts @@ -1,12 +1,18 @@ import type Database from "bun:sqlite"; import type { CreateConversationRequest, RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { createConversation } from "../../db/conversations"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export async function handleCreateConversation(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleCreateConversation( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { const url = new URL(req.url); const projectId = url.pathname.split("/")[3]; @@ -16,14 +22,16 @@ export async function handleCreateConversation(req: Request, db: Database, mode: let body: CreateConversationRequest = {}; try { body = (await req.json()) as CreateConversationRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); // empty body is ok, defaults will be used } - const result = createConversation(db, validated.id, body.modelId); + const result = createConversation(db, validated.id, logger, body.modelId); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ conversationId: result.conversation.id, projectId: validated.id }, "会话创建成功"); return jsonResponse({ conversation: result.conversation }, { mode, status: 201 }); } diff --git a/src/server/routes/chat/delete.ts b/src/server/routes/chat/delete.ts index b9c141d..ef874f3 100644 --- a/src/server/routes/chat/delete.ts +++ b/src/server/routes/chat/delete.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { deleteConversation, getConversation } from "../../db/conversations"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleDeleteConversation(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleDeleteConversation(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { const parts = new URL(req.url).pathname.split("/"); const projectId = parts[3]; const conversationId = parts[5]; @@ -26,10 +27,11 @@ export function handleDeleteConversation(req: Request, db: Database, mode: Runti return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 }); } - const result = deleteConversation(db, validatedConv.id); + const result = deleteConversation(db, validatedConv.id, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ conversationId: validatedConv.id }, "会话删除成功"); return jsonResponse({ success: true }, { mode }); } diff --git a/src/server/routes/chat/get.ts b/src/server/routes/chat/get.ts index 73c94f9..1b3c542 100644 --- a/src/server/routes/chat/get.ts +++ b/src/server/routes/chat/get.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getConversation } from "../../db/conversations"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleGetConversation(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleGetConversation(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const parts = new URL(req.url).pathname.split("/"); const projectId = parts[3]; const conversationId = parts[5]; diff --git a/src/server/routes/chat/list.ts b/src/server/routes/chat/list.ts index c18f210..9d3542a 100644 --- a/src/server/routes/chat/list.ts +++ b/src/server/routes/chat/list.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { listConversations } from "../../db/conversations"; import { jsonResponse } from "../../helpers"; import { validateIdParam, validatePagination } from "../../middleware"; -export function handleListConversations(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleListConversations(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const projectId = url.pathname.split("/")[3]; diff --git a/src/server/routes/chat/messages.ts b/src/server/routes/chat/messages.ts index e918eea..7fdb24e 100644 --- a/src/server/routes/chat/messages.ts +++ b/src/server/routes/chat/messages.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getConversation, listMessages } from "../../db/conversations"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam, validatePagination } from "../../middleware"; -export function handleListMessages(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleListMessages(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const parts = new URL(req.url).pathname.split("/"); const projectId = parts[3]; const conversationId = parts[5]; diff --git a/src/server/routes/chat/send.ts b/src/server/routes/chat/send.ts index 527d705..588f236 100644 --- a/src/server/routes/chat/send.ts +++ b/src/server/routes/chat/send.ts @@ -29,7 +29,8 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo let body: { conversationId?: string; messages?: UIMessage[] }; try { body = (await req.json()) as typeof body; - } catch { + } catch (e) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -64,12 +65,16 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo ?.filter((p) => p.type === "text") .map((p) => p.text) .join("") ?? ""; - createMessage(db, { - content, - conversationId: conversation.id, - parts: JSON.stringify(lastMsg.parts ?? []), - role: "user", - }); + createMessage( + db, + { + content, + conversationId: conversation.id, + parts: JSON.stringify(lastMsg.parts ?? []), + role: "user", + }, + logger, + ); } updateConversationTimestamp(db, conversation.id); @@ -114,12 +119,16 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo .filter((p): p is { text: string; type: "text" } => p.type === "text") .map((p) => p.text) .join(""); - createMessage(db, { - content: text, - conversationId: conversation.id, - parts: JSON.stringify(responseMessage.parts), - role: "assistant", - }); + createMessage( + db, + { + content: text, + conversationId: conversation.id, + parts: JSON.stringify(responseMessage.parts), + role: "assistant", + }, + logger, + ); updateConversationTimestamp(db, conversation.id); }, uiMessages: body.messages, @@ -138,7 +147,7 @@ function generateConversationTitle( logger: Logger, ): void { if (firstUserText.length <= 5) { - updateConversation(db, conversationId, { title: firstUserText }); + updateConversation(db, conversationId, { title: firstUserText }, logger); return; } @@ -149,13 +158,13 @@ function generateConversationTitle( }) .then((result) => { const title = result.text.trim().slice(0, 10); - updateConversation(db, conversationId, { title: title || firstUserText.slice(0, 10) }); + updateConversation(db, conversationId, { title: title || firstUserText.slice(0, 10) }, logger); }) .catch((titleError: unknown) => { const titleMsg = titleError instanceof Error ? titleError.message : String(titleError); logger.error({ conversationId, error: titleMsg }, "标题生成失败"); try { - updateConversation(db, conversationId, { title: firstUserText.slice(0, 10) }); + updateConversation(db, conversationId, { title: firstUserText.slice(0, 10) }, logger); } catch { logger.error({ conversationId }, "标题兜底更新失败"); } diff --git a/src/server/routes/chat/update.ts b/src/server/routes/chat/update.ts index c97e318..709c539 100644 --- a/src/server/routes/chat/update.ts +++ b/src/server/routes/chat/update.ts @@ -1,12 +1,18 @@ import type Database from "bun:sqlite"; import type { RuntimeMode, UpdateConversationRequest } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getConversation, updateConversation } from "../../db/conversations"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export async function handleUpdateConversation(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleUpdateConversation( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { const url = new URL(req.url); const parts = url.pathname.split("/"); const projectId = parts[3]; @@ -30,7 +36,8 @@ export async function handleUpdateConversation(req: Request, db: Database, mode: let body: UpdateConversationRequest; try { body = (await req.json()) as UpdateConversationRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -38,10 +45,11 @@ export async function handleUpdateConversation(req: Request, db: Database, mode: return jsonResponse(createApiError("至少需要传 modelId 或 title", 400), { mode, status: 400 }); } - const result = updateConversation(db, validatedConv.id, body); + const result = updateConversation(db, validatedConv.id, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ conversationId: result.conversation.id }, "会话更新成功"); return jsonResponse({ conversation: result.conversation }, { mode }); } diff --git a/src/server/routes/meta.ts b/src/server/routes/meta.ts index 8a59345..964317d 100644 --- a/src/server/routes/meta.ts +++ b/src/server/routes/meta.ts @@ -1,7 +1,8 @@ import type { RuntimeMode } from "../../shared/api"; +import type { Logger } from "../logger"; import { createMetaResponse, jsonResponse } from "../helpers"; -export function handleMeta(mode: RuntimeMode, version: string): Response { +export function handleMeta(mode: RuntimeMode, version: string, _logger: Logger): Response { return jsonResponse(createMetaResponse(version), { mode }); } diff --git a/src/server/routes/models/create.ts b/src/server/routes/models/create.ts index a9e1c6d..35bbe6e 100644 --- a/src/server/routes/models/create.ts +++ b/src/server/routes/models/create.ts @@ -1,16 +1,23 @@ import type Database from "bun:sqlite"; import type { CreateModelRequest, RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { MODEL_CAPABILITIES } from "../../../shared/api"; import { createModel } from "../../db/models"; import { createApiError, jsonResponse } from "../../helpers"; -export async function handleCreateModel(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleCreateModel( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { let body: CreateModelRequest; try { body = (await req.json()) as CreateModelRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -44,11 +51,15 @@ export async function handleCreateModel(req: Request, db: Database, mode: Runtim const tokenError = validateOptionalPositiveInteger("maxOutputTokens", body.maxOutputTokens); if (tokenError) return jsonResponse(createApiError(tokenError, 400), { mode, status: 400 }); - const result = createModel(db, body); + const result = createModel(db, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info( + { modelId: result.model.id, name: result.model.name, providerId: result.model.providerId }, + "模型创建成功", + ); return jsonResponse(result, { mode, status: 201 }); } diff --git a/src/server/routes/models/delete.ts b/src/server/routes/models/delete.ts index e090850..31e4d7f 100644 --- a/src/server/routes/models/delete.ts +++ b/src/server/routes/models/delete.ts @@ -1,22 +1,24 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { deleteModel } from "../../db/models"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleDeleteModel(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleDeleteModel(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; const validated = validateIdParam(idStr ?? "", mode); if (validated instanceof Response) return validated; - const result = deleteModel(db, validated.id); + const result = deleteModel(db, validated.id, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ modelId: validated.id }, "模型删除成功"); return new Response(null, { status: 204 }); } diff --git a/src/server/routes/models/get.ts b/src/server/routes/models/get.ts index 8d11e0b..e70f2f3 100644 --- a/src/server/routes/models/get.ts +++ b/src/server/routes/models/get.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getModel } from "../../db/models"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleGetModel(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleGetModel(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; diff --git a/src/server/routes/models/list.ts b/src/server/routes/models/list.ts index 8ff6737..7c1958c 100644 --- a/src/server/routes/models/list.ts +++ b/src/server/routes/models/list.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { listModels } from "../../db/models"; import { jsonResponse } from "../../helpers"; import { validatePagination } from "../../middleware"; -export function handleListModels(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const pageParam = url.searchParams.get("page"); const pageSizeParam = url.searchParams.get("pageSize"); diff --git a/src/server/routes/models/test.ts b/src/server/routes/models/test.ts index 49e0eff..a80cb48 100644 --- a/src/server/routes/models/test.ts +++ b/src/server/routes/models/test.ts @@ -1,16 +1,23 @@ import type Database from "bun:sqlite"; import type { RuntimeMode, TestModelRequest } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { testModelConnection } from "../../ai/registry"; import { getProvider } from "../../db/providers"; import { createApiError, jsonResponse } from "../../helpers"; -export async function handleTestModelConfig(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleTestModelConfig( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { let body: TestModelRequest; try { body = (await req.json()) as TestModelRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -30,13 +37,23 @@ export async function handleTestModelConfig(req: Request, db: Database, mode: Ru }); } - const testResult = await testModelConnection({ - apiKey: providerResult.provider.apiKey, - baseUrl: providerResult.provider.baseUrl, - modelId: body.modelId, - name: providerResult.provider.name, - type: providerResult.provider.type, - }); + const testResult = await testModelConnection( + { + apiKey: providerResult.provider.apiKey, + baseUrl: providerResult.provider.baseUrl, + modelId: body.modelId, + name: providerResult.provider.name, + type: providerResult.provider.type, + }, + logger, + ); + + if (!testResult.ok) { + logger.warn( + { message: testResult.message, modelId: body.modelId, providerId: body.providerId }, + "模型连接测试失败", + ); + } return jsonResponse({ modelTestResponse: testResult }, { mode }); } diff --git a/src/server/routes/models/update.ts b/src/server/routes/models/update.ts index be0a90c..93876cf 100644 --- a/src/server/routes/models/update.ts +++ b/src/server/routes/models/update.ts @@ -1,13 +1,19 @@ import type Database from "bun:sqlite"; import type { RuntimeMode, UpdateModelRequest } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { MODEL_CAPABILITIES } from "../../../shared/api"; import { updateModel } from "../../db/models"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export async function handleUpdateModel(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleUpdateModel( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; @@ -17,7 +23,8 @@ export async function handleUpdateModel(req: Request, db: Database, mode: Runtim let body: UpdateModelRequest; try { body = (await req.json()) as UpdateModelRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -40,11 +47,12 @@ export async function handleUpdateModel(req: Request, db: Database, mode: Runtim const tokenError = validateOptionalPositiveInteger("maxOutputTokens", body.maxOutputTokens); if (tokenError) return jsonResponse(createApiError(tokenError, 400), { mode, status: 400 }); - const result = updateModel(db, validated.id, body); + const result = updateModel(db, validated.id, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ modelId: result.model.id }, "模型更新成功"); return jsonResponse(result, { mode }); } diff --git a/src/server/routes/projects/archive.ts b/src/server/routes/projects/archive.ts index 6cd8998..03b22d7 100644 --- a/src/server/routes/projects/archive.ts +++ b/src/server/routes/projects/archive.ts @@ -1,22 +1,24 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { archiveProject } from "../../db/projects"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; const validated = validateIdParam(idStr ?? "", mode); if (validated instanceof Response) return validated; - const result = archiveProject(db, validated.id); + const result = archiveProject(db, validated.id, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ projectId: validated.id }, "项目归档成功"); return jsonResponse(result, { mode }); } diff --git a/src/server/routes/projects/create.ts b/src/server/routes/projects/create.ts index b24f106..871b87d 100644 --- a/src/server/routes/projects/create.ts +++ b/src/server/routes/projects/create.ts @@ -1,15 +1,22 @@ import type Database from "bun:sqlite"; import type { CreateProjectRequest, RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { createProject } from "../../db/projects"; import { createApiError, jsonResponse } from "../../helpers"; -export async function handleCreateProject(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleCreateProject( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { let body: CreateProjectRequest; try { body = (await req.json()) as CreateProjectRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -17,10 +24,11 @@ export async function handleCreateProject(req: Request, db: Database, mode: Runt return jsonResponse(createApiError("name is required", 400), { mode, status: 400 }); } - const result = createProject(db, body); + const result = createProject(db, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ name: result.project.name, projectId: result.project.id }, "项目创建成功"); return jsonResponse(result, { mode, status: 201 }); } diff --git a/src/server/routes/projects/delete.ts b/src/server/routes/projects/delete.ts index 641ce15..ae830de 100644 --- a/src/server/routes/projects/delete.ts +++ b/src/server/routes/projects/delete.ts @@ -1,22 +1,24 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { deleteProject } from "../../db/projects"; import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { const url = new URL(req.url); const idStr = parseIdFromUrl(url); const validated = validateIdParam(idStr ?? "", mode); if (validated instanceof Response) return validated; - const result = deleteProject(db, validated.id); + const result = deleteProject(db, validated.id, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ projectId: validated.id }, "项目删除成功"); return new Response(null, { status: 204 }); } diff --git a/src/server/routes/projects/get.ts b/src/server/routes/projects/get.ts index 4bc857b..1bfb5cb 100644 --- a/src/server/routes/projects/get.ts +++ b/src/server/routes/projects/get.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getProject } from "../../db/projects"; import { jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleGetProject(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleGetProject(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; diff --git a/src/server/routes/projects/list.ts b/src/server/routes/projects/list.ts index 0759bff..d63b3c1 100644 --- a/src/server/routes/projects/list.ts +++ b/src/server/routes/projects/list.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { listProjects } from "../../db/projects"; import { createApiError, jsonResponse } from "../../helpers"; import { validatePagination } from "../../middleware"; -export function handleListProjects(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const pageParam = url.searchParams.get("page"); const pageSizeParam = url.searchParams.get("pageSize"); diff --git a/src/server/routes/projects/restore.ts b/src/server/routes/projects/restore.ts index 40b2ce6..4baa781 100644 --- a/src/server/routes/projects/restore.ts +++ b/src/server/routes/projects/restore.ts @@ -1,22 +1,24 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { restoreProject } from "../../db/projects"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; const validated = validateIdParam(idStr ?? "", mode); if (validated instanceof Response) return validated; - const result = restoreProject(db, validated.id); + const result = restoreProject(db, validated.id, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ projectId: validated.id }, "项目恢复成功"); return jsonResponse(result, { mode }); } diff --git a/src/server/routes/projects/update.ts b/src/server/routes/projects/update.ts index 2099ac4..f90d759 100644 --- a/src/server/routes/projects/update.ts +++ b/src/server/routes/projects/update.ts @@ -1,12 +1,18 @@ import type Database from "bun:sqlite"; import type { RuntimeMode, UpdateProjectRequest } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { updateProject } from "../../db/projects"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export async function handleUpdateProject(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleUpdateProject( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; @@ -16,7 +22,9 @@ export async function handleUpdateProject(req: Request, db: Database, mode: Runt let body: UpdateProjectRequest; try { body = (await req.json()) as UpdateProjectRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); + return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -24,10 +32,11 @@ export async function handleUpdateProject(req: Request, db: Database, mode: Runt return jsonResponse(createApiError("At least one of name or description is required", 400), { mode, status: 400 }); } - const result = updateProject(db, validated.id, body); + const result = updateProject(db, validated.id, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ projectId: result.project.id }, "项目更新成功"); return jsonResponse(result, { mode }); } diff --git a/src/server/routes/providers/create.ts b/src/server/routes/providers/create.ts index 5b4a62b..7aa4ced 100644 --- a/src/server/routes/providers/create.ts +++ b/src/server/routes/providers/create.ts @@ -1,15 +1,22 @@ import type Database from "bun:sqlite"; import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { createProvider } from "../../db/providers"; import { createApiError, jsonResponse } from "../../helpers"; -export async function handleCreateProvider(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleCreateProvider( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { let body: CreateProviderRequest; try { body = (await req.json()) as CreateProviderRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -32,10 +39,14 @@ export async function handleCreateProvider(req: Request, db: Database, mode: Run }); } - const result = createProvider(db, body); + const result = createProvider(db, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info( + { name: result.provider.name, providerId: result.provider.id, type: result.provider.type }, + "供应商创建成功", + ); return jsonResponse(result, { mode, status: 201 }); } diff --git a/src/server/routes/providers/delete.ts b/src/server/routes/providers/delete.ts index 21af85d..d155185 100644 --- a/src/server/routes/providers/delete.ts +++ b/src/server/routes/providers/delete.ts @@ -1,13 +1,14 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getModelsByProviderId } from "../../db/models"; import { deleteProvider } from "../../db/providers"; import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { const url = new URL(req.url); const idStr = parseIdFromUrl(url); @@ -19,10 +20,11 @@ export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMo return jsonResponse(createApiError("该供应商下存在模型,无法删除", 409), { mode, status: 409 }); } - const result = deleteProvider(db, validated.id); + const result = deleteProvider(db, validated.id, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ providerId: validated.id }, "供应商删除成功"); return new Response(null, { status: 204 }); } diff --git a/src/server/routes/providers/get.ts b/src/server/routes/providers/get.ts index 019a258..901e79d 100644 --- a/src/server/routes/providers/get.ts +++ b/src/server/routes/providers/get.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { getProvider } from "../../db/providers"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export function handleGetProvider(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleGetProvider(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; diff --git a/src/server/routes/providers/list.ts b/src/server/routes/providers/list.ts index a3e685e..7dbeefb 100644 --- a/src/server/routes/providers/list.ts +++ b/src/server/routes/providers/list.ts @@ -1,12 +1,13 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { listProviders } from "../../db/providers"; import { jsonResponse } from "../../helpers"; import { validatePagination } from "../../middleware"; -export function handleListProviders(req: Request, db: Database, mode: RuntimeMode): Response { +export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { const url = new URL(req.url); const pageParam = url.searchParams.get("page"); const pageSizeParam = url.searchParams.get("pageSize"); diff --git a/src/server/routes/providers/options.ts b/src/server/routes/providers/options.ts index a2a9cae..f4381d8 100644 --- a/src/server/routes/providers/options.ts +++ b/src/server/routes/providers/options.ts @@ -1,10 +1,11 @@ import type Database from "bun:sqlite"; import type { RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { listProviderOptions } from "../../db/providers"; import { jsonResponse } from "../../helpers"; -export function handleListProviderOptions(db: Database, mode: RuntimeMode): Response { +export function handleListProviderOptions(db: Database, mode: RuntimeMode, _logger: Logger): Response { return jsonResponse({ items: listProviderOptions(db) }, { mode }); } diff --git a/src/server/routes/providers/test.ts b/src/server/routes/providers/test.ts index 68a2724..63d8833 100644 --- a/src/server/routes/providers/test.ts +++ b/src/server/routes/providers/test.ts @@ -1,29 +1,46 @@ import type Database from "bun:sqlite"; import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { testProviderConnection } from "../../ai/registry"; import { createApiError, jsonResponse } from "../../helpers"; -export async function handleTestProviderConfig(req: Request, _db: Database, mode: RuntimeMode): Promise { - const validated = await readProviderConfig(req, mode); +export async function handleTestProviderConfig( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { + const validated = await readProviderConfig(req, mode, logger); if (validated instanceof Response) return validated; - const testResult = await testProviderConnection({ - apiKey: validated.apiKey, - baseUrl: validated.baseUrl, - name: validated.name, - type: validated.type, - }); + const testResult = await testProviderConnection( + { + apiKey: validated.apiKey, + baseUrl: validated.baseUrl, + name: validated.name, + type: validated.type, + }, + logger, + ); + if (!testResult.ok) { + logger.warn({ message: testResult.message, name: validated.name, type: validated.type }, "供应商连接测试失败"); + } return jsonResponse({ providerTestResponse: testResult }, { mode }); } -async function readProviderConfig(req: Request, mode: RuntimeMode): Promise { +async function readProviderConfig( + req: Request, + mode: RuntimeMode, + logger: Logger, +): Promise { let body: CreateProviderRequest; try { body = (await req.json()) as CreateProviderRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } diff --git a/src/server/routes/providers/update.ts b/src/server/routes/providers/update.ts index 24fe441..4d7c94e 100644 --- a/src/server/routes/providers/update.ts +++ b/src/server/routes/providers/update.ts @@ -1,12 +1,18 @@ import type Database from "bun:sqlite"; import type { RuntimeMode, UpdateProviderRequest } from "../../../shared/api"; +import type { Logger } from "../../logger"; import { updateProvider } from "../../db/providers"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; -export async function handleUpdateProvider(req: Request, db: Database, mode: RuntimeMode): Promise { +export async function handleUpdateProvider( + req: Request, + db: Database, + mode: RuntimeMode, + logger: Logger, +): Promise { const url = new URL(req.url); const idStr = url.pathname.split("/")[3]; @@ -16,7 +22,8 @@ export async function handleUpdateProvider(req: Request, db: Database, mode: Run let body: UpdateProviderRequest; try { body = (await req.json()) as UpdateProviderRequest; - } catch { + } catch (e: unknown) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败"); return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); } @@ -27,10 +34,11 @@ export async function handleUpdateProvider(req: Request, db: Database, mode: Run }); } - const result = updateProvider(db, validated.id, body); + const result = updateProvider(db, validated.id, body, logger); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } + logger.info({ providerId: result.provider.id }, "供应商更新成功"); return jsonResponse(result, { mode }); } diff --git a/src/server/server.ts b/src/server/server.ts index 37e6c9b..5252808 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -42,7 +42,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async () => { const resolvedVersion = await resolveVersion(); - return handleMeta(mode, resolvedVersion); + return handleMeta(mode, resolvedVersion, logger); }, mode, logger, @@ -52,7 +52,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleListModels } = await import("./routes/models/list"); - return handleListModels(req, db, mode); + return handleListModels(req, db, mode, logger); }, mode, logger, @@ -60,7 +60,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleCreateModel } = await import("./routes/models/create"); - return handleCreateModel(req, db, mode); + return handleCreateModel(req, db, mode, logger); }, mode, logger, @@ -70,7 +70,7 @@ export function startServer(options: StartServerOptions) { DELETE: withErrorHandler( async (req) => { const { handleDeleteModel } = await import("./routes/models/delete"); - return handleDeleteModel(req, db, mode); + return handleDeleteModel(req, db, mode, logger); }, mode, logger, @@ -78,7 +78,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleGetModel } = await import("./routes/models/get"); - return handleGetModel(req, db, mode); + return handleGetModel(req, db, mode, logger); }, mode, logger, @@ -86,7 +86,7 @@ export function startServer(options: StartServerOptions) { PATCH: withErrorHandler( async (req) => { const { handleUpdateModel } = await import("./routes/models/update"); - return handleUpdateModel(req, db, mode); + return handleUpdateModel(req, db, mode, logger); }, mode, logger, @@ -96,7 +96,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleTestModelConfig } = await import("./routes/models/test"); - return handleTestModelConfig(req, db, mode); + return handleTestModelConfig(req, db, mode, logger); }, mode, logger, @@ -106,7 +106,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleListProjects } = await import("./routes/projects/list"); - return handleListProjects(req, db, mode); + return handleListProjects(req, db, mode, logger); }, mode, logger, @@ -114,7 +114,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleCreateProject } = await import("./routes/projects/create"); - return handleCreateProject(req, db, mode); + return handleCreateProject(req, db, mode, logger); }, mode, logger, @@ -124,7 +124,7 @@ export function startServer(options: StartServerOptions) { DELETE: withErrorHandler( async (req) => { const { handleDeleteProject } = await import("./routes/projects/delete"); - return handleDeleteProject(req, db, mode); + return handleDeleteProject(req, db, mode, logger); }, mode, logger, @@ -132,7 +132,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleGetProject } = await import("./routes/projects/get"); - return handleGetProject(req, db, mode); + return handleGetProject(req, db, mode, logger); }, mode, logger, @@ -140,7 +140,7 @@ export function startServer(options: StartServerOptions) { PATCH: withErrorHandler( async (req) => { const { handleUpdateProject } = await import("./routes/projects/update"); - return handleUpdateProject(req, db, mode); + return handleUpdateProject(req, db, mode, logger); }, mode, logger, @@ -150,7 +150,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleArchiveProject } = await import("./routes/projects/archive"); - return handleArchiveProject(req, db, mode); + return handleArchiveProject(req, db, mode, logger); }, mode, logger, @@ -170,7 +170,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleListConversations } = await import("./routes/chat/list"); - return handleListConversations(req, db, mode); + return handleListConversations(req, db, mode, logger); }, mode, logger, @@ -178,7 +178,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleCreateConversation } = await import("./routes/chat/create"); - return handleCreateConversation(req, db, mode); + return handleCreateConversation(req, db, mode, logger); }, mode, logger, @@ -188,7 +188,7 @@ export function startServer(options: StartServerOptions) { DELETE: withErrorHandler( async (req) => { const { handleDeleteConversation } = await import("./routes/chat/delete"); - return handleDeleteConversation(req, db, mode); + return handleDeleteConversation(req, db, mode, logger); }, mode, logger, @@ -196,7 +196,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleGetConversation } = await import("./routes/chat/get"); - return handleGetConversation(req, db, mode); + return handleGetConversation(req, db, mode, logger); }, mode, logger, @@ -204,7 +204,7 @@ export function startServer(options: StartServerOptions) { PATCH: withErrorHandler( async (req) => { const { handleUpdateConversation } = await import("./routes/chat/update"); - return handleUpdateConversation(req, db, mode); + return handleUpdateConversation(req, db, mode, logger); }, mode, logger, @@ -214,7 +214,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleListMessages } = await import("./routes/chat/messages"); - return handleListMessages(req, db, mode); + return handleListMessages(req, db, mode, logger); }, mode, logger, @@ -224,7 +224,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleRestoreProject } = await import("./routes/projects/restore"); - return handleRestoreProject(req, db, mode); + return handleRestoreProject(req, db, mode, logger); }, mode, logger, @@ -234,7 +234,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleListProviders } = await import("./routes/providers/list"); - return handleListProviders(req, db, mode); + return handleListProviders(req, db, mode, logger); }, mode, logger, @@ -242,7 +242,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleCreateProvider } = await import("./routes/providers/create"); - return handleCreateProvider(req, db, mode); + return handleCreateProvider(req, db, mode, logger); }, mode, logger, @@ -252,7 +252,7 @@ export function startServer(options: StartServerOptions) { DELETE: withErrorHandler( async (req) => { const { handleDeleteProvider } = await import("./routes/providers/delete"); - return handleDeleteProvider(req, db, mode); + return handleDeleteProvider(req, db, mode, logger); }, mode, logger, @@ -260,7 +260,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async (req) => { const { handleGetProvider } = await import("./routes/providers/get"); - return handleGetProvider(req, db, mode); + return handleGetProvider(req, db, mode, logger); }, mode, logger, @@ -268,7 +268,7 @@ export function startServer(options: StartServerOptions) { PATCH: withErrorHandler( async (req) => { const { handleUpdateProvider } = await import("./routes/providers/update"); - return handleUpdateProvider(req, db, mode); + return handleUpdateProvider(req, db, mode, logger); }, mode, logger, @@ -278,7 +278,7 @@ export function startServer(options: StartServerOptions) { GET: withErrorHandler( async () => { const { handleListProviderOptions } = await import("./routes/providers/options"); - return handleListProviderOptions(db, mode); + return handleListProviderOptions(db, mode, logger); }, mode, logger, @@ -288,7 +288,7 @@ export function startServer(options: StartServerOptions) { POST: withErrorHandler( async (req) => { const { handleTestProviderConfig } = await import("./routes/providers/test"); - return handleTestProviderConfig(req, db, mode); + return handleTestProviderConfig(req, db, mode, logger); }, mode, logger, diff --git a/src/web/consoles/workbench/components/chat/ChatPanel.tsx b/src/web/consoles/workbench/components/chat/ChatPanel.tsx index e43928b..af11342 100644 --- a/src/web/consoles/workbench/components/chat/ChatPanel.tsx +++ b/src/web/consoles/workbench/components/chat/ChatPanel.tsx @@ -11,6 +11,7 @@ import { fetchMessages, updateConversation, } from "../../../../hooks/use-conversations"; +import { useLogger } from "../../../../hooks/use-logger"; import { useModelList } from "../../../../hooks/use-models"; import { ChatInputArea } from "./ChatInputArea"; import { ReasoningPart } from "./parts/ReasoningPart"; @@ -25,6 +26,7 @@ interface ChatPanelProps { export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) { const { message } = App.useApp(); + const logger = useLogger().child({ component: "ChatPanel", page: "workbench" }); const queryClient = useQueryClient(); const [input, setInput] = useState(""); const [editingMessageId, setEditingMessageId] = useState(null); @@ -45,6 +47,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({ onError: (err) => { + logger.error("聊天发送失败", { error: err.message }); void message.error(`发送失败:${err.message}`); }, transport: new DefaultChatTransport({ api: `/api/projects/${projectId}/chat` }), @@ -87,6 +90,7 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: } catch (err: unknown) { if (!cancelled) { const msg = err instanceof Error ? err.message : String(err); + logger.error("加载历史失败", { conversationId, error: msg, projectId }); void message.error(`加载历史失败:${msg}`); } } finally { @@ -99,22 +103,27 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: return () => { cancelled = true; }; - }, [conversationId, projectId, setMessages, message]); + }, [conversationId, projectId, setMessages, message, logger]); useEffect(() => { if (!conversationId) return; const firstTextId = textModels[0]?.id; if (!firstTextId) return; - void fetchConversation(projectId, conversationId).then((conv) => { - if (textModels.every((m) => m.id !== conv.modelId)) { - setSelectedModelId(firstTextId); - void updateConversation(projectId, conversationId, { modelId: firstTextId }); - } else { - setSelectedModelId(conv.modelId); - } - }); - }, [conversationId, textModels, projectId]); + void fetchConversation(projectId, conversationId) + .then((conv) => { + if (textModels.every((m) => m.id !== conv.modelId)) { + setSelectedModelId(firstTextId); + void updateConversation(projectId, conversationId, { modelId: firstTextId }); + } else { + setSelectedModelId(conv.modelId); + } + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("获取会话模型信息失败", { conversationId, error: msg, projectId }); + }); + }, [conversationId, textModels, projectId, logger]); useEffect(() => { scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight }); @@ -132,10 +141,13 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: (value: string) => { setSelectedModelId(value); if (conversationId) { - void updateConversation(projectId, conversationId, { modelId: value }); + void updateConversation(projectId, conversationId, { modelId: value }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("更新会话模型失败", { conversationId, error: msg, projectId }); + }); } }, - [projectId, conversationId], + [projectId, conversationId, logger], ); const handleSend = useCallback(async () => { @@ -153,13 +165,27 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: } catch (err: unknown) { setInput(text); const msg = err instanceof Error ? err.message : String(err); + logger.error("创建会话失败", { error: msg, projectId }); void message.error(`创建会话失败:${msg}`); } return; } - void sendMessage({ text }, { body: { conversationId } }); - }, [input, sendMessage, conversationId, projectId, onConversationCreated, message, queryClient, displayModelId]); + void sendMessage({ text }, { body: { conversationId } }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.error("发送消息失败", { conversationId, error: msg, projectId }); + }); + }, [ + input, + sendMessage, + conversationId, + projectId, + onConversationCreated, + message, + queryClient, + displayModelId, + logger, + ]); const extractText = useCallback((msg: UIMessage) => { return msg.parts @@ -171,11 +197,17 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: const handleCopy = useCallback( (msg: UIMessage) => { const text = extractText(msg); - void navigator.clipboard.writeText(text).then(() => { - void message.success("已复制"); - }); + void navigator.clipboard + .writeText(text) + .then(() => { + void message.success("已复制"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("复制失败", { error: msg }); + }); }, - [extractText, message], + [extractText, message, logger], ); const handleEditStart = useCallback( @@ -192,8 +224,11 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: const idx = messages.findIndex((m) => m.id === editingMessageId); if (idx === -1) return; setMessages(messages.slice(0, idx)); - void sendMessage({ text: editText }, { body: { conversationId } }); - }, [editText, conversationId, messages, editingMessageId, setMessages, sendMessage]); + void sendMessage({ text: editText }, { body: { conversationId } }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.error("重新发送消息失败", { conversationId, error: msg, projectId }); + }); + }, [editText, conversationId, messages, editingMessageId, setMessages, sendMessage, logger, projectId]); const handleEditCancel = useCallback(() => { setEditingMessageId(null); @@ -202,8 +237,11 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: const handleRegenerate = useCallback(() => { if (!conversationId) return; - void regenerate({ body: { conversationId } }); - }, [regenerate, conversationId]); + void regenerate({ body: { conversationId } }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.error("重新生成失败", { conversationId, error: msg, projectId }); + }); + }, [regenerate, conversationId, logger, projectId]); const getCardExtra = useCallback( (msg: UIMessage, idx: number) => { @@ -282,7 +320,10 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: void handleSend(); }} onStop={() => { - void stop(); + void stop().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("停止聊天失败", { error: msg }); + }); }} /> @@ -350,7 +391,10 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }: void handleSend(); }} onStop={() => { - void stop(); + void stop().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("停止聊天失败", { error: msg }); + }); }} /> diff --git a/src/web/hooks/use-conversations.ts b/src/web/hooks/use-conversations.ts index f05c93c..f1ce7f5 100644 --- a/src/web/hooks/use-conversations.ts +++ b/src/web/hooks/use-conversations.ts @@ -7,6 +7,9 @@ import type { } from "../../shared/api"; import { handleResponse, handleVoidResponse } from "../utils/api"; +import { createConsoleLogger } from "../utils/logger"; + +const logger = createConsoleLogger(); export async function createConversation(projectId: string, modelId?: string): Promise { const response = await fetch(`/api/projects/${projectId}/conversations`, { @@ -23,8 +26,17 @@ export async function deleteConversation(projectId: string, conversationId: stri } export async function fetchConversation(projectId: string, conversationId: string): Promise { - const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`); - return handleResponse(response, (data) => (data as ConversationResponse).conversation); + try { + const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`); + return handleResponse(response, (data) => (data as ConversationResponse).conversation); + } catch (err) { + logger.error("获取会话失败", { + conversationId, + error: err instanceof Error ? err.message : String(err), + projectId, + }); + throw err; + } } export async function fetchConversations(projectId: string): Promise { @@ -42,10 +54,19 @@ export async function updateConversation( conversationId: string, data: UpdateConversationRequest, ): Promise { - const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, { - body: JSON.stringify(data), - headers: { "Content-Type": "application/json" }, - method: "PATCH", - }); - return handleResponse(response, (data) => (data as ConversationResponse).conversation); + try { + const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, { + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }); + return handleResponse(response, (data) => (data as ConversationResponse).conversation); + } catch (err) { + logger.error("更新会话失败", { + conversationId, + error: err instanceof Error ? err.message : String(err), + projectId, + }); + throw err; + } } diff --git a/src/web/hooks/use-models.ts b/src/web/hooks/use-models.ts index 3a0e36c..da6db5c 100644 --- a/src/web/hooks/use-models.ts +++ b/src/web/hooks/use-models.ts @@ -12,8 +12,10 @@ import type { } from "../../shared/api"; import { handleResponse, handleVoidResponse } from "../utils/api"; +import { createConsoleLogger } from "../utils/logger"; const MODELS_KEY = ["models"] as const; +const logger = createConsoleLogger(); export async function createModel(data: CreateModelRequest): Promise { const response = await fetch("/api/models", { @@ -82,7 +84,8 @@ export function useCreateModel() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createModel, - onSuccess: () => { + onSuccess: (data) => { + logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); }, }); @@ -92,7 +95,8 @@ export function useDeleteModel() { const queryClient = useQueryClient(); return useMutation({ mutationFn: deleteModel, - onSuccess: () => { + onSuccess: (_data, variables) => { + logger.info("模型删除成功", { modelId: variables }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); }, }); @@ -123,7 +127,8 @@ export function useUpdateModel() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data), - onSuccess: () => { + onSuccess: (data) => { + logger.info("模型更新成功", { modelId: data.modelId, providerId: data.providerId }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); }, }); diff --git a/src/web/hooks/use-projects.ts b/src/web/hooks/use-projects.ts index 532342f..0253336 100644 --- a/src/web/hooks/use-projects.ts +++ b/src/web/hooks/use-projects.ts @@ -10,8 +10,10 @@ import type { } from "../../shared/api"; import { handleResponse, handleVoidResponse } from "../utils/api"; +import { createConsoleLogger } from "../utils/logger"; const PROJECTS_KEY = ["projects"] as const; +const logger = createConsoleLogger(); export async function archiveProject(id: string): Promise { const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" }); @@ -76,7 +78,8 @@ export function useArchiveProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: archiveProject, - onSuccess: () => { + onSuccess: (data) => { + logger.info("项目归档成功", { name: data.name, projectId: data.id }); void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); }, }); @@ -86,7 +89,8 @@ export function useCreateProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createProject, - onSuccess: () => { + onSuccess: (data) => { + logger.info("项目创建成功", { name: data.name, projectId: data.id }); void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); }, }); @@ -96,7 +100,8 @@ export function useDeleteProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: deleteProject, - onSuccess: () => { + onSuccess: (_data, variables) => { + logger.info("项目删除成功", { projectId: variables }); void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); }, }); @@ -121,7 +126,8 @@ export function useRestoreProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: restoreProject, - onSuccess: () => { + onSuccess: (data) => { + logger.info("项目恢复成功", { name: data.name, projectId: data.id }); void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); }, }); @@ -131,7 +137,8 @@ export function useUpdateProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: { data: UpdateProjectRequest; id: string }) => updateProject(args.id, args.data), - onSuccess: () => { + onSuccess: (data) => { + logger.info("项目更新成功", { name: data.name, projectId: data.id }); void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); }, }); diff --git a/src/web/hooks/use-providers.ts b/src/web/hooks/use-providers.ts index df26417..0ae6ba0 100644 --- a/src/web/hooks/use-providers.ts +++ b/src/web/hooks/use-providers.ts @@ -12,9 +12,11 @@ import type { } from "../../shared/api"; import { handleResponse, handleVoidResponse } from "../utils/api"; +import { createConsoleLogger } from "../utils/logger"; const PROVIDERS_KEY = ["providers"] as const; const MODELS_KEY = ["models"] as const; +const logger = createConsoleLogger(); export async function createProvider(data: CreateProviderRequest): Promise { const response = await fetch("/api/providers", { @@ -90,7 +92,8 @@ export function useCreateProvider() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createProvider, - onSuccess: () => { + onSuccess: (data) => { + logger.info("供应商创建成功", { name: data.name, providerId: data.id }); void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY }); }, }); @@ -100,7 +103,8 @@ export function useDeleteProvider() { const queryClient = useQueryClient(); return useMutation({ mutationFn: deleteProvider, - onSuccess: () => { + onSuccess: (_data, variables) => { + logger.info("供应商删除成功", { providerId: variables }); void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); }, @@ -139,7 +143,8 @@ export function useUpdateProvider() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: { data: UpdateProviderRequest; id: string }) => updateProvider(args.id, args.data), - onSuccess: () => { + onSuccess: (data) => { + logger.info("供应商更新成功", { name: data.name, providerId: data.id }); void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY }); }, }); diff --git a/src/web/main.tsx b/src/web/main.tsx index ebeebce..25e4483 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -1,4 +1,4 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; @@ -6,8 +6,11 @@ import { BrowserRouter } from "react-router"; import { App } from "./app"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { createConsoleLogger } from "./utils/logger"; import "./styles.css"; +const logger = createConsoleLogger(); + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -16,6 +19,19 @@ const queryClient = new QueryClient({ staleTime: 5000, }, }, + mutationCache: new MutationCache({ + onError: (error: Error, _variables, _context, mutation) => { + logger.error("mutation failed", { + error: error.message, + mutationKey: mutation.options.mutationKey, + }); + }, + }), + queryCache: new QueryCache({ + onError: (error: Error, query) => { + logger.error("query failed", { error: error.message, queryKey: query.queryKey }); + }, + }), }); const rootElement = document.getElementById("root"); @@ -36,3 +52,18 @@ createRoot(rootElement).render( , ); + +window.onerror = (message, source, lineno, colno, error) => { + logger.error("未处理的异常", { + colno, + error: error instanceof Error ? error.message : String(error), + lineno, + message, + source, + }); +}; + +window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => { + const msg = event.reason instanceof Error ? event.reason.message : String(event.reason); + logger.error("unhandled rejection", { reason: msg }); +}); diff --git a/src/web/utils/api.ts b/src/web/utils/api.ts index 6531798..94df1cf 100644 --- a/src/web/utils/api.ts +++ b/src/web/utils/api.ts @@ -1,15 +1,49 @@ +import { createConsoleLogger } from "./logger"; + +const logger = createConsoleLogger(); + export async function handleResponse(response: Response, extract: (data: unknown) => T): Promise { + const start = performance.now(); if (!response.ok) { const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); + const errorBody = body?.error ?? `HTTP ${response.status}`; + logger.warn("API request failed", { + duration: Math.round(performance.now() - start), + errorBody, + status: response.status, + url: response.url, + }); + throw new Error(errorBody); } const data: unknown = await response.json(); + if (import.meta.env["DEV"]) { + logger.debug("API request", { + duration: Math.round(performance.now() - start), + status: response.status, + url: response.url, + }); + } return extract(data); } export async function handleVoidResponse(response: Response): Promise { + const start = performance.now(); if (!response.ok) { const body = (await response.json().catch(() => null)) as null | { error?: string }; - throw new Error(body?.error ?? `HTTP ${response.status}`); + const errorBody = body?.error ?? `HTTP ${response.status}`; + logger.warn("API request failed", { + duration: Math.round(performance.now() - start), + errorBody, + status: response.status, + url: response.url, + }); + throw new Error(errorBody); + } + if (import.meta.env["DEV"]) { + logger.debug("API request", { + duration: Math.round(performance.now() - start), + status: response.status, + url: response.url, + }); } } diff --git a/tests/server/ai/registry.test.ts b/tests/server/ai/registry.test.ts index 66f74e0..406177b 100644 --- a/tests/server/ai/registry.test.ts +++ b/tests/server/ai/registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedTestDatabase } from "../../helpers"; void mock.module("ai", () => ({ @@ -36,12 +37,15 @@ describe("AI registry", () => { test("testProviderConnection reports unreachable Base URL", async () => { const { testProviderConnection } = await import("../../../src/server/ai/registry"); - const result = await testProviderConnection({ - apiKey: "bad-key", - baseUrl: "http://127.0.0.1:1", - name: "Bad", - type: "openai-compatible", - }); + const result = await testProviderConnection( + { + apiKey: "bad-key", + baseUrl: "http://127.0.0.1:1", + name: "Bad", + type: "openai-compatible", + }, + createNoopLogger(), + ); expect(result.ok).toBe(false); expect(result.message).toContain("Base URL 不可达"); @@ -51,12 +55,15 @@ describe("AI registry", () => { await withProviderServer(new Response(null, { status: 401 }), async (baseUrl) => { const { testProviderConnection } = await import("../../../src/server/ai/registry"); - const result = await testProviderConnection({ - apiKey: "bad-key", - baseUrl, - name: "Bad", - type: "openai-compatible", - }); + const result = await testProviderConnection( + { + apiKey: "bad-key", + baseUrl, + name: "Bad", + type: "openai-compatible", + }, + createNoopLogger(), + ); expect(result.ok).toBe(false); expect(result.message).toContain("API Key 无效"); @@ -68,12 +75,15 @@ describe("AI registry", () => { await withProviderServer(Response.json({ data: [{ id: "gpt-4o" }] }), async (baseUrl) => { const { testProviderConnection } = await import("../../../src/server/ai/registry"); - const result = await testProviderConnection({ - apiKey: "sk-test", - baseUrl, - name: "Test", - type: "openai", - }); + const result = await testProviderConnection( + { + apiKey: "sk-test", + baseUrl, + name: "Test", + type: "openai", + }, + createNoopLogger(), + ); expect(result.ok).toBe(true); expect(result.message).toContain("/models 返回 1 个模型"); @@ -84,12 +94,15 @@ describe("AI registry", () => { await withProviderServer(new Response(null, { status: 404 }), async (baseUrl) => { const { testProviderConnection } = await import("../../../src/server/ai/registry"); - const result = await testProviderConnection({ - apiKey: "sk-test", - baseUrl, - name: "Test", - type: "openai", - }); + const result = await testProviderConnection( + { + apiKey: "sk-test", + baseUrl, + name: "Test", + type: "openai", + }, + createNoopLogger(), + ); expect(result.ok).toBe(true); expect(result.message).toContain("可能不支持 /models"); @@ -134,13 +147,16 @@ describe("AI registry", () => { test("testModelConnection 成功返回 ok:true", async () => { const { testModelConnection } = await import("../../../src/server/ai/registry"); - const result = await testModelConnection({ - apiKey: "sk-test", - baseUrl: "https://api.openai.com/v1", - modelId: "gpt-4o", - name: "Test", - type: "openai", - }); + const result = await testModelConnection( + { + apiKey: "sk-test", + baseUrl: "https://api.openai.com/v1", + modelId: "gpt-4o", + name: "Test", + type: "openai", + }, + createNoopLogger(), + ); expect(result.ok).toBe(true); expect(result.message).toContain("模型连接成功"); diff --git a/tests/server/db/models.test.ts b/tests/server/db/models.test.ts index da24279..8355394 100644 --- a/tests/server/db/models.test.ts +++ b/tests/server/db/models.test.ts @@ -11,10 +11,15 @@ import { updateModel, } from "../../../src/server/db/models"; import { createProvider } from "../../../src/server/db/providers"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedTestDatabase } from "../../helpers"; function seedProvider(db: Database, name = "TestProvider"): string { - const result = createProvider(db, { apiKey: "sk-test", baseUrl: "https://api.test.com/v1", name, type: "openai" }); + const result = createProvider( + db, + { apiKey: "sk-test", baseUrl: "https://api.test.com/v1", name, type: "openai" }, + createNoopLogger(), + ); return (result as { provider: { id: string } }).provider.id; } @@ -32,12 +37,16 @@ describe("模型数据访问层", () => { test("创建模型", () => { withDb((db) => { const providerId = seedProvider(db); - const result = createModel(db, { - capabilities: ["text", "reasoning"], - modelId: "gpt-4o", - name: "GPT-4o", - providerId, - }); + const result = createModel( + db, + { + capabilities: ["text", "reasoning"], + modelId: "gpt-4o", + name: "GPT-4o", + providerId, + }, + createNoopLogger(), + ); expect("error" in result).toBe(false); const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } }) .model; @@ -50,12 +59,16 @@ describe("模型数据访问层", () => { test("供应商不存在时创建失败", () => { withDb((db) => { - const result = createModel(db, { - capabilities: ["text"], - modelId: "test", - name: "Test", - providerId: "nonexistent", - }); + const result = createModel( + db, + { + capabilities: ["text"], + modelId: "test", + name: "Test", + providerId: "nonexistent", + }, + createNoopLogger(), + ); expect("error" in result).toBe(true); expect((result as unknown as { status: number }).status).toBe(400); }); @@ -64,8 +77,12 @@ describe("模型数据访问层", () => { test("同一供应商下模型 ID 唯一", () => { withDb((db) => { const providerId = seedProvider(db); - createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId }); - const result = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId }); + createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId }, createNoopLogger()); + const result = createModel( + db, + { capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId }, + createNoopLogger(), + ); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("已存在"); }); @@ -75,8 +92,16 @@ describe("模型数据访问层", () => { withDb((db) => { const p1 = seedProvider(db, "P1"); const p2 = seedProvider(db, "P2"); - const r1 = createModel(db, { capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 }); - const r2 = createModel(db, { capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 }); + const r1 = createModel( + db, + { capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 }, + createNoopLogger(), + ); + const r2 = createModel( + db, + { capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 }, + createNoopLogger(), + ); expect("error" in r1).toBe(false); expect("error" in r2).toBe(false); }); @@ -85,7 +110,11 @@ describe("模型数据访问层", () => { test("能力标签为空时创建失败", () => { withDb((db) => { const providerId = seedProvider(db); - const result = createModel(db, { capabilities: [], modelId: "test", name: "Test", providerId }); + const result = createModel( + db, + { capabilities: [], modelId: "test", name: "Test", providerId }, + createNoopLogger(), + ); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("能力标签"); }); @@ -95,9 +124,9 @@ describe("模型数据访问层", () => { withDb((db) => { const p1 = seedProvider(db, "P1"); const p2 = seedProvider(db, "P2"); - createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }); - createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }); - createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }); + createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger()); const all = listModels(db, { page: 1, pageSize: 20 }); expect(all.total).toBe(3); @@ -113,7 +142,11 @@ describe("模型数据访问层", () => { test("获取模型详情", () => { withDb((db) => { const providerId = seedProvider(db); - const created = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId }); + const created = createModel( + db, + { capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId }, + createNoopLogger(), + ); const id = (created as { model: { id: string } }).model.id; const result = getModel(db, id); @@ -133,10 +166,14 @@ describe("模型数据访问层", () => { test("更新模型", () => { withDb((db) => { const providerId = seedProvider(db); - const created = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId }); + const created = createModel( + db, + { capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId }, + createNoopLogger(), + ); const id = (created as { model: { id: string } }).model.id; - const result = updateModel(db, id, { capabilities: ["text", "reasoning"], name: "新名" }); + const result = updateModel(db, id, { capabilities: ["text", "reasoning"], name: "新名" }, createNoopLogger()); expect("error" in result).toBe(false); const updated = (result as { model: { capabilities: string[]; name: string } }).model; expect(updated.name).toBe("新名"); @@ -147,10 +184,14 @@ describe("模型数据访问层", () => { test("删除模型", () => { withDb((db) => { const providerId = seedProvider(db); - const created = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId }); + const created = createModel( + db, + { capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId }, + createNoopLogger(), + ); const id = (created as { model: { id: string } }).model.id; - const result = deleteModel(db, id); + const result = deleteModel(db, id, createNoopLogger()); expect("error" in result).toBe(false); const after = getModel(db, id); @@ -162,9 +203,9 @@ describe("模型数据访问层", () => { withDb((db) => { const p1 = seedProvider(db, "P1"); const p2 = seedProvider(db, "P2"); - createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }); - createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }); - createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }); + createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger()); expect(getModelsByProviderId(db, p1)).toBe(2); expect(getModelsByProviderId(db, p2)).toBe(1); @@ -174,14 +215,18 @@ describe("模型数据访问层", () => { test("可选字段 contextLength 和 maxOutputTokens", () => { withDb((db) => { const providerId = seedProvider(db); - const result = createModel(db, { - capabilities: ["text"], - contextLength: 128000, - maxOutputTokens: 4096, - modelId: "gpt-4o", - name: "GPT-4o", - providerId, - }); + const result = createModel( + db, + { + capabilities: ["text"], + contextLength: 128000, + maxOutputTokens: 4096, + modelId: "gpt-4o", + name: "GPT-4o", + providerId, + }, + createNoopLogger(), + ); expect("error" in result).toBe(false); const model = (result as { model: { contextLength: null | number; maxOutputTokens: null | number } }).model; expect(model.contextLength).toBe(128000); diff --git a/tests/server/db/projects.test.ts b/tests/server/db/projects.test.ts index fac0c75..d7394d8 100644 --- a/tests/server/db/projects.test.ts +++ b/tests/server/db/projects.test.ts @@ -11,6 +11,7 @@ import { restoreProject, updateProject, } from "../../../src/server/db/projects"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedTestDatabase } from "../../helpers"; function withProjectsDb(callback: (db: Database) => void): void { @@ -26,7 +27,7 @@ function withProjectsDb(callback: (db: Database) => void): void { describe("项目数据访问层", () => { test("创建项目", () => { withProjectsDb((db) => { - const result = createProject(db, { description: "测试描述", name: "测试项目" }); + const result = createProject(db, { description: "测试描述", name: "测试项目" }, createNoopLogger()); expect("error" in result).toBe(false); expect((result as { project: unknown }).project).toBeDefined(); @@ -43,8 +44,8 @@ describe("项目数据访问层", () => { test("项目名称全局唯一(含归档项目)", () => { withProjectsDb((db) => { - createProject(db, { name: "唯一名称" }); - const result2 = createProject(db, { name: "唯一名称" }); + createProject(db, { name: "唯一名称" }, createNoopLogger()); + const result2 = createProject(db, { name: "唯一名称" }, createNoopLogger()); expect("error" in result2).toBe(true); expect((result2 as unknown as { error: string }).error).toContain("已存在"); }); @@ -52,7 +53,7 @@ describe("项目数据访问层", () => { test("trim 后名称为空时创建失败", () => { withProjectsDb((db) => { - const result = createProject(db, { name: " " }); + const result = createProject(db, { name: " " }, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("不能为空"); }); @@ -60,9 +61,9 @@ describe("项目数据访问层", () => { test("列表查询(分页和关键字)", () => { withProjectsDb((db) => { - createProject(db, { description: "descA", name: "项目A" }); - createProject(db, { description: "descB", name: "项目B" }); - createProject(db, { name: "其他" }); + createProject(db, { description: "descA", name: "项目A" }, createNoopLogger()); + createProject(db, { description: "descB", name: "项目B" }, createNoopLogger()); + createProject(db, { name: "其他" }, createNoopLogger()); const result1 = listProjects(db, { page: 1, pageSize: 20 }); expect(result1.total).toBe(3); @@ -79,7 +80,7 @@ describe("项目数据访问层", () => { test("获取项目详情", () => { withProjectsDb((db) => { - const created = createProject(db, { description: "详情", name: "详情项目" }); + const created = createProject(db, { description: "详情", name: "详情项目" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; const result = getProject(db, id); @@ -99,10 +100,10 @@ describe("项目数据访问层", () => { test("更新项目名称和描述", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "原名" }); + const created = createProject(db, { name: "原名" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - const result = updateProject(db, id, { description: "新描述", name: "新名" }); + const result = updateProject(db, id, { description: "新描述", name: "新名" }, createNoopLogger()); expect("error" in result).toBe(false); const updated = result as { project: { description: string; name: string } }; @@ -113,11 +114,11 @@ describe("项目数据访问层", () => { test("更新已归档项目失败", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "待归档" }); + const created = createProject(db, { name: "待归档" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - archiveProject(db, id); + archiveProject(db, id, createNoopLogger()); - const result = updateProject(db, id, { name: "新名称" }); + const result = updateProject(db, id, { name: "新名称" }, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { status: number }).status).toBe(409); }); @@ -125,10 +126,10 @@ describe("项目数据访问层", () => { test("归档项目", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "待归档" }); + const created = createProject(db, { name: "待归档" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - const result = archiveProject(db, id); + const result = archiveProject(db, id, createNoopLogger()); expect("error" in result).toBe(false); const archived = (result as { project: { archivedAt: null | string; status: string } }).project; @@ -146,10 +147,10 @@ describe("项目数据访问层", () => { test("对已归档项目重复归档失败", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "测试" }); + const created = createProject(db, { name: "测试" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - archiveProject(db, id); - const result = archiveProject(db, id); + archiveProject(db, id, createNoopLogger()); + const result = archiveProject(db, id, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { status: number }).status).toBe(409); }); @@ -157,11 +158,11 @@ describe("项目数据访问层", () => { test("恢复已归档项目", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "恢复测试" }); + const created = createProject(db, { name: "恢复测试" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - archiveProject(db, id); + archiveProject(db, id, createNoopLogger()); - const result = restoreProject(db, id); + const result = restoreProject(db, id, createNoopLogger()); expect("error" in result).toBe(false); const restored = (result as { project: { archivedAt: null | string; status: string } }).project; @@ -172,9 +173,9 @@ describe("项目数据访问层", () => { test("恢复 active 项目失败", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "活跃项目" }); + const created = createProject(db, { name: "活跃项目" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - const result = restoreProject(db, id); + const result = restoreProject(db, id, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { status: number }).status).toBe(409); }); @@ -182,11 +183,11 @@ describe("项目数据访问层", () => { test("永久删除已归档项目", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "删除测试" }); + const created = createProject(db, { name: "删除测试" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - archiveProject(db, id); + archiveProject(db, id, createNoopLogger()); - const result = deleteProject(db, id); + const result = deleteProject(db, id, createNoopLogger()); expect("error" in result).toBe(false); const after = getProject(db, id); @@ -196,10 +197,10 @@ describe("项目数据访问层", () => { test("删除 active 项目失败", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "活跃项目" }); + const created = createProject(db, { name: "活跃项目" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - const result = deleteProject(db, id); + const result = deleteProject(db, id, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { status: number }).status).toBe(409); }); @@ -207,7 +208,7 @@ describe("项目数据访问层", () => { test("创建项目名称超过 10 个字符失败", () => { withProjectsDb((db) => { - const result = createProject(db, { name: "这是一个非常非常长的名字" }); + const result = createProject(db, { name: "这是一个非常非常长的名字" }, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符"); }); @@ -215,7 +216,7 @@ describe("项目数据访问层", () => { test("创建项目名称刚好 10 个字符成功", () => { withProjectsDb((db) => { - const result = createProject(db, { name: "一二三四五六七八九十" }); + const result = createProject(db, { name: "一二三四五六七八九十" }, createNoopLogger()); expect("error" in result).toBe(false); const project = (result as { project: { name: string } }).project; expect(project.name).toBe("一二三四五六七八九十"); @@ -224,10 +225,10 @@ describe("项目数据访问层", () => { test("更新项目名称超过 10 个字符失败", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "短名" }); + const created = createProject(db, { name: "短名" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - const result = updateProject(db, id, { name: "这是一个非常非常长的名字" }); + const result = updateProject(db, id, { name: "这是一个非常非常长的名字" }, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符"); }); @@ -235,10 +236,10 @@ describe("项目数据访问层", () => { test("更新项目名称 trim 后为空失败", () => { withProjectsDb((db) => { - const created = createProject(db, { name: "原名" }); + const created = createProject(db, { name: "原名" }, createNoopLogger()); const id = (created as { project: { id: string } }).project.id; - const result = updateProject(db, id, { name: " " }); + const result = updateProject(db, id, { name: " " }, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("不能为空"); }); diff --git a/tests/server/db/providers.test.ts b/tests/server/db/providers.test.ts index f922cec..3600334 100644 --- a/tests/server/db/providers.test.ts +++ b/tests/server/db/providers.test.ts @@ -10,6 +10,7 @@ import { listProviders, updateProvider, } from "../../../src/server/db/providers"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedTestDatabase } from "../../helpers"; function withDb(callback: (db: Database) => void): void { @@ -35,12 +36,16 @@ describe("供应商数据访问层", () => { test("创建供应商", () => { withDb((db) => { - const result = createProvider(db, { - apiKey: "sk-test", - baseUrl: "https://api.openai.com/v1", - name: "OpenAI", - type: "openai", - }); + const result = createProvider( + db, + { + apiKey: "sk-test", + baseUrl: "https://api.openai.com/v1", + name: "OpenAI", + type: "openai", + }, + createNoopLogger(), + ); expect("error" in result).toBe(false); const provider = (result as { provider: { apiKey: string; baseUrl: string; name: string; type: string } }) .provider; @@ -53,8 +58,16 @@ describe("供应商数据访问层", () => { test("供应商名称唯一", () => { withDb((db) => { - createProvider(db, { apiKey: "sk-1", baseUrl: "https://a.com", name: "唯一", type: "openai" }); - const result = createProvider(db, { apiKey: "sk-2", baseUrl: "https://b.com", name: "唯一", type: "openai" }); + createProvider( + db, + { apiKey: "sk-1", baseUrl: "https://a.com", name: "唯一", type: "openai" }, + createNoopLogger(), + ); + const result = createProvider( + db, + { apiKey: "sk-2", baseUrl: "https://b.com", name: "唯一", type: "openai" }, + createNoopLogger(), + ); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("已存在"); }); @@ -62,7 +75,11 @@ describe("供应商数据访问层", () => { test("名称为空时创建失败", () => { withDb((db) => { - const result = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: " ", type: "openai" }); + const result = createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: " ", type: "openai" }, + createNoopLogger(), + ); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("不能为空"); }); @@ -70,9 +87,21 @@ describe("供应商数据访问层", () => { test("列表查询(分页和关键字)", () => { withDb((db) => { - createProvider(db, { apiKey: "sk-1", baseUrl: "https://a.com", name: "Alpha", type: "openai" }); - createProvider(db, { apiKey: "sk-2", baseUrl: "https://b.com", name: "Beta", type: "anthropic" }); - createProvider(db, { apiKey: "sk-3", baseUrl: "https://c.com", name: "Gamma", type: "openai-compatible" }); + createProvider( + db, + { apiKey: "sk-1", baseUrl: "https://a.com", name: "Alpha", type: "openai" }, + createNoopLogger(), + ); + createProvider( + db, + { apiKey: "sk-2", baseUrl: "https://b.com", name: "Beta", type: "anthropic" }, + createNoopLogger(), + ); + createProvider( + db, + { apiKey: "sk-3", baseUrl: "https://c.com", name: "Gamma", type: "openai-compatible" }, + createNoopLogger(), + ); const result1 = listProviders(db, { page: 1, pageSize: 20 }); expect(result1.total).toBe(3); @@ -88,7 +117,11 @@ describe("供应商数据访问层", () => { test("获取供应商详情", () => { withDb((db) => { - const created = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "详情", type: "openai" }); + const created = createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: "详情", type: "openai" }, + createNoopLogger(), + ); const id = (created as { provider: { id: string } }).provider.id; const result = getProvider(db, id); @@ -107,10 +140,14 @@ describe("供应商数据访问层", () => { test("更新供应商", () => { withDb((db) => { - const created = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "原名", type: "openai" }); + const created = createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: "原名", type: "openai" }, + createNoopLogger(), + ); const id = (created as { provider: { id: string } }).provider.id; - const result = updateProvider(db, id, { name: "新名" }); + const result = updateProvider(db, id, { name: "新名" }, createNoopLogger()); expect("error" in result).toBe(false); expect((result as { provider: { name: string } }).provider.name).toBe("新名"); }); @@ -118,11 +155,19 @@ describe("供应商数据访问层", () => { test("更新供应商名称重复失败", () => { withDb((db) => { - createProvider(db, { apiKey: "sk-1", baseUrl: "https://a.com", name: "已存在", type: "openai" }); - const created = createProvider(db, { apiKey: "sk-2", baseUrl: "https://b.com", name: "原名", type: "openai" }); + createProvider( + db, + { apiKey: "sk-1", baseUrl: "https://a.com", name: "已存在", type: "openai" }, + createNoopLogger(), + ); + const created = createProvider( + db, + { apiKey: "sk-2", baseUrl: "https://b.com", name: "原名", type: "openai" }, + createNoopLogger(), + ); const id = (created as { provider: { id: string } }).provider.id; - const result = updateProvider(db, id, { name: "已存在" }); + const result = updateProvider(db, id, { name: "已存在" }, createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { error: string }).error).toContain("已存在"); }); @@ -130,10 +175,14 @@ describe("供应商数据访问层", () => { test("删除供应商", () => { withDb((db) => { - const created = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "删除测试", type: "openai" }); + const created = createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: "删除测试", type: "openai" }, + createNoopLogger(), + ); const id = (created as { provider: { id: string } }).provider.id; - const result = deleteProvider(db, id); + const result = deleteProvider(db, id, createNoopLogger()); expect("error" in result).toBe(false); const after = getProvider(db, id); @@ -143,7 +192,7 @@ describe("供应商数据访问层", () => { test("删除不存在的供应商返回 404", () => { withDb((db) => { - const result = deleteProvider(db, "nonexistent"); + const result = deleteProvider(db, "nonexistent", createNoopLogger()); expect("error" in result).toBe(true); expect((result as unknown as { status: number }).status).toBe(404); }); @@ -151,20 +200,28 @@ describe("供应商数据访问层", () => { test("默认类型为 openai-compatible", () => { withDb((db) => { - createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "默认类型", type: "openai-compatible" }); - const result = createProvider(db, { - apiKey: "sk2", - baseUrl: "https://b.com", - name: "显式默认", - type: "openai-compatible", - }); + createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: "默认类型", type: "openai-compatible" }, + createNoopLogger(), + ); + const result = createProvider( + db, + { + apiKey: "sk2", + baseUrl: "https://b.com", + name: "显式默认", + type: "openai-compatible", + }, + createNoopLogger(), + ); expect((result as { provider: { type: string } }).provider.type).toBe("openai-compatible"); }); }); test("供应商 options 返回最小字段", () => { withDb((db) => { - createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "选项", type: "openai" }); + createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "选项", type: "openai" }, createNoopLogger()); const options = listProviderOptions(db); expect(options.length).toBe(1); diff --git a/tests/server/routes/chat.test.ts b/tests/server/routes/chat.test.ts index bb30655..86500e9 100644 --- a/tests/server/routes/chat.test.ts +++ b/tests/server/routes/chat.test.ts @@ -11,6 +11,7 @@ import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedMemoryTestDatabase } from "../../helpers"; const MODE: RuntimeMode = "test"; +const LOG = createNoopLogger(); void mock.module("ai", () => ({ createAgentUIStreamResponse: (opts: { @@ -49,65 +50,73 @@ void mock.module("ai", () => ({ async function createConversationViaHandler(req: Request, db: Database): Promise { const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function deleteConversationViaHandler(req: Request, db: Database): Promise { const { handleDeleteConversation: h } = await import("../../../src/server/routes/chat/delete"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function getConversationViaHandler(req: Request, db: Database): Promise { const { handleGetConversation: h } = await import("../../../src/server/routes/chat/get"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function listConversationsViaHandler(req: Request, db: Database): Promise { const { handleListConversations: h } = await import("../../../src/server/routes/chat/list"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function listMessagesViaHandler(req: Request, db: Database): Promise { const { handleListMessages: h } = await import("../../../src/server/routes/chat/messages"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function patchConversationViaHandler(req: Request, db: Database): Promise { const { handleUpdateConversation: h } = await import("../../../src/server/routes/chat/update"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string { - const result = createModel(db, { - capabilities: ["text"], - modelId, - name: modelName, - providerId, - }); + const result = createModel( + db, + { + capabilities: ["text"], + modelId, + name: modelName, + providerId, + }, + LOG, + ); if ("error" in result) throw new Error(result.error); return result.model.id; } function seedProject(db: Database, name = "测试项目"): string { - const result = createProject(db, { description: "测试", name }); + const result = createProject(db, { description: "测试", name }, LOG); if ("error" in result) throw new Error(result.error); return result.project.id; } function seedProvider(db: Database, name = "测试供应商"): string { - const result = createProvider(db, { - apiKey: "sk-test", - baseUrl: "https://api.test.com/v1", - name, - type: "openai", - }); + const result = createProvider( + db, + { + apiKey: "sk-test", + baseUrl: "https://api.test.com/v1", + name, + type: "openai", + }, + LOG, + ); if ("error" in result) throw new Error(result.error); return result.provider.id; } async function sendChatViaHandler(req: Request, db: Database): Promise { const { handleSendChat: h } = await import("../../../src/server/routes/chat/send"); - return h(req, db, MODE, createNoopLogger()); + return h(req, db, MODE, LOG); } describe("聊天 API 路由", () => { diff --git a/tests/server/routes/models.test.ts b/tests/server/routes/models.test.ts index f539918..e22f877 100644 --- a/tests/server/routes/models.test.ts +++ b/tests/server/routes/models.test.ts @@ -4,40 +4,46 @@ import { describe, expect, mock, test } from "bun:test"; import type { Model, RuntimeMode } from "../../../src/shared/api"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedMemoryTestDatabase } from "../../helpers"; const MODE: RuntimeMode = "test"; +const LOG = createNoopLogger(); async function createModelViaHandler(req: Request, db: Database): Promise { const { handleCreateModel: h } = await import("../../../src/server/routes/models/create"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } function createTestModel(db: Database, pName: string, providerId?: string): Model { const pid = providerId ?? seedProvider(db); - const result = createModel(db, { - capabilities: ["text"], - modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), - name: pName, - providerId: pid, - }); + const result = createModel( + db, + { + capabilities: ["text"], + modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), + name: pName, + providerId: pid, + }, + LOG, + ); if ("error" in result) throw new Error(result.error); return result.model; } async function deleteModelViaHandler(req: Request, db: Database): Promise { const { handleDeleteModel: h } = await import("../../../src/server/routes/models/delete"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function getModelViaHandler(req: Request, db: Database): Promise { const { handleGetModel: h } = await import("../../../src/server/routes/models/get"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function listModelsViaHandler(req: Request, db: Database): Promise { const { handleListModels: h } = await import("../../../src/server/routes/models/list"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } import { createModel } from "../../../src/server/db/models"; @@ -51,24 +57,28 @@ void mock.module("ai", () => ({ })); function seedProvider(db: Database, name?: string): string { - const result = createProvider(db, { - apiKey: "sk-test", - baseUrl: "https://api.test.com/v1", - name: name ?? "TestProvider", - type: "openai", - }); + const result = createProvider( + db, + { + apiKey: "sk-test", + baseUrl: "https://api.test.com/v1", + name: name ?? "TestProvider", + type: "openai", + }, + LOG, + ); if ("error" in result) throw new Error(result.error); return result.provider.id; } async function testModelViaHandler(req: Request, db: Database): Promise { const { handleTestModelConfig: h } = await import("../../../src/server/routes/models/test"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function updateModelViaHandler(req: Request, db: Database): Promise { const { handleUpdateModel: h } = await import("../../../src/server/routes/models/update"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function withRouteDb(callback: (db: Database) => Promise): Promise { diff --git a/tests/server/routes/projects.test.ts b/tests/server/routes/projects.test.ts index a6161ae..47aa7d0 100644 --- a/tests/server/routes/projects.test.ts +++ b/tests/server/routes/projects.test.ts @@ -4,50 +4,52 @@ import { describe, expect, test } from "bun:test"; import type { Project, RuntimeMode } from "../../../src/shared/api"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedMemoryTestDatabase } from "../../helpers"; const MODE: RuntimeMode = "test"; +const LOG = createNoopLogger(); async function archiveProjectViaHandler(req: Request, db: Database): Promise { const { handleArchiveProject: h } = await import("../../../src/server/routes/projects/archive"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } // Inline imports for actual route handler tests (each handler is in separate file) async function createProjectViaHandler(req: Request, db: Database): Promise { const { handleCreateProject: h } = await import("../../../src/server/routes/projects/create"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } function createTestProject(db: Database, name = "测试项目"): Project { - const result = createProject(db, { name }); + const result = createProject(db, { name }, LOG); if ("error" in result) throw new Error(result.error); return result.project; } async function deleteProjectViaHandler(req: Request, db: Database): Promise { const { handleDeleteProject: h } = await import("../../../src/server/routes/projects/delete"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function getProjectViaHandler(req: Request, db: Database): Promise { const { handleGetProject: h } = await import("../../../src/server/routes/projects/get"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function listProjectsViaHandler(req: Request, db: Database): Promise { const { handleListProjects: h } = await import("../../../src/server/routes/projects/list"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function restoreProjectViaHandler(req: Request, db: Database): Promise { const { handleRestoreProject: h } = await import("../../../src/server/routes/projects/restore"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function updateProjectViaHandler(req: Request, db: Database): Promise { const { handleUpdateProject: h } = await import("../../../src/server/routes/projects/update"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } // Need db/projects for setup @@ -135,7 +137,7 @@ describe("项目 API 路由", () => { test("POST /api/projects/:id/restore 恢复项目", async () => { await withRouteDb(async (db) => { const project = createTestProject(db, "恢复路由"); - archiveProject(db, project.id); + archiveProject(db, project.id, LOG); const req = new Request(`http://localhost/api/projects/${project.id}/restore`, { method: "POST" }); const res = await restoreProjectViaHandler(req, db); @@ -148,7 +150,7 @@ describe("项目 API 路由", () => { test("DELETE /api/projects/:id 永久删除已归档项目", async () => { await withRouteDb(async (db) => { const project = createTestProject(db, "删除路由"); - archiveProject(db, project.id); + archiveProject(db, project.id, LOG); const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" }); const res = await deleteProjectViaHandler(req, db); diff --git a/tests/server/routes/providers.test.ts b/tests/server/routes/providers.test.ts index a7c018d..703eb59 100644 --- a/tests/server/routes/providers.test.ts +++ b/tests/server/routes/providers.test.ts @@ -6,9 +6,11 @@ import type { Provider, ProviderOption, RuntimeMode } from "../../../src/shared/ import { createModel } from "../../../src/server/db/models"; import { createProvider } from "../../../src/server/db/providers"; +import { createNoopLogger } from "../../../src/server/logger"; import { createMigratedMemoryTestDatabase } from "../../helpers"; const MODE: RuntimeMode = "test"; +const LOG = createNoopLogger(); void mock.module("ai", () => ({ createProviderRegistry: () => ({ @@ -18,48 +20,52 @@ void mock.module("ai", () => ({ async function createProviderViaHandler(req: Request, db: Database): Promise { const { handleCreateProvider: h } = await import("../../../src/server/routes/providers/create"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } function createTestProvider(db: Database, name = "测试供应商", baseUrl = "https://api.test.com/v1"): Provider { - const result = createProvider(db, { - apiKey: "sk-test", - baseUrl, - name, - type: "openai", - }); + const result = createProvider( + db, + { + apiKey: "sk-test", + baseUrl, + name, + type: "openai", + }, + LOG, + ); if ("error" in result) throw new Error(result.error); return result.provider; } async function deleteProviderViaHandler(req: Request, db: Database): Promise { const { handleDeleteProvider: h } = await import("../../../src/server/routes/providers/delete"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function getProviderViaHandler(req: Request, db: Database): Promise { const { handleGetProvider: h } = await import("../../../src/server/routes/providers/get"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function listProviderOptionsViaHandler(_req: Request, db: Database): Promise { const { handleListProviderOptions: h } = await import("../../../src/server/routes/providers/options"); - return h(db, MODE); + return h(db, MODE, LOG); } async function listProvidersViaHandler(req: Request, db: Database): Promise { const { handleListProviders: h } = await import("../../../src/server/routes/providers/list"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function testProviderConfigViaHandler(req: Request, db: Database): Promise { const { handleTestProviderConfig: h } = await import("../../../src/server/routes/providers/test"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function updateProviderViaHandler(req: Request, db: Database): Promise { const { handleUpdateProvider: h } = await import("../../../src/server/routes/providers/update"); - return h(req, db, MODE); + return h(req, db, MODE, LOG); } async function withProviderServer( @@ -182,12 +188,16 @@ describe("供应商 API 路由", () => { test("DELETE /api/providers/:id 存在关联模型时返回 409", async () => { await withRouteDb(async (db) => { const provider = createTestProvider(db, "有关联模型"); - const modelResult = createModel(db, { - capabilities: ["text"], - modelId: "gpt-4o", - name: "GPT-4o", - providerId: provider.id, - }); + const modelResult = createModel( + db, + { + capabilities: ["text"], + modelId: "gpt-4o", + name: "GPT-4o", + providerId: provider.id, + }, + LOG, + ); if ("error" in modelResult) throw new Error(modelResult.error); const req = new Request(`http://localhost/api/providers/${provider.id}`, { method: "DELETE" }); diff --git a/tests/web/components/query-client-logging.test.tsx b/tests/web/components/query-client-logging.test.tsx new file mode 100644 index 0000000..106dc30 --- /dev/null +++ b/tests/web/components/query-client-logging.test.tsx @@ -0,0 +1,103 @@ +import { MutationCache, QueryCache, QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import { describe, expect, test } from "bun:test"; +import { createElement, useRef } from "react"; + +import { useCreateProject } from "../../../src/web/hooks/use-projects"; +import { installFetchMock, jsonResponse } from "../test-utils"; + +describe("QueryClient MutationCache onError", () => { + test("mutation 错误触发 MutationCache onError 回调", async () => { + installFetchMock(() => jsonResponse({ error: "项目名称已存在" }, { status: 409 })); + + const errors: string[] = []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + mutationCache: new MutationCache({ + onError: (error: Error) => { + errors.push(error.message); + }, + }), + }); + + function TestComponent({ onResult }: { onResult: (mutate: () => void) => void }) { + const { mutate } = useCreateProject(); + const called = useRef(false); + + if (!called.current) { + called.current = true; + onResult(() => { + mutate( + { name: "test" }, + { + // eslint-disable-next-line @typescript-eslint/no-empty-function + onError: () => {}, + }, + ); + }); + } + + return null; + } + + render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(TestComponent, { onResult: (fn) => fn() }), + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(errors.length).toBe(1); + expect(errors[0]).toBe("项目名称已存在"); + }); +}); + +describe("QueryClient QueryCache onError", () => { + test("query 错误触发 QueryCache onError 回调", async () => { + installFetchMock(() => new Response("broken", { status: 500 })); + + const errors: string[] = []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + queryCache: new QueryCache({ + onError: (error: Error) => { + errors.push(error.message); + }, + }), + }); + + function TestComponent({ onResult }: { onResult: (trigger: () => void) => void }) { + const called = useRef(false); + + useQuery({ + queryFn: () => Promise.reject(new Error("test query error")), + queryKey: ["test-query-error"], + }); + + if (!called.current) { + called.current = true; + onResult(() => { + // no-op trigger + }); + } + + return null; + } + + render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(TestComponent, { onResult: (fn) => fn() }), + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(errors.length).toBe(1); + expect(errors[0]).toBe("test query error"); + }); +}); diff --git a/tests/web/hooks/on-success-logging.test.tsx b/tests/web/hooks/on-success-logging.test.tsx new file mode 100644 index 0000000..06a6697 --- /dev/null +++ b/tests/web/hooks/on-success-logging.test.tsx @@ -0,0 +1,400 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import { describe, expect, mock, test } from "bun:test"; +import { createElement, useRef } from "react"; + +import { + useCreateModel, + useDeleteModel, + useTestModelConnection, + useUpdateModel, +} from "../../../src/web/hooks/use-models"; +import { + useArchiveProject, + useCreateProject, + useDeleteProject, + useRestoreProject, + useUpdateProject, +} from "../../../src/web/hooks/use-projects"; +import { + useCreateProvider, + useDeleteProvider, + useTestProviderConfig, + useUpdateProvider, +} from "../../../src/web/hooks/use-providers"; +import { installFetchMock, jsonResponse } from "../test-utils"; + +const MODEL = { + autoAdapt: true, + capabilities: ["text"] as string[], + createdAt: "2024-01-01T00:00:00.000Z", + customApiKey: null, + customBaseUrl: null, + description: "测试模型", + id: "m1", + modelId: "gpt-4", + name: "测试模型", + providerId: "prov-1", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +const PROJECT = { + archivedAt: null, + createdAt: "2024-01-01T00:00:00.000Z", + description: "测试", + id: "p1", + name: "测试项目", + status: "active" as const, + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +const PROVIDER = { + createdAt: "2024-01-01T00:00:00.000Z", + id: "prov-1", + name: "测试供应商", + type: "openai" as const, + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +function getLogMessages(spy: ReturnType) { + return spy.mock.calls.map((c) => c[0] as string).filter((s) => s.includes("[Alfred:INFO]")); +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function setupModelFetches(result: unknown) { + installFetchMock((call) => { + if (call.method === "DELETE") return new Response(null, { status: 204 }); + if (call.url.includes("test")) return jsonResponse({ modelTestResponse: { message: "ok", ok: true } }); + return jsonResponse({ model: result }, { status: 201 }); + }); +} + +function setupProjectFetches(result: unknown) { + installFetchMock((call) => { + if (call.method === "DELETE") return new Response(null, { status: 204 }); + if (call.url.includes("archive")) return jsonResponse({ project: result }); + if (call.url.includes("restore")) return jsonResponse({ project: result }); + return jsonResponse({ project: result }, { status: 201 }); + }); +} + +function setupProviderFetches(result: unknown) { + installFetchMock((call) => { + if (call.method === "DELETE") return new Response(null, { status: 204 }); + if (call.url.includes("test")) return jsonResponse({ providerTestResponse: { message: "ok", ok: true } }); + return jsonResponse({ provider: result }, { status: 201 }); + }); +} + +function spyConsoleLog() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spy = mock((..._args: any[]) => {}); + const orig = console.log; + console.log = spy; + return { orig, restore: () => (console.log = orig), spy }; +} + +describe("useProjects onSuccess 日志", () => { + const qc = makeQueryClient(); + + test("create onSuccess", async () => { + setupProjectFetches(PROJECT); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useCreateProject(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ name: "x" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/项目创建成功/); + restore(); + }); + + test("update onSuccess", async () => { + setupProjectFetches(PROJECT); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useUpdateProject(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ data: { name: "y" }, id: "p1" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/项目更新成功/); + restore(); + }); + + test("delete onSuccess", async () => { + setupProjectFetches(PROJECT); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useDeleteProject(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate("p1")); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/项目删除成功/); + restore(); + }); + + test("archive onSuccess", async () => { + setupProjectFetches(PROJECT); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useArchiveProject(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate("p1")); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/项目归档成功/); + restore(); + }); + + test("restore onSuccess", async () => { + setupProjectFetches(PROJECT); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useRestoreProject(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate("p1")); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/项目恢复成功/); + restore(); + }); +}); + +describe("useModels onSuccess 日志", () => { + const qc = makeQueryClient(); + + test("create onSuccess", async () => { + setupModelFetches(MODEL); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useCreateModel(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ capabilities: ["text"], modelId: "gpt-4", name: "x", providerId: "p1" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/模型创建成功/); + restore(); + }); + + test("update onSuccess", async () => { + setupModelFetches(MODEL); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useUpdateModel(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ data: { name: "y" }, id: "m1" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/模型更新成功/); + restore(); + }); + + test("delete onSuccess", async () => { + setupModelFetches(MODEL); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useDeleteModel(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate("m1")); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/模型删除成功/); + restore(); + }); + + test("test onSuccess", async () => { + setupModelFetches(MODEL); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useTestModelConnection(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ modelId: "gpt-4", providerId: "p1" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + restore(); + // useTestModelConnection has no onSuccess logger + const infoCalls = spy.mock.calls.filter((c) => typeof c[0] === "string" && c[0].includes("[Alfred:INFO]")); + expect(infoCalls.length).toBe(0); + }); +}); + +describe("useProviders onSuccess 日志", () => { + const qc = makeQueryClient(); + + test("create onSuccess", async () => { + setupProviderFetches(PROVIDER); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useCreateProvider(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ apiKey: "k", baseUrl: "http://x", name: "x", type: "openai" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/供应商创建成功/); + restore(); + }); + + test("update onSuccess", async () => { + setupProviderFetches(PROVIDER); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useUpdateProvider(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ data: { name: "y" }, id: "prov-1" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/供应商更新成功/); + restore(); + }); + + test("delete onSuccess", async () => { + setupProviderFetches(PROVIDER); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useDeleteProvider(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate("prov-1")); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + const msgs = getLogMessages(spy); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatch(/供应商删除成功/); + restore(); + }); + + test("test onSuccess", async () => { + setupProviderFetches(PROVIDER); + const { restore, spy } = spyConsoleLog(); + + function T({ onResult }: { onResult: (fn: () => void) => void }) { + const { mutate } = useTestProviderConfig(); + const c = useRef(false); + if (!c.current) { + c.current = true; + onResult(() => mutate({ apiKey: "k", baseUrl: "http://x", name: "x", type: "openai" })); + } + return null; + } + render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); + await new Promise((r) => setTimeout(r, 200)); + + restore(); + // useTestProviderConfig has no onSuccess logger + const infoMsgs = spy.mock.calls.filter((c) => typeof c[0] === "string" && String(c[0]).includes("[Alfred:INFO]")); + expect(infoMsgs.length).toBe(0); + }); +}); diff --git a/tests/web/utils/api.test.ts b/tests/web/utils/api.test.ts new file mode 100644 index 0000000..40870f6 --- /dev/null +++ b/tests/web/utils/api.test.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { describe, expect, mock, test } from "bun:test"; + +import { handleResponse, handleVoidResponse } from "../../../src/web/utils/api"; + +function expectRejects(action: () => Promise, message: string) { + return action().then( + () => { + throw new Error("expected rejection"); + }, + (error: unknown) => { + expect((error as Error).message).toBe(message); + }, + ); +} + +function non200Response(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status, + }); +} + +function spyConsoleLog() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spy = mock((..._args: any[]) => {}); + const orig = console.log; + console.log = spy; + return { orig, restore: () => (console.log = orig), spy }; +} + +function spyConsoleWarn() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spy = mock((..._args: any[]) => {}); + const orig = console.warn; + console.warn = spy; + return { orig, restore: () => (console.warn = orig), spy }; +} + +describe("api.ts 日志行为", () => { + test("handleResponse 非 200 响应输出 warn 日志", async () => { + const { restore, spy } = spyConsoleWarn(); + + const response = non200Response({ error: "项目名称已存在" }, 409); + await expectRejects(() => handleResponse(response, (d) => d), "项目名称已存在"); + + restore(); + + expect(spy).toHaveBeenCalledTimes(1); + const msg = spy.mock.calls[0]![0] as string; + const data = spy.mock.calls[0]![1] as Record; + expect(msg).toMatch(/\[Alfred:WARN\] API request failed/); + expect(data).toBeObject(); + expect(data).toHaveProperty("duration"); + expect(data).toHaveProperty("errorBody", "项目名称已存在"); + expect(data).toHaveProperty("status", 409); + expect(data).toHaveProperty("url"); + }); + + test("handleVoidResponse 非 200 响应输出 warn 日志", async () => { + const { restore, spy } = spyConsoleWarn(); + + const response = non200Response({ error: "服务器错误" }, 500); + await expectRejects(() => handleVoidResponse(response), "服务器错误"); + + restore(); + + expect(spy).toHaveBeenCalledTimes(1); + const msg = spy.mock.calls[0]![0] as string; + const data = spy.mock.calls[0]![1] as Record; + expect(msg).toMatch(/\[Alfred:WARN\] API request failed/); + expect(data).toHaveProperty("duration"); + expect(data).toHaveProperty("errorBody", "服务器错误"); + expect(data).toHaveProperty("status", 500); + }); + + test("handleResponse 非 JSON 错误响应回退到 HTTP 状态", async () => { + const { restore, spy } = spyConsoleWarn(); + + const response = new Response("broken", { status: 503 }); + await expectRejects(() => handleResponse(response, (d) => d), "HTTP 503"); + + restore(); + + expect(spy).toHaveBeenCalledTimes(1); + const data = spy.mock.calls[0]![1] as Record; + expect(data).toHaveProperty("errorBody", "HTTP 503"); + expect(data).toHaveProperty("status", 503); + }); + + test("handleResponse 成功响应在 DEV 模式输出 debug 日志", async () => { + const { restore, spy } = spyConsoleLog(); + + (import.meta.env as Record)["DEV"] = true; + + const response = new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + await handleResponse(response, (d) => d); + + (import.meta.env as Record)["DEV"] = undefined; + restore(); + + expect(spy).toHaveBeenCalledTimes(1); + const msg = spy.mock.calls[0]![0] as string; + const data = spy.mock.calls[0]![1] as Record; + expect(msg).toMatch(/\[Alfred:DEBUG\] API request/); + expect(data).toBeObject(); + expect(data).toHaveProperty("duration"); + expect(data).toHaveProperty("status", 200); + expect(data).toHaveProperty("url"); + }); + + test("handleVoidResponse 成功响应在 DEV 模式输出 debug 日志", async () => { + const { restore, spy } = spyConsoleLog(); + + (import.meta.env as Record)["DEV"] = true; + + const response = new Response(null, { status: 204 }); + await handleVoidResponse(response); + + (import.meta.env as Record)["DEV"] = undefined; + restore(); + + expect(spy).toHaveBeenCalledTimes(1); + const msg = spy.mock.calls[0]![0] as string; + const data = spy.mock.calls[0]![1] as Record; + expect(msg).toMatch(/\[Alfred:DEBUG\] API request/); + expect(data).toHaveProperty("duration"); + expect(data).toHaveProperty("status", 204); + expect(data).toHaveProperty("url"); + }); +});