feat: 全栈 Logger 依赖注入 — DB/Route/AI 层传参 + 前端 Logger + 测试更新 + 归档 add-frontend-logger
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -24,6 +24,8 @@ export default tseslint.config(
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".agents/**",
|
||||
".worktrees/**",
|
||||
"bin/**",
|
||||
"bun.lock",
|
||||
"data/**",
|
||||
],
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: fast-drive
|
||||
created: 2026-06-01
|
||||
@@ -1,224 +0,0 @@
|
||||
## 背景
|
||||
|
||||
当前项目后端通过 `src/server/logger.ts` 提供了统一的 `Logger` 接口和四种实现(PinoLoggerWrapper、ConsoleFallbackLogger、NoopLogger、MemoryLogger),通过依赖注入在运行时代码中使用,并通过 ESLint 的 `no-restricted-syntax` 规则禁止 `src/server/**/*.ts` 直接使用 `console.*`。
|
||||
|
||||
前端目前没有统一的日志处理。唯一的 console 使用在 `ErrorBoundary.tsx:22` 的 `console.error`,业务错误反馈通过各组件零散调用 `App.useApp().message.error()` 完成。ESLint 对前端 `console.*` 没有任何限制。
|
||||
|
||||
用户希望:
|
||||
1. 构造统一的前端日志工具,镜像后端的接口设计和多实现模式
|
||||
2. 业务错误(warn 及以上)同时通过 antd notification 和 console 输出
|
||||
3. 避免前端调试日志散落为原始 `console.*` 调用
|
||||
4. ESLint 按前后端分区管理 console 限制
|
||||
|
||||
## 讨论记录
|
||||
|
||||
- **已确认结论**:自己封装前端 Logger,不引入第三方日志库(loglevel / consola 等)。理由:后端已有成熟的接口+多实现模式可直接镜像,核心逻辑不到 100 行,引入库属于过度工程
|
||||
- **用户偏好**:
|
||||
- notification 仅展示 warn 及以上的业务错误信息,debug/info 不给用户看,写入文档作为开发引导
|
||||
- `child()` 作用域日志首批实现
|
||||
- `data` 参数不做 JSON 序列化,直接透传给 console 以保留 Error 堆栈
|
||||
- 生产环境(`import.meta.env.PROD`)屏蔽 warn 以下级别的日志输出
|
||||
- Logger 前缀 `[Alfred]` 硬编码在代码中
|
||||
- **约束**:
|
||||
- 不新增依赖
|
||||
- 符合 `docs/development/frontend.md` 的前端规约
|
||||
- ESLint 保持单文件 + file glob 分区模式
|
||||
- **被否决方案**:
|
||||
- 引入 pino 浏览器版:体积偏大(~15KB gzip),且 pino 的 JSON 结构化在浏览器 DevTools 中不如纯文本直观
|
||||
- 引入 loglevel:API 简洁但无原生 Sink 模式,无法优雅集成 antd notification
|
||||
- 引入 consola:功能全但与后端 pino 风格不一致,增加理解成本
|
||||
|
||||
## 需求
|
||||
|
||||
| 需求 | 验收标准 |
|
||||
| ---- | -------- |
|
||||
| 统一的前端 Logger 接口 | `src/web/utils/logger.ts` 导出 `Logger` 接口,含 `debug/info/warn/error/child/setLevel` 方法 |
|
||||
| Console 输出 | 所有级别日志均输出到 console(debug→log, info→log, warn→warn, error→error),带有 `[Alfred:LEVEL]` 前缀和可选附加数据 |
|
||||
| antd notification 集成 | `useLogger()` hook 自动将 warn 映射为 `message.warning()`,error 映射为 `message.error()`;debug/info 不触发 notification |
|
||||
| 生产环境静默 | `import.meta.env.PROD` 时 debug/info 不输出到 console 也不触发 notification |
|
||||
| 作用域日志 | `child(bindings)` 返回带绑定的子 Logger,日志消息自动追加 `[key=value]` 后缀 |
|
||||
| MemoryLogger 测试替身 | 提供 `createMemoryLogger()`,将日志条目存储在内存数组中以供测试断言 |
|
||||
| NoopLogger 测试替身 | 提供 `createNoopLogger()`,静默丢弃所有日志 |
|
||||
| ESLint 前端 console 禁止 | `eslint.config.js` 新增规则:`src/web/**/*.{ts,tsx}` 禁止 `console.*`,`logger.ts` 除外 |
|
||||
| Error.data 透传 | `logger.error("失败", { error: someError })` 保留 Error 对象的堆栈信息,不做 JSON 序列化 |
|
||||
| 前缀硬编码 | 日志前缀固定为 `[Alfred]`,不使用模块路径检测 |
|
||||
| 开发文档补充 | `docs/development/frontend.md` 新增日志模块章节,明确 notification 仅用于 warn+error 的红线 |
|
||||
|
||||
## 目标 / 非目标
|
||||
|
||||
**目标:**
|
||||
- 建立与后端一致的 `Logger` 接口和实现模式
|
||||
- 提供 `useLogger()` React hook,在组件内自动集成 antd notification
|
||||
- 通过 ESLint 强制前端使用统一 Logger,杜绝散落的 `console.*`
|
||||
- 支持测试替身(MemoryLogger / NoopLogger)用于前端单元测试
|
||||
|
||||
**非目标:**
|
||||
- 不引入任何第三方日志依赖
|
||||
- 不实现前端日志持久化到文件(浏览器无此能力)
|
||||
- 不改造所有现有组件(渐进式迁移,先建工具再逐步替换)
|
||||
- 不修改后端 Logger 接口或 ESLint 后端规则
|
||||
- 不影响 `message.success()` / `message.info()` 等业务成功反馈的调用方式
|
||||
|
||||
## 执行约束
|
||||
|
||||
- **依赖限制**:零新增依赖,纯 TypeScript + React hooks
|
||||
- **约束**:
|
||||
- 遵守 `docs/development/frontend.md` 全部规约
|
||||
- `useLogger()` 内部使用 `App.useApp()` 获取 antd message 实例
|
||||
- 仅 `warn` 和 `error` 级别调用 antd notification
|
||||
- `data` 参数不加工,直接透传
|
||||
- 生产环境 `import.meta.env.PROD` 时 debug/info 静默
|
||||
- **质量门禁**:
|
||||
- `bun test` 全部通过
|
||||
- `bun run lint` 零违规
|
||||
- `bun run typecheck` 零错误
|
||||
- **文档 / 沟通**:
|
||||
- 更新 `docs/development/frontend.md`,新增"日志模块"章节
|
||||
- 新增的 ESLint 规则需在 ESLint 报错信息中包含明确的替代方案(`useLogger()` / `createConsoleLogger()`)
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 |
|
||||
| ---- | -------------------- | -------- | ---- |
|
||||
| 核心新增 | `src/web/utils/logger.ts` | 新建,Logger 接口 + ConsoleLogger + DefaultLogger + NoopLogger + MemoryLogger + Sink 概念 | 约 150 行 |
|
||||
| React 集成 | `src/web/hooks/use-logger.ts` | 新建,`useLogger()` hook | 约 30 行 |
|
||||
| ESLint | `eslint.config.js` | 新增前端 console 禁止区块,排除 `logger.ts` | 约 15 行 |
|
||||
| 错误边界 | `src/web/components/ErrorBoundary.tsx` | `console.error` 改为 `createConsoleLogger().error()` | 首处改造,只能使用 `createConsoleLogger()` 不可用 `useLogger()` |
|
||||
| 错误边界测试 | `tests/web/components/ErrorBoundary.test.tsx` | 断言字符串从 `"渲染错误:"` 更新为 `"[Alfred:ERROR] 渲染错误"` | 适配新日志格式 |
|
||||
| 开发文档 | `docs/development/frontend.md` | 新增"日志模块"章节 | 描述接口、用法、notification 红线 |
|
||||
| 测试 | `tests/web/utils/logger.test.ts` | 新建,Logger 各实现的单元测试 | 覆盖 ConsoleSink(级别映射/PROD静默/child绑定/data透传)、AntdMessageSink(warn→warning/error→error/debug+info不触发)、MemoryLogger、NoopLogger、setLevel运行时调整、嵌套child追加和覆盖 |
|
||||
| 测试 | `tests/web/hooks/use-logger.test.ts` | 新建,useLogger hook 单元测试 | 覆盖 antd notification 调用映射 |
|
||||
|
||||
## 决策
|
||||
|
||||
| 决策 | 理由 | 已否决替代方案 |
|
||||
| ---- | ---- | -------------- |
|
||||
| 自己封装而非引入库 | 后端已有成熟模式可镜像,核心逻辑不足 100 行;引入库需额外审计和升级维护;项目原则不引入额外依赖 | pino(浏览器)/loglevel/consola |
|
||||
| 裸对象透传而不做 JSON 序列化 | 保留 Error 的 `stack` 属性,DevTools 的 Error 对象交互(点击跳转源代码)是重要调试体验 | `JSON.stringify(data)` |
|
||||
| `useLogger()` 内部使用 `App.useApp()` | 与项目 Ant Design 使用规范一致("需要 message 时在 ConfigProvider 内包裹 App,组件内通过 App.useApp() 获取") | 全局 message 实例 / 独立 Context |
|
||||
| ESLint 保持单文件 + glob 分区 | 前后端共享 TS/import/perfectionist 规则,分文件产生重复;glob 已提供足够精细的作用域控制 | 拆分为 `eslint.server.js` 和 `eslint.web.js` |
|
||||
| 生产环境屏蔽 warn 以下级别 | 减少生产环境的 console 噪音,debug 和 info 是面向开发者的调试日志 | 全部保留 / 全部去除 |
|
||||
| notification 仅 warn + error | 避免过度弹提示干扰用户,debug/info 是开发者调试日志不应暴露给用户 | 全级别都走 notification |
|
||||
|
||||
## 日志模块设计
|
||||
|
||||
### Logger 接口
|
||||
|
||||
```ts
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface Logger {
|
||||
debug(message: string, data?: unknown): void;
|
||||
info(message: string, data?: unknown): void;
|
||||
warn(message: string, data?: unknown): void;
|
||||
error(message: string, data?: unknown): void;
|
||||
child(bindings: Record<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.warning,error→message.error |
|
||||
| notification 不映射 info | use-logger 测试:info 不调用 message 任何方法 |
|
||||
| ESLint 规则生效 | 手动验证:在 `src/web/` 非 logger.ts 文件写入 `console.log` 触发 lint 报错 |
|
||||
| 类型检查 | `bun run typecheck` 零错误 |
|
||||
| Error 堆栈保留 | 手动验证:`logger.error("msg", { error: new Error("test") })` 在 DevTools 中可展开查看堆栈 |
|
||||
| 文档完整性 | 检查 `docs/development/frontend.md` 包含日志模块章节,含 notification 红线说明 |
|
||||
|
||||
## 风险 / 权衡
|
||||
|
||||
- [风险] `useLogger()` 依赖 `App.useApp()`,仅能在 `AntApp` 组件内部使用,不可在 utils 纯函数中调用 → 纯函数使用 `createConsoleLogger()`,文档明确说明两种使用场景
|
||||
- [风险] antd notification 的 `message` 实例在 React 组件外不可用 → 设计上 `useLogger()` 本身就是 React hook,外部场景用 ConsoleLogger 是预期的
|
||||
- [风险] 生产环境屏蔽 debug/info 可能导致线上排查困难 → 可在后续通过 URL 参数或配置开关临时开启,当前不实现以保持简单
|
||||
- [风险] 现有组件渐进式改造可能长期不完成 → ESLint 规则确保不再新增 `console.*` 调用,现有代码中的 `console.*`(仅 ErrorBoundary 一处)首批改造
|
||||
|
||||
## 待解决问题
|
||||
|
||||
| 状态 | 问题 | 所需决策 |
|
||||
| ---- | ---- | -------- |
|
||||
| 无 | 无待解决问题。 | 无需决策 |
|
||||
@@ -1,45 +0,0 @@
|
||||
## 1. 上下文审查
|
||||
|
||||
- [x] 1.1 阅读 design.md,确认范围、需求、决策、执行约束和待解决问题
|
||||
- [x] 1.2 审查后端 `src/server/logger.ts` 的 Logger 接口和实现模式,确保前端镜像的一致性
|
||||
- [x] 1.3 审查 `eslint.config.js` 当前 rules 结构,确认新增前端 console 禁止规则的位置和写法
|
||||
|
||||
## 2. 核心日志模块
|
||||
|
||||
- [x] 2.1 新建 `src/web/utils/logger.ts`:定义 `LogLevel` 类型、`Logger` 接口(debug/info/warn/error/child/setLevel)、`Sink` 接口
|
||||
- [x] 2.2 实现 `ConsoleSink`:按级别映射到 console.log/warn/error,前缀 `[Alfred:LEVEL]`,data 透传不序列化
|
||||
- [x] 2.3 实现 `NoopLogger`:所有方法空实现,child 返回自身,静默丢弃日志
|
||||
- [x] 2.4 实现 `MemoryLogger`:将日志条目存入 `entries` 数组,用于测试断言
|
||||
- [x] 2.5 实现 `ConsoleLogger`:组合 ConsoleSink,生产环境(`import.meta.env.PROD`)屏蔽 debug/info
|
||||
- [x] 2.6 实现 `AntdMessageSink`:接收 antd MessageInstance,warn→message.warning,error→message.error,debug/info 不触发
|
||||
- [x] 2.7 实现 `DefaultLogger`:组合多个 Sink(ConsoleSink + AntdMessageSink),支持 `child()` 作用域绑定
|
||||
|
||||
## 3. React 集成
|
||||
|
||||
- [x] 3.1 新建 `src/web/hooks/use-logger.ts`:`useLogger()` 内部调用 `App.useApp()` 获取 message 实例,组装 DefaultLogger + ConsoleSink + AntdMessageSink
|
||||
- [x] 3.2 改造 `src/web/components/ErrorBoundary.tsx`:`console.error` 替换为 `createConsoleLogger().error()`
|
||||
|
||||
## 4. ESLint 规则
|
||||
|
||||
- [x] 4.1 在 `eslint.config.js` 新增 `src/web/**/*.{ts,tsx}` 的 `no-restricted-syntax` 规则,禁止 `console.*`,排除 `logger.ts`
|
||||
- [x] 4.2 运行 `bun run lint` 验证规则生效且无其他文件违规
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- [x] 5.1 新建 `tests/web/utils/logger.test.ts`:测试 ConsoleLogger/MemoryLogger/NoopLogger 的 debug/info/warn/error 级别输出
|
||||
- [x] 5.2 测试生产环境静默:MemoryLogger 在 `isProduction=true` 时 debug/info 不记录
|
||||
- [x] 5.3 测试 `child()` 作用域:绑定后的 Logger 在日志消息中携带对应键值,嵌套 child 追加(同 key 覆盖)
|
||||
- [x] 5.4 新建 `tests/web/hooks/use-logger.test.ts`:测试 useLogger 返回含 ConsoleSink 的 Logger 实例,warn/error 正常输出到 console(AntdMessageSink 映射在 logger.test.ts 的 AntdMessageSink describe 中测试)
|
||||
|
||||
## 6. 文档
|
||||
|
||||
- [x] 6.1 更新 `docs/development/frontend.md`,新增"日志模块"章节:Logger 接口、使用方式、notification 红线(仅 warn+error)
|
||||
- [x] 6.2 确认 `docs/development/frontend.md` 新增的日志模块章节包含 notification 红线、使用场景区分和 ErrorBoundary 特殊说明
|
||||
|
||||
## 7. 质量门禁
|
||||
|
||||
- [x] 7.1 运行 `bun test` 确保所有新增和已有测试通过
|
||||
- [x] 7.2 运行 `bun run typecheck` 确保零类型错误
|
||||
- [x] 7.3 运行 `bun run lint` 确保零 lint 违规
|
||||
- [ ] 7.4 手动验证:在 `src/web/` 非 logger.ts 文件临时写入 `console.log("test")` 确认 ESLint 报错
|
||||
- [ ] 7.5 手动验证:在浏览器 DevTools 中调用 `logger.error("msg", { error: new Error("test") })` 确认 Error 堆栈可展开
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'"),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 }, "标题兜底更新失败");
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("模型连接成功");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("不能为空");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 路由", () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
103
tests/web/components/query-client-logging.test.tsx
Normal file
103
tests/web/components/query-client-logging.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
400
tests/web/hooks/on-success-logging.test.tsx
Normal file
400
tests/web/hooks/on-success-logging.test.tsx
Normal 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
134
tests/web/utils/api.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user