feat: 全栈 Logger 依赖注入 — DB/Route/AI 层传参 + 前端 Logger + 测试更新 + 归档 add-frontend-logger

This commit is contained in:
2026-06-01 20:32:19 +08:00
parent 4c72754739
commit 844562303c
60 changed files with 1648 additions and 778 deletions

View File

@@ -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() {

View File

@@ -24,6 +24,8 @@ export default tseslint.config(
".claude/**",
".codex/**",
".agents/**",
".worktrees/**",
"bin/**",
"bun.lock",
"data/**",
],

View File

@@ -1,2 +0,0 @@
schema: fast-drive
created: 2026-06-01

View File

@@ -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 中不如纯文本直观
- 引入 loglevelAPI 简洁但无原生 Sink 模式,无法优雅集成 antd notification
- 引入 consola功能全但与后端 pino 风格不一致,增加理解成本
## 需求
| 需求 | 验收标准 |
| ---- | -------- |
| 统一的前端 Logger 接口 | `src/web/utils/logger.ts` 导出 `Logger` 接口,含 `debug/info/warn/error/child/setLevel` 方法 |
| Console 输出 | 所有级别日志均输出到 consoledebug→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<string, unknown>): Logger;
setLevel(level: LogLevel): void;
}
```
`data``unknown`,不做 JSON 序列化,直接透传给 console保留 Error 堆栈和 DevTools 交互能力。
### Sink 接口
```ts
interface Sink {
write(level: LogLevel, message: string, data: unknown, bindings: Record<string, unknown>): 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()`,仅可在 `<App>` 子树内的组件中使用。`ErrorBoundary` 位于 `<App>` 之上(见 `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.warningerror→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 一处)首批改造
## 待解决问题
| 状态 | 问题 | 所需决策 |
| ---- | ---- | -------- |
| 无 | 无待解决问题。 | 无需决策 |

View File

@@ -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 MessageInstancewarn→message.warningerror→message.errordebug/info 不触发
- [x] 2.7 实现 `DefaultLogger`:组合多个 SinkConsoleSink + 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 正常输出到 consoleAntdMessageSink 映射在 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 堆栈可展开

View File

@@ -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) },
});
}

View File

@@ -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 };
}
}

View File

@@ -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'"),
}),
});

View File

@@ -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();

View File

@@ -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;
}
}
})();

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<Response> {
export async function handleCreateConversation(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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 });
}

View File

@@ -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];

View File

@@ -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];

View File

@@ -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];

View File

@@ -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 }, "标题兜底更新失败");
}

View File

@@ -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<Response> {
export async function handleUpdateConversation(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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 });
}

View File

@@ -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<Response> {
export async function handleCreateModel(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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 });
}

View File

@@ -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];

View File

@@ -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");

View File

@@ -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<Response> {
export async function handleTestModelConfig(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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<Response> {
export async function handleUpdateModel(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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 });
}

View File

@@ -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<Response> {
export async function handleCreateProject(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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 });
}

View File

@@ -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];

View File

@@ -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");

View File

@@ -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 });
}

View File

@@ -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<Response> {
export async function handleUpdateProject(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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<Response> {
export async function handleCreateProvider(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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 });
}

View File

@@ -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];

View File

@@ -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");

View File

@@ -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 });
}

View File

@@ -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<Response> {
const validated = await readProviderConfig(req, mode);
export async function handleTestProviderConfig(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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<CreateProviderRequest | Response> {
async function readProviderConfig(
req: Request,
mode: RuntimeMode,
logger: Logger,
): Promise<CreateProviderRequest | Response> {
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 });
}

View File

@@ -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<Response> {
export async function handleUpdateProvider(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
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 });
}

View File

@@ -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,

View File

@@ -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 | string>(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 });
});
}}
/>
</div>
@@ -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 });
});
}}
/>
</div>

View File

@@ -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<Conversation> {
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<Conversation> {
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<ConversationListResponse> {
@@ -42,10 +54,19 @@ export async function updateConversation(
conversationId: string,
data: UpdateConversationRequest,
): Promise<Conversation> {
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;
}
}

View File

@@ -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<Model> {
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 });
},
});

View File

@@ -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<Project> {
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 });
},
});

View File

@@ -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<Provider> {
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 });
},
});

View File

@@ -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(
</ErrorBoundary>
</StrictMode>,
);
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 });
});

View File

@@ -1,15 +1,49 @@
import { createConsoleLogger } from "./logger";
const logger = createConsoleLogger();
export async function handleResponse<T>(response: Response, extract: (data: unknown) => T): Promise<T> {
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<void> {
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,
});
}
}

View File

@@ -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("模型连接成功");

View File

@@ -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);

View File

@@ -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("不能为空");
});

View File

@@ -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);

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
return h(req, db, MODE, createNoopLogger());
return h(req, db, MODE, LOG);
}
describe("聊天 API 路由", () => {

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<void>): Promise<void> {

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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);

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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" });

View File

@@ -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");
});
});

View File

@@ -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<typeof mock>) {
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);
});
});

134
tests/web/utils/api.test.ts Normal file
View File

@@ -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<unknown>, 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>)["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<string, unknown>)["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<string, unknown>;
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<string, unknown>)["DEV"] = true;
const response = new Response(null, { status: 204 });
await handleVoidResponse(response);
(import.meta.env as Record<string, unknown>)["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<string, unknown>;
expect(msg).toMatch(/\[Alfred:DEBUG\] API request/);
expect(data).toHaveProperty("duration");
expect(data).toHaveProperty("status", 204);
expect(data).toHaveProperty("url");
});
});