Compare commits

...

9 Commits

Author SHA1 Message Date
db40d04dc5 refactor(db): 统一数据库 schema — 软删除、命名规范、约束标准化
- 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at
- models.model_id 重命名为 external_id,消除语义混淆
- conversations.model_id 改为可空(模型为建议而非绑定)
- messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联
- 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除)
- 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试
- 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role)
- DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护
- 路由/前端/测试全量适配 externalId 重命名及类型变更
2026-06-05 01:02:23 +08:00
e25b2537fd fix: 消除并发测试中的 tool 导出竞争和 SQLite 目录碰撞 2026-06-04 18:50:58 +08:00
6f547560d1 refactor: 统一管理页面布局 — FilterToolbar + usePageSearchParams + parseListParams 2026-06-04 17:25:36 +08:00
61b479e2be feat: 拆分模型/供应商为独立路由页面,侧边栏支持 SubMenu 分组 2026-06-04 11:11:32 +08:00
f67cfa84ef feat: 用自定义侧边栏替换聊天室 Conversations 组件,提取公共 SidebarGroup 和 date-group 2026-06-04 00:46:57 +08:00
dc7d9e83b8 feat: 收集箱侧边栏UI美化 — 自定义滚动条、隐藏空分组、优化列表项间距 2026-06-03 22:31:49 +08:00
525278870f style: 收集箱侧边栏对齐聊天室布局模式,按钮筛选栏区域独立padding,列表贴边 2026-06-03 21:46:44 +08:00
eb93de52d8 fix: 修正 markdown-to-jsx 导入方式 + 新增 formatDateLabel 日期工具函数
- TextPart: default import → named import
- MaterialCard: 使用 formatDateLabel 显示今天/昨天/日期
- 清理旧测试文件,新增 ResourceTable 测试
2026-06-03 21:08:00 +08:00
83cc28fe1b chore: 更新 skills-lock.json computed hashes 2026-06-03 18:59:39 +08:00
106 changed files with 5284 additions and 2697 deletions

View File

@@ -5,14 +5,15 @@
## 目录索引 ## 目录索引
```text ```text
docs/ docs/
README.md
development/
README.md README.md
architecture.md development/
backend.md README.md
frontend.md architecture.md
release.md backend.md
crud.md
frontend.md
release.md
user/ user/
README.md README.md
usage.md usage.md
@@ -39,18 +40,18 @@ docs/
## 按任务阅读路径 ## 按任务阅读路径
| 任务 | 必读文档 | | 任务 | 必读文档 |
| -------------------------------- | ----------------------------------------------------------------------------------- | | -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 | | 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) | | 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) | | 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) | | 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) | | 修改前端 CRUD 管理页面 | [开发文档](development/README.md)、[前端开发](development/frontend.md)、[CRUD 模式](development/crud.md) |
| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) | | 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) | | 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) | | 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) | | 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) | | 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
## 文档归属矩阵 ## 文档归属矩阵
@@ -63,6 +64,7 @@ docs/
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` | | 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` | | 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` | | 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
| 管理页面 CRUD 模式筛选工具条、URL 同步、分页排序约定) | `docs/development/crud.md` |
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` | | 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
| 快速开始、安装配置 | `docs/user/usage.md` | | 快速开始、安装配置 | `docs/user/usage.md` |
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` | | YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |

View File

@@ -217,6 +217,9 @@ features/<name>/
- 输入输出类型来自 `src/shared/api.ts` - 输入输出类型来自 `src/shared/api.ts`
- 列表查询使用 `paginateQuery()`,不重复实现分页。 - 列表查询使用 `paginateQuery()`,不重复实现分页。
- 列名 snake_caseTS 类型 camelCaseDrizzle schema 映射。 - 列名 snake_caseTS 类型 camelCaseDrizzle schema 映射。
- 软删除:所有业务表使用 `deleted_at` 列,通过 `notDeleted(table)``paginateQuery({ softDelete })` 过滤。`deleted_at` 不暴露到 API 层。
- 唯一性:无数据库级 UNIQUE 约束DAO 层应用校验(同字段 + `deleted_at IS NULL`)。
- 表定义:通过 `helpers.ts``baseColumns` 展开 id/created_at/updated_at/deleted_at禁止直接 `sqliteTable()`ESLint 强制)。
### AI 调用层 ### AI 调用层

View File

@@ -8,6 +8,8 @@
- `response.ts``createApiError(error, status)``createHeaders(mode, init)``createMetaResponse(version)``formatDuration(ms)``jsonResponse(body, options)` - `response.ts``createApiError(error, status)``createHeaders(mode, init)``createMetaResponse(version)``formatDuration(ms)``jsonResponse(body, options)`
- `url.ts``parseIdFromUrl(url)` - `url.ts``parseIdFromUrl(url)`
- `list-params.ts``parseListParams(url, mode, options?)` — 统一校验分页/排序参数,替代 validatePagination
- `pagination.ts``paginateQuery()` — Drizzle 分页查询封装
`src/server/middleware/`: `src/server/middleware/`:
@@ -18,9 +20,14 @@
SQLite + bun:sqlite + Drizzle ORM。 SQLite + bun:sqlite + Drizzle ORM。
- `src/server/db/schema.ts`Drizzle 表结构,列名 snake_caseTS 类型 camelCase。 - `src/server/db/schema.ts`Drizzle 表结构,列名 snake_caseTS 类型 camelCase。所有业务表通过 `helpers.ts``baseColumns` 获取 id/created_at/updated_at/deleted_at。
- `src/server/db/connection.ts``createDatabase(dataDir, logger)` 打开 `alfred.db`PRAGMAforeign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转为 Drizzle 实例。`paginateQuery()` 分页工具 - `src/server/db/helpers.ts``baseColumns` 常量id、createdAt、updatedAt、deletedAt+ Drizzle 构建器再导出。`src/server/db/` 内禁止直接从 `drizzle-orm/sqlite-core` 导入 `sqliteTable`ESLint 强制)
- Migration开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/backups/`,事务中执行,失败回滚 - `src/server/db/connection.ts``createDatabase(dataDir, logger)` 打开 `alfred.db`PRAGMAforeign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转 Drizzle 实例(`DrizzleDB` 类型)。工具函数:`timestamp()``notDeleted(table)``softDeleteRecord(db, table, id)``paginateQuery()`(支持 `softDelete` 参数自动过滤已删除行)
- Migration开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/backups/`,事务中执行(迁移期间临时关闭外键检查),失败回滚。
### 软删除
所有业务表projects、providers、models、conversations、materials、messages使用 `deleted_at` 列实现软删除,不暴露给 API 层。DAO 查询通过 `notDeleted(table)``paginateQuery({ softDelete })` 自动过滤已删除行。唯一性校验在应用层完成(同名 + `deleted_at IS NULL`),无数据库级 UNIQUE 约束。级联软删除:删除项目 → 级联软删除会话(→ 消息)+ 素材;删除会话 → 级联软删除消息;删除供应商 → 需无未删除模型。
### 数据访问函数 ### 数据访问函数
@@ -36,7 +43,7 @@ SQLite + bun:sqlite + Drizzle ORM。
## AI 服务层 ## AI 服务层
- `src/server/ai/types.ts``AIProviderConfig`name、type、baseUrl、apiKey`AIModelConfig`providerId、modelId、capabilities - `src/server/ai/types.ts``AIProviderConfig`name、type、baseUrl、apiKey`AIModelConfig`providerId、modelId、capabilitiesAI 层 `modelId` 对应 DB 层 `Model.externalId`
- `src/server/ai/registry.ts` - `src/server/ai/registry.ts`
- `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry每次调用重建不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。 - `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry每次调用重建不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。
- `testProviderConnection(config, logger)` — 测试 Base URL 可达性 + `/models` 接口 - `testProviderConnection(config, logger)` — 测试 Base URL 可达性 + `/models` 接口
@@ -55,7 +62,7 @@ SQLite + bun:sqlite + Drizzle ORM。
### 连通性测试 ### 连通性测试
- `POST /api/providers/test` — 用未保存配置测试,不写入 DB不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false``/models` 不支持返回 `ok: true` + 提示。 - `POST /api/providers/test` — 用未保存配置测试,不写入 DB不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false``/models` 不支持返回 `ok: true` + 提示。
- `POST /api/models/test` — 用模型关联供应商 + modelId 测试。 - `POST /api/models/test` — 用模型关联供应商 + externalId 测试。
## 素材 API ## 素材 API
@@ -64,9 +71,9 @@ SQLite + bun:sqlite + Drizzle ORM。
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) | | GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
| POST | `/api/projects/:id/materials` | 创建素材 | | POST | `/api/projects/:id/materials` | 创建素材 |
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 | | GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(删除) | | DELETE | `/api/projects/:id/materials/:mid` | 删除素材(删除) |
校验description 必填非空associatedDate 必填 YYYY-MM-DD项目须存在且 active素材归属校验不匹配返回 403。 校验description 必填非空associatedDate 必填 YYYY-MM-DD项目须存在且 active 且未删除,素材归属校验不匹配返回 403。
## 聊天 API ## 聊天 API
@@ -97,4 +104,4 @@ SQLite + bun:sqlite + Drizzle ORM。
## 更新触发条件 ## 更新触发条件
修改后端模块 API、共享工具、数据库 schema、AI 服务层聊天 API 时,必须更新本文档。 修改后端模块 API、共享工具、数据库 schema、AI 服务层聊天 API 或列表查询参数解析时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。

167
docs/development/crud.md Normal file
View File

@@ -0,0 +1,167 @@
# CRUD 管理页面模式
管理类页面(项目管理、模型管理、供应商管理)遵循统一的 CRUD 模式。本文档描述该模式的后端和前端约定。
## 背景
三个管理页面原本各自实现了工具条、筛选、分页、搜索等逻辑,代码重复且交互不一致。统一后所有管理页面使用相同的基础设施。
## 页面结构
每个管理页面的组件结构:
```text
<Space className="app-page-flex" orientation="vertical" size="large">
<FilterToolbar /> ← 筛选 + 搜索 + 操作按钮
<EntityTable /> ← 数据表格(分页 + 排序 + 行操作)
<EntityFormModal /> ← 创建/编辑弹窗
</Space>
```
## 前端基础设施
### FilterToolbar
`src/web/shared/components/FilterToolbar.tsx` — 统一筛选工具条。
```tsx
interface FilterConfig {
key: string;
label: string;
placeholder: string;
options: Array<{ label: string; value: string }>;
value?: string; // 受控值
onChange: (value: string | undefined) => void;
}
interface SearchConfig {
placeholder: string;
keyword?: string; // 受控关键词(来自 URL
onSearch: (value: string) => void; // 点击搜索按钮
onReset: () => void; // 点击重置按钮
}
interface FilterToolbarProps {
filters?: FilterConfig[]; // 左侧筛选下拉框
search?: SearchConfig; // 搜索输入框 + 重置按钮
actions?: ReactNode; // 右侧操作区(如"新建"按钮)
}
```
**布局**:左侧 = 筛选 Select + 搜索框SearchOutlined 按钮) + 重置按钮UndoOutlined右侧 = actions 插槽。
**交互约定**
- 筛选下拉框:选中即过滤,调用 `onChange`(实时过滤)
- 搜索框:输入文本,点击搜索按钮或 Enter 触发 `onSearch`(点击搜索)
- 重置按钮:调用 `onReset` 清除全部筛选条件
### usePageSearchParams
`src/web/shared/hooks/usePageSearchParams.ts` — 将页面筛选/分页/排序状态同步到 URL 查询参数。
```tsx
const { params, setParams, resetAll } = usePageSearchParams({
defaults: { page: "1", pageSize: "20" },
});
```
- `params``Record<string, string>`,读取当前 URL 参数(含默认值)
- `setParams(patch)`:批量更新多个参数(推荐),内部使用函数式更新器 + `replace: true`
- `setParam(key, value)`:更新单个参数(**注意**:连续多次 `setParam` 会导致闭包快照覆盖,多参数更新必须使用 `setParams`
- `resetAll()`:清空所有 URL 参数
- 默认值(`defaults`)不出现在 URL 中
**核心约束**react-router 的 `setSearchParams` 函数式更新器使用 `useCallback` 闭包捕获的 `searchParams` 快照,连续调用时第二次的 `prev` 还是第一次更新前的旧值。多参数同时更新必须使用单次 `setParams({...})` 批量操作。
### useConfirmAction
`src/web/shared/hooks/useConfirmAction.ts` — 包装异步操作,提供成功/失败 toast 通知。
```tsx
const { confirmAction } = useConfirmAction();
confirmAction(() => deleteMutation.mutateAsync(id), "删除成功");
```
## 后端基础设施
### parseListParams
`src/server/helpers/list-params.ts` — 统一解析列表请求参数。
```ts
export interface ParsedListParams {
keyword?: string;
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: SortOrder;
}
export function parseListParams(
url: URL,
mode: RuntimeMode,
options?: { allowedSortBy?: string[] },
): ParsedListParams | Response;
```
- 校验 page正整数、pageSize正整数最大 200
- 校验 sortBy白名单由调用方传入 `allowedSortBy`
- 校验 sortOrder仅 "asc" / "desc"
- 校验失败返回 400 Response调用方应直接返回
- 替代了旧的 `validatePagination` 中间件
### 数据访问层约定
每个实体的 DB 函数(`listProjects` / `listModels` / `listProviders`
- 通过 `paginateQuery` 工具执行分页查询
- 接受 `sortBy` / `sortOrder` 参数,各实体自带白名单(`buildOrderBy`
- 默认排序:`desc(createdAt)`
- 实体专用筛选参数在 DB 层处理(如 `status``type``capabilities`
### 路由层约定
每个实体的列表路由:
```ts
// src/server/routes/{entity}/list.ts
const parsed = parseListParams(url, mode, { allowedSortBy: [...] });
if (parsed instanceof Response) return parsed;
// 解析实体专用筛选参数(如 status、providerId、capabilities、type
// 调用 DB 函数
```
## URL 参数约定
| 参数 | 说明 | 默认值 |
| -------------- | -------------- | ------ |
| `page` | 当前页码 | 1 |
| `pageSize` | 每页条数 | 20 |
| `keyword` | 搜索关键词 | - |
| `sortBy` | 排序列 | - |
| `sortOrder` | 排序方向 | - |
| `status` | 项目状态筛选 | - |
| `type` | 供应商类型筛选 | - |
| `providerId` | 模型供应商筛选 | - |
| `capabilities` | 模型能力筛选 | - |
默认值不出现在 URL 中。
## 排序约定
- 排序列后端白名单校验,拒绝无效列名
- 所有列排序通过后端 `ORDER BY` 实现,不使用前端排序
- Table 组件的 `column.sorter` 设为 `true` 启用排序指示器
- Table 的 `onChange` 回调统一处理分页 + 排序变更
## 交互约定
| 操作 | 触发时机 | 行为 |
| -------- | -------------------- | ----------------------------- |
| 筛选下拉 | 选中选项 / 清除 | 重置到第 1 页,更新 URL |
| 搜索 | 点击搜索按钮 / Enter | 重置到第 1 页,更新 URL |
| 重置 | 点击重置按钮 | 清除所有筛选条件,回到第 1 页 |
| 分页 | 点击分页器 | 更新 URLpage+pageSize |
| 排序 | 点击列头排序 | 重置到第 1 页,更新 URL |
| 删除 | Popconfirm 确认后 | toast 通知,刷新列表 |

View File

@@ -6,7 +6,7 @@
两个布局入口共享 ConsoleShell`src/web/shared/components/ConsoleShell/` 两个布局入口共享 ConsoleShell`src/web/shared/components/ConsoleShell/`
- **AdminLayout**`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects``/models` - **AdminLayout**`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects``/models``/models/providers`
- **WorkbenchLayout**`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。 - **WorkbenchLayout**`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和布局标题。 ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和布局标题。
@@ -18,27 +18,27 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
| 功能模块 | 路径 | 说明 | | 功能模块 | 路径 | 说明 |
| -------- | --------------------- | --------------------------- | | -------- | --------------------- | --------------------------- |
| 仪表盘 | `features/dashboard/` | 总览页面 | | 仪表盘 | `features/dashboard/` | 总览页面 |
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索 | | 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索、排序 |
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 | | 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 | | 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 | | 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
## 页面 ## 页面
| 页面 | 路径 | 入口 | | 页面 | 路径 | 入口 |
| -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 总览 | `/` | `features/dashboard/index.tsx` | | 总览 | `/` | `features/dashboard/index.tsx` |
| 项目管理 | `/projects` | `features/projects/index.tsx`ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 | | 项目管理 | `/projects` | `features/projects/index.tsx`FilterToolbar状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`,新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 | | 模型管理 | `/models` `/models/providers` | 独立路由页面:`ModelListPage.tsx`FilterToolbar + ModelTable + `ProviderListPage.tsx`FilterToolbar + ProviderTable。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`)。 |
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` | | 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层selectedId + modalOpen+ MaterialSidebar列表容器+ MaterialDetailPanel详情容器+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 | | 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层selectedId + modalOpen+ MaterialSidebar列表容器+ MaterialDetailPanel详情容器+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
| 404 | `*` | `features/not-found/index.tsx` | | 404 | `*` | `features/not-found/index.tsx` |
### 聊天页面 ### 聊天页面
`ChatPage` = `Conversations`@ant-design/x+ `ChatPanel` `ChatPage` = `ConversationSidebar`(自定义组件+ `ChatPanel`
- **Conversations**:会话侧边栏TanStack Query 管理会话列表,支持创建/选中/删除menu dropdown)。 - **ConversationSidebar**:会话侧边栏数据加载层useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除Popconfirm)。
- **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。按 `part.type` 分派渲染TextPartmarkdown-to-jsx 含自定义 overridesCodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式、ReasoningPart、ToolPart四态。支持编辑重发、重新生成、复制。 - **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。按 `part.type` 分派渲染TextPartmarkdown-to-jsx 含自定义 overridesCodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式、ReasoningPart、ToolPart四态。支持编辑重发、重新生成、复制。
- **Sender**@ant-design/x输入框 + 发送/停止按钮 + 模型 Selectfooter slot - **Sender**@ant-design/x输入框 + 发送/停止按钮 + 模型 Selectfooter slot
@@ -46,38 +46,44 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
### 共享组件 ### 共享组件
| 组件 | 路径 | 说明 | | 组件 | 路径 | 说明 |
| ------------- | ------------------------------------- | --------------------------------- | | ------------- | ------------------------------------- | ------------------------------------------------------ |
| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳Provider + Layout | | ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳Provider + Layout |
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 | | FilterToolbar | `shared/components/FilterToolbar.tsx` | 统一筛选工具条Select 筛选 + 搜索 + 重置 + 操作按钮) |
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 | | Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
### 共享 Hooks ### 共享 Hooks
| Hook | 路径 | 说明 | | Hook | 路径 | 说明 |
| ----------------------- | --------------------------------------- | ---------------------------------------------------------- | | ------------------------ | --------------------------------------- | --------------------------------------------------------------------- |
| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`30s 轮询5s staleTime | | `use-page-search-params` | `shared/hooks/usePageSearchParams.ts` | URL 查询参数同步(筛选/分页/排序),批量更新 `setParams` 避免闭包覆盖 |
| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection | | `use-confirm-action` | `shared/hooks/useConfirmAction.ts` | 包装异步操作 + toast 成功/失败通知 |
| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection | | `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`30s 轮询5s staleTime |
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore | | `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks | | `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 | | `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 | | `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks |
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 | | `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 |
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 | | `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) | | `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks | | `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享工具函数 ### 共享工具函数
| 文件 | 导出 | | 文件 | 导出 |
| --------------- | --------------------------------------------------------------------------------------------- | | --------------------- | --------------------------------------------------------------------------------------------- |
| `utils/api.ts` | `handleResponse(response, extract)``handleVoidResponse(response)` | | `utils/api.ts` | `handleResponse(response, extract)``handleVoidResponse(response)` |
| `utils/time.ts` | `formatCountdown``formatDurationUnit``formatRelativeTime``isOlderThan``subtractHours` | | `utils/format.ts` | `formatDatetime(iso: string)` — 格式化 ISO 时间字符串为 `YYYY-MM-DD HH:mm` |
| `utils/time.ts` | `formatCountdown``formatDurationUnit``formatRelativeTime``isOlderThan``subtractHours` |
| `utils/date-group.ts` | `getDateGroup``groupByDate``GROUP_LABELS``GROUP_ORDER``DateGroup``DateGroupData` |
## 更新触发条件 ## 更新触发条件
修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引、hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。 修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引、hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。
## 日志模块 ## 日志模块

View File

@@ -32,12 +32,13 @@ bun run dev config.yaml
## 功能介绍 ## 功能介绍
| 功能 | 路径 | 说明 | | 功能 | 路径 | 说明 |
| -------- | ----------------------- | ---------------------------------------- | | -------- | ----------------------- | -------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 | | 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 | | 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 | | 模型 | `/models` | 管理 AI 模型,供后续 AI 功能使用 |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 | | 供应商 | `/models/providers` | 配置 AI 供应商API Key、Base URL 等) |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
平台提供两个入口: 平台提供两个入口:
@@ -46,12 +47,14 @@ bun run dev config.yaml
从项目管理页面的 active 项目行可点击"工作台"跳转到对应项目的工作台。 从项目管理页面的 active 项目行可点击"工作台"跳转到对应项目的工作台。
## 模型管理 ## 模型与供应商管理
在 Admin 侧栏进入 `/models` 后,页面通过两个标签页管理 AI 基础配置 在 Admin 侧栏的"模型管理"分组下包含两个独立页面
- **供应商**:新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`baseURL 和 API Key 由用户填写 - **模型**`/models`):新增、编辑、删除 AI 模型。填写模型显示名称、实际调用用的 modelId、能力标签以及可选的上下文长度和最大输出 token。新建模型时下拉选择已配置的供应商
- **模型**:为供应商新增模型,填写模型显示名称、实际调用用的 modelId、能力标签以及可选的上下文长度和最大输出 token - **供应商**`/models/providers`):新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`baseURL 和 API Key 由用户填写
侧栏"模型管理"为分组标签,点击展开/收起子项,不直接导航。
供应商表单提供"测试连接"操作:系统先测试 Base URL 是否可达,再尝试请求 `/models` 验证 API Key 和模型列表接口。若服务不支持 `/models`,页面会提示接口可达但可能不支持模型列表;该结果只作为提醒,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。 供应商表单提供"测试连接"操作:系统先测试 Base URL 是否可达,再尝试请求 `/models` 验证 API Key 和模型列表接口。若服务不支持 `/models`,页面会提示接口可达但可能不支持模型列表;该结果只作为提醒,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。
@@ -67,4 +70,4 @@ bun run dev config.yaml
- **编辑**:最后一条用户消息可编辑,确认后重新发送 - **编辑**:最后一条用户消息可编辑,确认后重新发送
- **重新生成**:最后一条 AI 消息可重新生成回复 - **重新生成**:最后一条 AI 消息可重新生成回复
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。 使用聊天功能前,需先在 Admin 管理台的模型和供应商页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。

View File

@@ -0,0 +1,109 @@
-- DB schema standardization migration
-- 1. Rename columns
ALTER TABLE `projects` RENAME COLUMN `archived_at` TO `deleted_at`;
ALTER TABLE `models` RENAME COLUMN `model_id` TO `external_id`;
-- 2. Add deleted_at to remaining tables
ALTER TABLE `providers` ADD COLUMN `deleted_at` text;
ALTER TABLE `models` ADD COLUMN `deleted_at` text;
ALTER TABLE `conversations` ADD COLUMN `deleted_at` text;
ALTER TABLE `materials` ADD COLUMN `deleted_at` text;
ALTER TABLE `messages` ADD COLUMN `deleted_at` text;
-- 3. Add updated_at to messages
ALTER TABLE `messages` ADD COLUMN `updated_at` text NOT NULL DEFAULT '';
-- 4. Drop unique indexes (enforcement moves to app layer)
DROP INDEX IF EXISTS `projects_name_unique`;
DROP INDEX IF EXISTS `providers_name_unique`;
DROP INDEX IF EXISTS `models_provider_id_model_id_unique`;
-- 5. Rebuild messages table (FK cascade → no action, add updated_at + deleted_at in-table, add CHECK on role)
CREATE TABLE `messages_new` (
`id` text PRIMARY KEY NOT NULL,
`conversation_id` text NOT NULL,
`role` text NOT NULL CHECK (`role` IN ('assistant', 'system', 'user')),
`content` text NOT NULL DEFAULT '',
`parts` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL DEFAULT '',
`deleted_at` text,
FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `messages_new` (`id`, `conversation_id`, `role`, `content`, `parts`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `conversation_id`, `role`, `content`, `parts`, `created_at`, '', NULL FROM `messages`;
--> statement-breakpoint
DROP TABLE `messages`;
--> statement-breakpoint
ALTER TABLE `messages_new` RENAME TO `messages`;
--> statement-breakpoint
CREATE INDEX `messages_conversation_id_idx` ON `messages` (`conversation_id`);
--> statement-breakpoint
-- 6. Rebuild conversations table (model_id nullable, add deleted_at in-table)
CREATE TABLE `conversations_new` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`model_id` text,
`title` text NOT NULL DEFAULT '新会话',
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
FOREIGN KEY (`model_id`) REFERENCES `models`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `conversations_new` (`id`, `project_id`, `model_id`, `title`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `project_id`, `model_id`, `title`, `created_at`, `updated_at`, NULL FROM `conversations`;
--> statement-breakpoint
DROP TABLE `conversations`;
--> statement-breakpoint
ALTER TABLE `conversations_new` RENAME TO `conversations`;
--> statement-breakpoint
CREATE INDEX `conversations_project_id_idx` ON `conversations` (`project_id`);
--> statement-breakpoint
CREATE INDEX `conversations_model_id_idx` ON `conversations` (`model_id`);
--> statement-breakpoint
-- 7. Rebuild providers table (add deleted_at in-table, add CHECK on type)
CREATE TABLE `providers_new` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL DEFAULT 'openai-compatible' CHECK (`type` IN ('anthropic', 'openai', 'openai-compatible')),
`api_key` text NOT NULL,
`base_url` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text
);
--> statement-breakpoint
INSERT INTO `providers_new` (`id`, `name`, `type`, `api_key`, `base_url`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `name`, `type`, `api_key`, `base_url`, `created_at`, `updated_at`, NULL FROM `providers`;
--> statement-breakpoint
DROP TABLE `providers`;
--> statement-breakpoint
ALTER TABLE `providers_new` RENAME TO `providers`;
--> statement-breakpoint
-- 8. Rebuild materials table (add deleted_at in-table, add CHECK on status)
CREATE TABLE `materials_new` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`associated_date` text NOT NULL,
`description` text NOT NULL,
`status` text NOT NULL DEFAULT 'pending' CHECK (`status` IN ('pending', 'approved', 'discarded')),
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `materials_new` (`id`, `project_id`, `associated_date`, `description`, `status`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `project_id`, `associated_date`, `description`, `status`, `created_at`, `updated_at`, NULL FROM `materials`;
--> statement-breakpoint
DROP TABLE `materials`;
--> statement-breakpoint
ALTER TABLE `materials_new` RENAME TO `materials`;
--> statement-breakpoint
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);

View File

@@ -0,0 +1,530 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b0da1e89-0647-40e1-9739-6bcd14cf5a2e",
"prevId": "340f6d1a-081b-413d-a289-f39592ece0a2",
"tables": {
"conversations": {
"name": "conversations",
"columns": {
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'新会话'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"conversations_project_id_idx": {
"name": "conversations_project_id_idx",
"columns": ["project_id"],
"isUnique": false
},
"conversations_model_id_idx": {
"name": "conversations_model_id_idx",
"columns": ["model_id"],
"isUnique": false
}
},
"foreignKeys": {
"conversations_model_id_models_id_fk": {
"name": "conversations_model_id_models_id_fk",
"tableFrom": "conversations",
"tableTo": "models",
"columnsFrom": ["model_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"conversations_project_id_projects_id_fk": {
"name": "conversations_project_id_projects_id_fk",
"tableFrom": "conversations",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"materials": {
"name": "materials",
"columns": {
"associated_date": {
"name": "associated_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"materials_project_id_idx": {
"name": "materials_project_id_idx",
"columns": ["project_id"],
"isUnique": false
}
},
"foreignKeys": {
"materials_project_id_projects_id_fk": {
"name": "materials_project_id_projects_id_fk",
"tableFrom": "materials",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parts": {
"name": "parts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
}
},
"indexes": {
"messages_conversation_id_idx": {
"name": "messages_conversation_id_idx",
"columns": ["conversation_id"],
"isUnique": false
}
},
"foreignKeys": {
"messages_conversation_id_conversations_id_fk": {
"name": "messages_conversation_id_conversations_id_fk",
"tableFrom": "messages",
"tableTo": "conversations",
"columnsFrom": ["conversation_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"models": {
"name": "models",
"columns": {
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"context_length": {
"name": "context_length",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"models_provider_id_idx": {
"name": "models_provider_id_idx",
"columns": ["provider_id"],
"isUnique": false
}
},
"foreignKeys": {
"models_provider_id_providers_id_fk": {
"name": "models_provider_id_providers_id_fk",
"tableFrom": "models",
"tableTo": "providers",
"columnsFrom": ["provider_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"providers": {
"name": "providers",
"columns": {
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'openai-compatible'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schema_migrations": {
"name": "schema_migrations",
"columns": {
"applied_at": {
"name": "applied_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1780463734721, "when": 1780463734721,
"tag": "0003_lying_cassandra_nova", "tag": "0003_lying_cassandra_nova",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1780587528226,
"tag": "0004_db_schema_standardization",
"breakpoints": true
} }
] ]
} }

View File

@@ -95,6 +95,25 @@ export default tseslint.config(
"import/no-named-as-default-member": "off", "import/no-named-as-default-member": "off",
}, },
}, },
{
files: ["src/server/db/**/*.ts"],
ignores: ["src/server/db/helpers.ts"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
importNames: ["sqliteTable"],
message:
"请从 ./helpers.ts 导入 sqliteTable并在列定义中展开 baseColumns。参见 src/server/db/helpers.ts。",
name: "drizzle-orm/sqlite-core",
},
],
},
],
},
},
{ {
files: ["src/server/**/*.ts"], files: ["src/server/**/*.ts"],
ignores: ["src/server/logger.ts"], ignores: ["src/server/logger.ts"],

View File

@@ -5,37 +5,37 @@
"source": "vercel/ai", "source": "vercel/ai",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/use-ai-sdk/SKILL.md", "skillPath": "skills/use-ai-sdk/SKILL.md",
"computedHash": "2249889eb47ef0f61c4dba4cf2afe01c1c8dd793bb4c24230347c1ab909bb7dd" "computedHash": "f9381aea9aa207157c88348c6b0ae3551137955f2bd48c855c27fa86ac03cd56"
}, },
"ant-design": { "ant-design": {
"source": "ant-design/antd-skill", "source": "ant-design/antd-skill",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/ant-design/SKILL.md", "skillPath": "skills/ant-design/SKILL.md",
"computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466" "computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179"
}, },
"antd": { "antd": {
"source": "ant-design/antd-skill", "source": "ant-design/antd-skill",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/antd/SKILL.md", "skillPath": "skills/antd/SKILL.md",
"computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2" "computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94"
}, },
"react-router-data-mode": { "react-router-data-mode": {
"source": "remix-run/agent-skills", "source": "remix-run/agent-skills",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/react-router-data-mode/SKILL.md", "skillPath": "skills/react-router-data-mode/SKILL.md",
"computedHash": "76e3e0f70ff47b743bd90999e676515221e25fd7ee89cd9e5b340417b1a601e2" "computedHash": "cbbe1b1cfa8f6ceae1ab26d26b38c612279c9c272cf956471838796d85659860"
}, },
"react-router-declarative-mode": { "react-router-declarative-mode": {
"source": "remix-run/agent-skills", "source": "remix-run/agent-skills",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/react-router-declarative-mode/SKILL.md", "skillPath": "skills/react-router-declarative-mode/SKILL.md",
"computedHash": "d7ebbf1ede90809618f02cb3b3d37b9871cdd6c88a81cf338e63de50a0df6a42" "computedHash": "b399ee32fa82efdbdad1121421702b7725fcffac36424529a0ea452796f3bc92"
}, },
"react-router-framework-mode": { "react-router-framework-mode": {
"source": "remix-run/agent-skills", "source": "remix-run/agent-skills",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/react-router-framework-mode/SKILL.md", "skillPath": "skills/react-router-framework-mode/SKILL.md",
"computedHash": "26c5bdac2f686c47eb4c4b48b6cb52401cde1dc833e6d26408ddfb22ea83c5ca" "computedHash": "a3294459f3a5065c837929d9700fe7d35730d5051f2979090e0f715e8fea693f"
}, },
"vercel-react-best-practices": { "vercel-react-best-practices": {
"source": "vercel-labs/agent-skills", "source": "vercel-labs/agent-skills",
@@ -48,14 +48,14 @@
"ref": "main", "ref": "main",
"sourceType": "github", "sourceType": "github",
"skillPath": "packages/x-skill/skills/x-components/SKILL.md", "skillPath": "packages/x-skill/skills/x-components/SKILL.md",
"computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729" "computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2"
}, },
"x-markdown": { "x-markdown": {
"source": "ant-design/x", "source": "ant-design/x",
"ref": "main", "ref": "main",
"sourceType": "github", "sourceType": "github",
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md", "skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
"computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17" "computedHash": "2d26b8eda1692929e99a8b6163ef8b206f1f096a4a84507b50dbe836a7ec041e"
} }
} }
} }

View File

@@ -1,8 +1,8 @@
import type { SQL } from "drizzle-orm"; import type { Column, SQL } from "drizzle-orm";
import type { SQLiteTable } from "drizzle-orm/sqlite-core"; import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import Database from "bun:sqlite"; import Database from "bun:sqlite";
import { and, sql } from "drizzle-orm"; import { and, eq, isNull, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite";
import { join } from "node:path"; import { join } from "node:path";
@@ -10,6 +10,8 @@ import type { Logger } from "../logger";
const DB_FILENAME = "alfred.db"; const DB_FILENAME = "alfred.db";
export type DrizzleDB = ReturnType<typeof wrap>;
export interface PaginateResult<T> { export interface PaginateResult<T> {
items: T[]; items: T[];
page: number; page: number;
@@ -30,6 +32,10 @@ export function createDatabase(dataDir: string, logger: Logger): Database {
return db; return db;
} }
export function notDeleted(table: { deletedAt: Column }): SQL {
return isNull(table.deletedAt);
}
export function paginateQuery<T extends SQLiteTable, R>( export function paginateQuery<T extends SQLiteTable, R>(
raw: Database, raw: Database,
table: T, table: T,
@@ -39,11 +45,16 @@ export function paginateQuery<T extends SQLiteTable, R>(
orderBy?: (table: T) => SQL | undefined; orderBy?: (table: T) => SQL | undefined;
page: number; page: number;
pageSize: number; pageSize: number;
softDelete?: Column;
}, },
): PaginateResult<R> { ): PaginateResult<R> {
const db = wrap(raw); const db = wrap(raw);
const where = options.conditions?.filter((c): c is SQL => c !== undefined); const conditions = [...(options.conditions ?? [])];
const whereClause = where && where.length > 0 ? and(...where) : undefined; if (options.softDelete) {
conditions.push(isNull(options.softDelete));
}
const where = conditions.filter((c): c is SQL => c !== undefined);
const whereClause = where.length > 0 ? and(...where) : undefined;
const countResult = db const countResult = db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
@@ -70,6 +81,24 @@ export function paginateQuery<T extends SQLiteTable, R>(
}; };
} }
export function softDeleteRecord<T extends SQLiteTable>(
db: DrizzleDB,
table: T,
id: string,
): T["$inferSelect"] | undefined {
const now = timestamp();
return db
.update(table)
.set({ deletedAt: now, updatedAt: now } as Partial<T["$inferInsert"]>)
.where(eq((table as unknown as { id: Column }).id, id))
.returning()
.get();
}
export function timestamp(): string {
return new Date().toISOString();
}
export function wrap(raw: Database) { export function wrap(raw: Database) {
return drizzle(raw); return drizzle(raw);
} }

View File

@@ -1,33 +1,36 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { desc, eq } from "drizzle-orm"; import { and, desc, eq, isNull } from "drizzle-orm";
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api"; import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection"; import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
import { conversations, messages, models } from "./schema"; import { conversations, messages, models } from "./schema";
export function createConversation( export function createConversation(
raw: Database, raw: Database,
projectId: string, projectId: string,
logger: Logger, _logger: Logger,
defaultModelId?: string, defaultModelId?: string,
): { conversation: Conversation } | { error: string; status: number } { ): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw); const db = wrap(raw);
let modelId = defaultModelId; let modelId: null | string = defaultModelId ?? null;
if (!modelId) { if (defaultModelId) {
const firstModel = db.select().from(models).limit(1).get(); const model = db
if (!firstModel) return { error: "没有可用的模型,请先配置模型", status: 400 }; .select()
modelId = firstModel.id; .from(models)
} else { .where(and(eq(models.id, defaultModelId), notDeleted(models)))
const model = db.select().from(models).where(eq(models.id, modelId)).get(); .get();
if (!model) return { error: "模型不存在", status: 400 }; if (!model) return { error: "模型不存在", status: 400 };
} else {
const firstModel = db.select().from(models).where(notDeleted(models)).limit(1).get();
if (firstModel) modelId = firstModel.id;
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date().toISOString(); const now = timestamp();
db.insert(conversations) db.insert(conversations)
.values({ .values({
@@ -56,7 +59,7 @@ export function createMessage(
): Message { ): Message {
const db = wrap(raw); const db = wrap(raw);
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date().toISOString(); const now = timestamp();
db.insert(messages) db.insert(messages)
.values({ .values({
@@ -66,6 +69,7 @@ export function createMessage(
id, id,
parts: data.parts ?? null, parts: data.parts ?? null,
role: data.role, role: data.role,
updatedAt: now,
}) })
.run(); .run();
@@ -84,7 +88,7 @@ export function createMessages(
_logger: Logger, _logger: Logger,
): Message[] { ): Message[] {
const db = wrap(raw); const db = wrap(raw);
const now = new Date().toISOString(); const now = timestamp();
const results: Message[] = []; const results: Message[] = [];
for (const item of data) { for (const item of data) {
@@ -97,6 +101,7 @@ export function createMessages(
id, id,
parts: item.parts ?? null, parts: item.parts ?? null,
role: item.role, role: item.role,
updatedAt: now,
}) })
.run(); .run();
const row = db.select().from(messages).where(eq(messages.id, id)).get(); const row = db.select().from(messages).where(eq(messages.id, id)).get();
@@ -112,11 +117,23 @@ export function deleteConversation(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { success: true } { ): { error: string; status: number } | { success: true } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get(); const existing = db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), notDeleted(conversations)))
.get();
if (!existing) return { error: "会话不存在", status: 404 }; if (!existing) return { error: "会话不存在", status: 404 };
db.delete(messages).where(eq(messages.conversationId, id)).run(); const now = timestamp();
db.delete(conversations).where(eq(conversations.id, id)).run();
db.transaction((tx) => {
tx.update(messages)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(messages.conversationId, id), isNull(messages.deletedAt)))
.run();
tx.update(conversations).set({ deletedAt: now, updatedAt: now }).where(eq(conversations.id, id)).run();
});
return { success: true }; return { success: true };
} }
@@ -125,7 +142,11 @@ export function getConversation(
id: string, id: string,
): { conversation: Conversation } | { error: string; status: number } { ): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(conversations).where(eq(conversations.id, id)).get(); const row = db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), notDeleted(conversations)))
.get();
if (!row) return { error: "会话不存在", status: 404 }; if (!row) return { error: "会话不存在", status: 404 };
return { conversation: toConversation(row) }; return { conversation: toConversation(row) };
} }
@@ -141,6 +162,7 @@ export function listConversations(
orderBy: () => desc(conversations.updatedAt), orderBy: () => desc(conversations.updatedAt),
page: options.page, page: options.page,
pageSize: options.pageSize, pageSize: options.pageSize,
softDelete: conversations.deletedAt,
}); });
} }
@@ -155,6 +177,7 @@ export function listMessages(
orderBy: () => desc(messages.createdAt), orderBy: () => desc(messages.createdAt),
page: options.page, page: options.page,
pageSize: options.pageSize, pageSize: options.pageSize,
softDelete: messages.deletedAt,
}); });
} }
@@ -165,13 +188,21 @@ export function updateConversation(
_logger: Logger, _logger: Logger,
): { conversation: Conversation } | { error: string; status: number } { ): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get(); const existing = db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), notDeleted(conversations)))
.get();
if (!existing) return { error: "会话不存在", status: 404 }; if (!existing) return { error: "会话不存在", status: 404 };
const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() }; const updates: { modelId?: null | string; title?: string; updatedAt: string } = { updatedAt: timestamp() };
if (data.modelId !== undefined) { if (data.modelId !== undefined) {
const model = db.select().from(models).where(eq(models.id, data.modelId)).get(); const model = db
.select()
.from(models)
.where(and(eq(models.id, data.modelId), notDeleted(models)))
.get();
if (!model) return { error: "模型不存在", status: 400 }; if (!model) return { error: "模型不存在", status: 400 };
updates.modelId = data.modelId; updates.modelId = data.modelId;
} }
@@ -188,7 +219,7 @@ export function updateConversation(
export function updateConversationTimestamp(raw: Database, id: string): void { export function updateConversationTimestamp(raw: Database, id: string): void {
const db = wrap(raw); const db = wrap(raw);
db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run(); db.update(conversations).set({ updatedAt: timestamp() }).where(eq(conversations.id, id)).run();
} }
function toConversation(row: typeof conversations.$inferSelect): Conversation { function toConversation(row: typeof conversations.$inferSelect): Conversation {
@@ -210,5 +241,6 @@ function toMessage(row: typeof messages.$inferSelect): Message {
id: row.id, id: row.id,
parts: row.parts, parts: row.parts,
role: row.role, role: row.role,
updatedAt: row.updatedAt,
}; };
} }

12
src/server/db/helpers.ts Normal file
View File

@@ -0,0 +1,12 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export { index, integer, sqliteTable, text, uniqueIndex };
export const baseColumns = {
createdAt: text("created_at").notNull(),
deletedAt: text("deleted_at"),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
updatedAt: text("updated_at").notNull(),
};

View File

@@ -1,11 +1,11 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api"; import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection"; import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { materials, projects } from "./schema"; import { materials, projects } from "./schema";
export function createMaterial( export function createMaterial(
@@ -15,7 +15,11 @@ export function createMaterial(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { material: Material } { ): { error: string; status: number } | { material: Material } {
const db = wrap(raw); const db = wrap(raw);
const project = db.select().from(projects).where(eq(projects.id, projectId)).get(); const project = db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), notDeleted(projects)))
.get();
if (!project) return { error: "项目不存在", status: 404 }; if (!project) return { error: "项目不存在", status: 404 };
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 }; if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
@@ -28,7 +32,7 @@ export function createMaterial(
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date().toISOString(); const now = timestamp();
db.insert(materials) db.insert(materials)
.values({ .values({
@@ -53,11 +57,15 @@ export function deleteMaterial(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { success: true } { ): { error: string; status: number } | { success: true } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(materials).where(eq(materials.id, materialId)).get(); const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 }; if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 }; if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
db.delete(materials).where(eq(materials.id, materialId)).run(); softDeleteRecord(db, materials, materialId);
return { success: true }; return { success: true };
} }
@@ -67,7 +75,11 @@ export function getMaterial(
materialId: string, materialId: string,
): { error: string; status: number } | { material: Material } { ): { error: string; status: number } | { material: Material } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(materials).where(eq(materials.id, materialId)).get(); const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 }; if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 }; if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
@@ -91,6 +103,7 @@ export function listMaterials(
orderBy: () => desc(materials.createdAt), orderBy: () => desc(materials.createdAt),
page: options.page, page: options.page,
pageSize: options.pageSize, pageSize: options.pageSize,
softDelete: materials.deletedAt,
}); });
} }

View File

@@ -33,19 +33,29 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)"); const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
db.transaction(() => { db.exec("PRAGMA foreign_keys = OFF");
for (const migration of pending) { try {
try { db.transaction(() => {
logger.info({ id: migration.id }, "执行 migration"); for (const migration of pending) {
db.exec(migration.sql); try {
insertApplied.run(migration.id, migration.checksum, new Date().toISOString()); logger.info({ id: migration.id }, "执行 migration");
} catch (e: unknown) { db.exec(migration.sql);
const msg = e instanceof Error ? e.message : String(e); insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
logger.error({ error: msg, id: migration.id }, "migration 执行失败"); } catch (e: unknown) {
throw e; const msg = e instanceof Error ? e.message : String(e);
logger.error({ error: msg, id: migration.id }, "migration 执行失败");
throw e;
}
} }
} })();
})(); } finally {
db.exec("PRAGMA foreign_keys = ON");
}
const violations = db.query("PRAGMA foreign_key_check").all();
if (violations.length > 0) {
logger.error({ violations }, "迁移后外键完整性检查失败");
}
logger.info({ count: pending.length }, "migration 全部执行完成"); logger.info({ count: pending.length }, "migration 全部执行完成");
} }

View File

@@ -1,59 +1,61 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { desc, eq, like, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, isNull, like, ne, or, sql } from "drizzle-orm";
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api"; import type { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection"; import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { models, providers } from "./schema"; import { models, providers } from "./schema";
export function createModel( export function createModel(
raw: Database, raw: Database,
request: CreateModelRequest, request: CreateModelRequest,
logger: Logger, _logger: Logger,
): { error: string; status: number } | { model: Model } { ): { error: string; status: number } | { model: Model } {
const db = wrap(raw); const db = wrap(raw);
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get(); const provider = db
.select()
.from(providers)
.where(and(eq(providers.id, request.providerId), notDeleted(providers)))
.get();
if (!provider) return { error: "供应商不存在", status: 400 }; if (!provider) return { error: "供应商不存在", status: 400 };
const name = request.name.trim(); const name = request.name.trim();
if (!name) return { error: "模型名称不能为空", status: 400 }; if (!name) return { error: "模型名称不能为空", status: 400 };
const modelId = request.modelId.trim(); const externalId = request.externalId.trim();
if (!modelId) return { error: "模型 ID 不能为空", status: 400 }; if (!externalId) return { error: "模型 ID 不能为空", status: 400 };
const capabilities = request.capabilities; const capabilities = request.capabilities;
if (!capabilities || capabilities.length === 0) { if (!capabilities || capabilities.length === 0) {
return { error: "至少选择一个能力标签", status: 400 }; return { error: "至少选择一个能力标签", status: 400 };
} }
const id = crypto.randomUUID(); const duplicate = db
const now = new Date().toISOString(); .select({ id: models.id })
.from(models)
.where(and(eq(models.providerId, request.providerId), eq(models.externalId, externalId), notDeleted(models)))
.get();
if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 };
try { const id = crypto.randomUUID();
db.insert(models) const now = timestamp();
.values({
capabilities: JSON.stringify(capabilities), db.insert(models)
contextLength: request.contextLength ?? null, .values({
createdAt: now, capabilities: JSON.stringify(capabilities),
id, contextLength: request.contextLength ?? null,
maxOutputTokens: request.maxOutputTokens ?? null, createdAt: now,
modelId, externalId,
name, id,
providerId: request.providerId, maxOutputTokens: request.maxOutputTokens ?? null,
updatedAt: now, name,
}) providerId: request.providerId,
.run(); updatedAt: now,
} catch (e: unknown) { })
const msg = e instanceof Error ? e.message : String(e); .run();
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败");
throw e;
}
const row = db.select().from(models).where(eq(models.id, id)).get(); const row = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(row!) }; return { model: toModel(row!) };
@@ -65,16 +67,24 @@ export function deleteModel(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { success: true } { ): { error: string; status: number } | { success: true } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get(); const existing = db
.select()
.from(models)
.where(and(eq(models.id, id), notDeleted(models)))
.get();
if (!existing) return { error: "模型不存在", status: 404 }; if (!existing) return { error: "模型不存在", status: 404 };
db.delete(models).where(eq(models.id, id)).run(); softDeleteRecord(db, models, id);
return { success: true }; return { success: true };
} }
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } { export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(models).where(eq(models.id, id)).get(); const row = db
.select()
.from(models)
.where(and(eq(models.id, id), notDeleted(models)))
.get();
if (!row) return { error: "模型不存在", status: 404 }; if (!row) return { error: "模型不存在", status: 404 };
return { model: toModel(row) }; return { model: toModel(row) };
@@ -85,7 +95,7 @@ export function getModelsByProviderId(raw: Database, providerId: string): number
const result = db const result = db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(models) .from(models)
.where(eq(models.providerId, providerId)) .where(and(eq(models.providerId, providerId), isNull(models.deletedAt)))
.get(); .get();
return Number(result?.count ?? 0); return Number(result?.count ?? 0);
} }
@@ -96,20 +106,28 @@ export function getModelWithProvider(
): ):
| { error: string; status: number } | { error: string; status: number }
| { | {
model: { modelId: string; name: string; providerId: string }; model: { externalId: string; name: string; providerId: string };
provider: { apiKey: string; baseUrl: string; id: string; type: string }; provider: { apiKey: string; baseUrl: string; id: string; type: string };
} { } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(models).where(eq(models.id, modelId)).get(); const row = db
.select()
.from(models)
.where(and(eq(models.id, modelId), notDeleted(models)))
.get();
if (!row) return { error: "模型不存在", status: 404 }; if (!row) return { error: "模型不存在", status: 404 };
const providerRow = db.select().from(providers).where(eq(providers.id, row.providerId)).get(); const providerRow = db
.select()
.from(providers)
.where(and(eq(providers.id, row.providerId), notDeleted(providers)))
.get();
if (!providerRow) return { error: "供应商不存在", status: 404 }; if (!providerRow) return { error: "供应商不存在", status: 404 };
return { return {
model: { model: {
modelId: row.modelId, externalId: row.externalId,
name: row.name, name: row.name,
providerId: row.providerId, providerId: row.providerId,
}, },
@@ -124,7 +142,15 @@ export function getModelWithProvider(
export function listModels( export function listModels(
raw: Database, raw: Database,
options: { keyword?: string; page: number; pageSize: number; providerId?: string }, options: {
capabilities?: string;
keyword?: string;
page: number;
pageSize: number;
providerId?: string;
sortBy?: string;
sortOrder?: SortOrder;
},
): { items: Model[]; page: number; pageSize: number; total: number } { ): { items: Model[]; page: number; pageSize: number; total: number } {
const conditions = []; const conditions = [];
@@ -134,15 +160,22 @@ export function listModels(
if (options.keyword) { if (options.keyword) {
const pattern = `%${options.keyword}%`; const pattern = `%${options.keyword}%`;
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!); conditions.push(or(like(models.name, pattern), like(models.externalId, pattern))!);
} }
if (options.capabilities) {
conditions.push(like(models.capabilities, `%"${options.capabilities}"%`));
}
const orderByFn = buildModelOrderBy(options.sortBy, options.sortOrder);
return paginateQuery(raw, models, { return paginateQuery(raw, models, {
conditions, conditions,
mapRow: toModel, mapRow: toModel,
orderBy: () => desc(models.createdAt), orderBy: orderByFn,
page: options.page, page: options.page,
pageSize: options.pageSize, pageSize: options.pageSize,
softDelete: models.deletedAt,
}); });
} }
@@ -150,14 +183,18 @@ export function updateModel(
raw: Database, raw: Database,
id: string, id: string,
request: UpdateModelRequest, request: UpdateModelRequest,
logger: Logger, _logger: Logger,
): { error: string; status: number } | { model: Model } { ): { error: string; status: number } | { model: Model } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get(); const existing = db
.select()
.from(models)
.where(and(eq(models.id, id), notDeleted(models)))
.get();
if (!existing) return { error: "模型不存在", status: 404 }; if (!existing) return { error: "模型不存在", status: 404 };
const updates: Partial<typeof models.$inferInsert> = { const updates: Partial<typeof models.$inferInsert> = {
updatedAt: new Date().toISOString(), updatedAt: timestamp(),
}; };
const name = request.name?.trim(); const name = request.name?.trim();
@@ -166,14 +203,32 @@ export function updateModel(
updates.name = name; updates.name = name;
} }
const modelId = request.modelId?.trim(); const externalId = request.externalId?.trim();
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 }; if (externalId === "") return { error: "模型 ID 不能为空", status: 400 };
if (modelId !== undefined) { if (externalId !== undefined) {
updates.modelId = modelId; const providerId = request.providerId ?? existing.providerId;
const duplicate = db
.select({ id: models.id })
.from(models)
.where(
and(
eq(models.providerId, providerId),
eq(models.externalId, externalId),
notDeleted(models),
ne(models.id, id),
),
)
.get();
if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 };
updates.externalId = externalId;
} }
if (request.providerId !== undefined) { if (request.providerId !== undefined) {
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get(); const provider = db
.select()
.from(providers)
.where(and(eq(providers.id, request.providerId), notDeleted(providers)))
.get();
if (!provider) return { error: "供应商不存在", status: 400 }; if (!provider) return { error: "供应商不存在", status: 400 };
updates.providerId = request.providerId; updates.providerId = request.providerId;
} }
@@ -197,29 +252,31 @@ export function updateModel(
return { model: toModel(existing) }; return { model: toModel(existing) };
} }
try { db.update(models).set(updates).where(eq(models.id, id)).run();
db.update(models).set(updates).where(eq(models.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "models" }, "数据库操作失败");
throw e;
}
const updated = db.select().from(models).where(eq(models.id, id)).get(); const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) }; return { model: toModel(updated!) };
} }
function buildModelOrderBy(
sortBy: string | undefined,
sortOrder: SortOrder | undefined,
): ((table: typeof models) => ReturnType<typeof desc>) | undefined {
if (!sortBy) return (t) => desc(t.createdAt);
return sortOrder === "asc"
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
}
function toModel(row: typeof models.$inferSelect): Model { function toModel(row: typeof models.$inferSelect): Model {
return { return {
capabilities: JSON.parse(row.capabilities) as ModelCapability[], capabilities: JSON.parse(row.capabilities) as ModelCapability[],
contextLength: row.contextLength, contextLength: row.contextLength,
createdAt: row.createdAt, createdAt: row.createdAt,
externalId: row.externalId,
id: row.id, id: row.id,
maxOutputTokens: row.maxOutputTokens, maxOutputTokens: row.maxOutputTokens,
modelId: row.modelId,
name: row.name, name: row.name,
providerId: row.providerId, providerId: row.providerId,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,

View File

@@ -1,12 +1,12 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { desc, eq, like, or } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNull, like, ne, or } from "drizzle-orm";
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api"; import type { CreateProjectRequest, Project, ProjectStatus, SortOrder, UpdateProjectRequest } from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection"; import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
import { projects } from "./schema"; import { conversations, materials, messages, projects } from "./schema";
export function archiveProject( export function archiveProject(
raw: Database, raw: Database,
@@ -14,12 +14,16 @@ export function archiveProject(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { project: Project } { ): { error: string; status: number } | { project: Project } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get(); const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 }; if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "archived") return { error: "项目已归档", status: 409 }; if (existing.status === "archived") return { error: "项目已归档", status: 409 };
const now = new Date().toISOString(); const now = timestamp();
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run(); db.update(projects).set({ status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get(); const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) }; return { project: toProject(updated!) };
@@ -28,37 +32,34 @@ export function archiveProject(
export function createProject( export function createProject(
raw: Database, raw: Database,
request: CreateProjectRequest, request: CreateProjectRequest,
logger: Logger, _logger: Logger,
): { error: string; status: number } | { project: Project } { ): { error: string; status: number } | { project: Project } {
const db = wrap(raw); const db = wrap(raw);
const name = request.name.trim(); const name = request.name.trim();
if (!name) return { error: "项目名称不能为空", status: 400 }; if (!name) return { error: "项目名称不能为空", status: 400 };
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 }; if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const duplicate = db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, name), notDeleted(projects)))
.get();
if (duplicate) return { error: "项目名称已存在", status: 409 };
const description = (request.description ?? "").trim(); const description = (request.description ?? "").trim();
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date().toISOString(); const now = timestamp();
try { db.insert(projects)
db.insert(projects) .values({
.values({ createdAt: now,
archivedAt: null, description,
createdAt: now, id,
description, name,
id, status: "active",
name, updatedAt: now,
status: "active", })
updatedAt: now, .run();
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "projects" }, "数据库操作失败");
throw e;
}
const row = db.select().from(projects).where(eq(projects.id, id)).get(); const row = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(row!) }; return { project: toProject(row!) };
@@ -70,17 +71,53 @@ export function deleteProject(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { success: true } { ): { error: string; status: number } | { success: true } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get(); const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 }; if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 }; if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 };
db.delete(projects).where(eq(projects.id, id)).run(); const now = timestamp();
db.transaction((tx) => {
const convIds = tx
.select({ id: conversations.id })
.from(conversations)
.where(and(eq(conversations.projectId, id), isNull(conversations.deletedAt)))
.all()
.map((r) => r.id);
if (convIds.length > 0) {
tx.update(messages)
.set({ deletedAt: now, updatedAt: now })
.where(and(inArray(messages.conversationId, convIds), isNull(messages.deletedAt)))
.run();
tx.update(conversations)
.set({ deletedAt: now, updatedAt: now })
.where(and(inArray(conversations.id, convIds), isNull(conversations.deletedAt)))
.run();
}
tx.update(materials)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(materials.projectId, id), isNull(materials.deletedAt)))
.run();
tx.update(projects).set({ deletedAt: now, updatedAt: now }).where(eq(projects.id, id)).run();
});
return { success: true }; return { success: true };
} }
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(projects).where(eq(projects.id, id)).get(); const row = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!row) return { error: "项目不存在", status: 404 }; if (!row) return { error: "项目不存在", status: 404 };
return { project: toProject(row) }; return { project: toProject(row) };
@@ -88,7 +125,14 @@ export function getProject(raw: Database, id: string): { error: string; status:
export function listProjects( export function listProjects(
raw: Database, raw: Database,
options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus }, options: {
keyword?: string;
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: SortOrder;
status?: ProjectStatus;
},
): { items: Project[]; page: number; pageSize: number; total: number } { ): { items: Project[]; page: number; pageSize: number; total: number } {
const conditions = []; const conditions = [];
@@ -101,12 +145,15 @@ export function listProjects(
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!); conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
} }
const orderByFn = buildProjectOrderBy(options.sortBy, options.sortOrder);
return paginateQuery(raw, projects, { return paginateQuery(raw, projects, {
conditions, conditions,
mapRow: toProject, mapRow: toProject,
orderBy: () => desc(projects.createdAt), orderBy: orderByFn,
page: options.page, page: options.page,
pageSize: options.pageSize, pageSize: options.pageSize,
softDelete: projects.deletedAt,
}); });
} }
@@ -116,12 +163,16 @@ export function restoreProject(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { project: Project } { ): { error: string; status: number } | { project: Project } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get(); const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 }; if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 }; if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
const now = new Date().toISOString(); const now = timestamp();
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run(); db.update(projects).set({ status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get(); const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) }; return { project: toProject(updated!) };
@@ -131,10 +182,14 @@ export function updateProject(
raw: Database, raw: Database,
id: string, id: string,
request: UpdateProjectRequest, request: UpdateProjectRequest,
logger: Logger, _logger: Logger,
): { error: string; status: number } | { project: Project } { ): { error: string; status: number } | { project: Project } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get(); const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 }; if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 }; if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
@@ -143,10 +198,16 @@ export function updateProject(
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 }; if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const updates: Partial<typeof projects.$inferInsert> = { const updates: Partial<typeof projects.$inferInsert> = {
updatedAt: new Date().toISOString(), updatedAt: timestamp(),
}; };
if (name !== undefined && name !== existing.name) { if (name !== undefined && name !== existing.name) {
const duplicate = db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, name), notDeleted(projects), ne(projects.id, id)))
.get();
if (duplicate) return { error: "项目名称已存在", status: 409 };
updates.name = name; updates.name = name;
} }
@@ -159,24 +220,25 @@ export function updateProject(
return { project: toProject(existing) }; return { project: toProject(existing) };
} }
try { db.update(projects).set(updates).where(eq(projects.id, id)).run();
db.update(projects).set(updates).where(eq(projects.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "projects" }, "数据库操作失败");
throw e;
}
const updated = db.select().from(projects).where(eq(projects.id, id)).get(); const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) }; return { project: toProject(updated!) };
} }
function buildProjectOrderBy(
sortBy: string | undefined,
sortOrder: SortOrder | undefined,
): ((table: typeof projects) => ReturnType<typeof desc>) | undefined {
if (!sortBy) return (t) => desc(t.createdAt);
return sortOrder === "asc"
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
}
function toProject(row: typeof projects.$inferSelect): Project { function toProject(row: typeof projects.$inferSelect): Project {
return { return {
archivedAt: row.archivedAt,
createdAt: row.createdAt, createdAt: row.createdAt,
description: row.description, description: row.description,
id: row.id, id: row.id,

View File

@@ -1,17 +1,23 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { desc, eq, like } from "drizzle-orm"; import { and, asc, desc, eq, isNull, like, ne } from "drizzle-orm";
import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api"; import type {
CreateProviderRequest,
Provider,
ProviderOption,
SortOrder,
UpdateProviderRequest,
} from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection"; import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { providers } from "./schema"; import { models, providers } from "./schema";
export function createProvider( export function createProvider(
raw: Database, raw: Database,
request: CreateProviderRequest, request: CreateProviderRequest,
logger: Logger, _logger: Logger,
): { error: string; status: number } | { provider: Provider } { ): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw); const db = wrap(raw);
const name = request.name.trim(); const name = request.name.trim();
@@ -23,29 +29,27 @@ export function createProvider(
const apiKey = request.apiKey.trim(); const apiKey = request.apiKey.trim();
if (!apiKey) return { error: "API Key 不能为空", status: 400 }; if (!apiKey) return { error: "API Key 不能为空", status: 400 };
const id = crypto.randomUUID(); const duplicate = db
const now = new Date().toISOString(); .select({ id: providers.id })
.from(providers)
.where(and(eq(providers.name, name), notDeleted(providers)))
.get();
if (duplicate) return { error: "供应商名称已存在", status: 409 };
try { const id = crypto.randomUUID();
db.insert(providers) const now = timestamp();
.values({
apiKey, db.insert(providers)
baseUrl, .values({
createdAt: now, apiKey,
id, baseUrl,
name, createdAt: now,
type: request.type, id,
updatedAt: now, name,
}) type: request.type,
.run(); updatedAt: now,
} catch (e: unknown) { })
const msg = e instanceof Error ? e.message : String(e); .run();
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败");
throw e;
}
const row = db.select().from(providers).where(eq(providers.id, id)).get(); const row = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(row!) }; return { provider: toProvider(row!) };
@@ -57,16 +61,31 @@ export function deleteProvider(
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { success: true } { ): { error: string; status: number } | { success: true } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get(); const existing = db
.select()
.from(providers)
.where(and(eq(providers.id, id), notDeleted(providers)))
.get();
if (!existing) return { error: "供应商不存在", status: 404 }; if (!existing) return { error: "供应商不存在", status: 404 };
db.delete(providers).where(eq(providers.id, id)).run(); const activeModels = db
.select({ id: models.id })
.from(models)
.where(and(eq(models.providerId, id), isNull(models.deletedAt)))
.get();
if (activeModels) return { error: "该供应商下仍有模型,无法删除", status: 409 };
softDeleteRecord(db, providers, id);
return { success: true }; return { success: true };
} }
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } { export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw); const db = wrap(raw);
const row = db.select().from(providers).where(eq(providers.id, id)).get(); const row = db
.select()
.from(providers)
.where(and(eq(providers.id, id), notDeleted(providers)))
.get();
if (!row) return { error: "供应商不存在", status: 404 }; if (!row) return { error: "供应商不存在", status: 404 };
return { provider: toProvider(row) }; return { provider: toProvider(row) };
@@ -77,6 +96,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
const rows = db const rows = db
.select({ id: providers.id, name: providers.name, type: providers.type }) .select({ id: providers.id, name: providers.name, type: providers.type })
.from(providers) .from(providers)
.where(notDeleted(providers))
.orderBy(desc(providers.createdAt)) .orderBy(desc(providers.createdAt))
.all(); .all();
@@ -85,7 +105,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
export function listProviders( export function listProviders(
raw: Database, raw: Database,
options: { keyword?: string; page: number; pageSize: number }, options: { keyword?: string; page: number; pageSize: number; sortBy?: string; sortOrder?: SortOrder; type?: string },
): { items: Provider[]; page: number; pageSize: number; total: number } { ): { items: Provider[]; page: number; pageSize: number; total: number } {
const conditions = []; const conditions = [];
@@ -94,12 +114,19 @@ export function listProviders(
conditions.push(like(providers.name, pattern)); conditions.push(like(providers.name, pattern));
} }
if (options.type) {
conditions.push(eq(providers.type, options.type as Provider["type"]));
}
const orderByFn = buildProviderOrderBy(options.sortBy, options.sortOrder);
return paginateQuery(raw, providers, { return paginateQuery(raw, providers, {
conditions, conditions,
mapRow: toProvider, mapRow: toProvider,
orderBy: () => desc(providers.createdAt), orderBy: orderByFn,
page: options.page, page: options.page,
pageSize: options.pageSize, pageSize: options.pageSize,
softDelete: providers.deletedAt,
}); });
} }
@@ -107,19 +134,29 @@ export function updateProvider(
raw: Database, raw: Database,
id: string, id: string,
request: UpdateProviderRequest, request: UpdateProviderRequest,
logger: Logger, _logger: Logger,
): { error: string; status: number } | { provider: Provider } { ): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw); const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get(); const existing = db
.select()
.from(providers)
.where(and(eq(providers.id, id), notDeleted(providers)))
.get();
if (!existing) return { error: "供应商不存在", status: 404 }; if (!existing) return { error: "供应商不存在", status: 404 };
const updates: Partial<typeof providers.$inferInsert> = { const updates: Partial<typeof providers.$inferInsert> = {
updatedAt: new Date().toISOString(), updatedAt: timestamp(),
}; };
const name = request.name?.trim(); const name = request.name?.trim();
if (name === "") return { error: "供应商名称不能为空", status: 400 }; if (name === "") return { error: "供应商名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) { if (name !== undefined && name !== existing.name) {
const duplicate = db
.select({ id: providers.id })
.from(providers)
.where(and(eq(providers.name, name), notDeleted(providers), ne(providers.id, id)))
.get();
if (duplicate) return { error: "供应商名称已存在", status: 409 };
updates.name = name; updates.name = name;
} }
@@ -143,21 +180,23 @@ export function updateProvider(
return { provider: toProvider(existing) }; return { provider: toProvider(existing) };
} }
try { db.update(providers).set(updates).where(eq(providers.id, id)).run();
db.update(providers).set(updates).where(eq(providers.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "providers" }, "数据库操作失败");
throw e;
}
const updated = db.select().from(providers).where(eq(providers.id, id)).get(); const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) }; return { provider: toProvider(updated!) };
} }
function buildProviderOrderBy(
sortBy: string | undefined,
sortOrder: SortOrder | undefined,
): ((table: typeof providers) => ReturnType<typeof desc>) | undefined {
if (!sortBy) return (t) => desc(t.createdAt);
return sortOrder === "asc"
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
}
function toProvider(row: typeof providers.$inferSelect): Provider { function toProvider(row: typeof providers.$inferSelect): Provider {
return { return {
apiKey: row.apiKey, apiKey: row.apiKey,

View File

@@ -1,81 +1,68 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { baseColumns, index, integer, sqliteTable, text } from "./helpers";
export const projects = sqliteTable("projects", { export const projects = sqliteTable("projects", {
archivedAt: text("archived_at"), ...baseColumns,
createdAt: text("created_at").notNull(),
description: text("description").notNull().default(""), description: text("description").notNull().default(""),
id: text("id").primaryKey(), name: text("name").notNull(),
name: text("name").notNull().unique(),
status: text("status", { enum: ["active", "archived"] }) status: text("status", { enum: ["active", "archived"] })
.notNull() .notNull()
.default("active"), .default("active"),
updatedAt: text("updated_at").notNull(),
}); });
export const providers = sqliteTable("providers", { export const providers = sqliteTable("providers", {
...baseColumns,
apiKey: text("api_key").notNull(), apiKey: text("api_key").notNull(),
baseUrl: text("base_url").notNull(), baseUrl: text("base_url").notNull(),
createdAt: text("created_at").notNull(), name: text("name").notNull(),
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] }) type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
.notNull() .notNull()
.default("openai-compatible"), .default("openai-compatible"),
updatedAt: text("updated_at").notNull(),
}); });
export const models = sqliteTable( export const models = sqliteTable(
"models", "models",
{ {
...baseColumns,
capabilities: text("capabilities").notNull(), capabilities: text("capabilities").notNull(),
contextLength: integer("context_length"), contextLength: integer("context_length"),
createdAt: text("created_at").notNull(), externalId: text("external_id").notNull(),
id: text("id").primaryKey(),
maxOutputTokens: integer("max_output_tokens"), maxOutputTokens: integer("max_output_tokens"),
modelId: text("model_id").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
providerId: text("provider_id") providerId: text("provider_id")
.notNull() .notNull()
.references(() => providers.id), .references(() => providers.id),
updatedAt: text("updated_at").notNull(),
}, },
(table) => [ (table) => [index("models_provider_id_idx").on(table.providerId)],
uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId),
index("models_provider_id_idx").on(table.providerId),
],
); );
export const conversations = sqliteTable( export const conversations = sqliteTable(
"conversations", "conversations",
{ {
createdAt: text("created_at").notNull(), ...baseColumns,
id: text("id").primaryKey(), modelId: text("model_id").references(() => models.id),
modelId: text("model_id")
.notNull()
.references(() => models.id),
projectId: text("project_id") projectId: text("project_id")
.notNull() .notNull()
.references(() => projects.id), .references(() => projects.id),
title: text("title").notNull().default("新会话"), title: text("title").notNull().default("新会话"),
updatedAt: text("updated_at").notNull(),
}, },
(table) => [index("conversations_project_id_idx").on(table.projectId)], (table) => [
index("conversations_project_id_idx").on(table.projectId),
index("conversations_model_id_idx").on(table.modelId),
],
); );
export const materials = sqliteTable( export const materials = sqliteTable(
"materials", "materials",
{ {
...baseColumns,
associatedDate: text("associated_date").notNull(), associatedDate: text("associated_date").notNull(),
createdAt: text("created_at").notNull(),
description: text("description").notNull(), description: text("description").notNull(),
id: text("id").primaryKey(),
projectId: text("project_id") projectId: text("project_id")
.notNull() .notNull()
.references(() => projects.id), .references(() => projects.id),
status: text("status", { enum: ["pending", "approved", "discarded"] }) status: text("status", { enum: ["pending", "approved", "discarded"] })
.notNull() .notNull()
.default("pending"), .default("pending"),
updatedAt: text("updated_at").notNull(),
}, },
(table) => [index("materials_project_id_idx").on(table.projectId)], (table) => [index("materials_project_id_idx").on(table.projectId)],
); );
@@ -83,12 +70,11 @@ export const materials = sqliteTable(
export const messages = sqliteTable( export const messages = sqliteTable(
"messages", "messages",
{ {
...baseColumns,
content: text("content").notNull().default(""), content: text("content").notNull().default(""),
conversationId: text("conversation_id") conversationId: text("conversation_id")
.notNull() .notNull()
.references(() => conversations.id, { onDelete: "cascade" }), .references(() => conversations.id),
createdAt: text("created_at").notNull(),
id: text("id").primaryKey(),
parts: text("parts"), parts: text("parts"),
role: text("role", { enum: ["assistant", "system", "user"] }).notNull(), role: text("role", { enum: ["assistant", "system", "user"] }).notNull(),
}, },

View File

@@ -1,2 +1,4 @@
export type { ParsedListParams } from "./list-params";
export { parseListParams } from "./list-params";
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response"; export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
export { parseIdFromUrl } from "./url"; export { parseIdFromUrl } from "./url";

View File

@@ -0,0 +1,59 @@
import type { RuntimeMode, SortOrder } from "../../shared/api";
import { createApiError, jsonResponse } from "./index";
export interface ParsedListParams {
keyword?: string;
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: SortOrder;
}
export function parseListParams(
url: URL,
mode: RuntimeMode,
options?: { allowedSortBy?: string[] },
): ParsedListParams | Response {
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
let page = 1;
let pageSize = 20;
if (pageParam !== null) {
page = Number(pageParam);
if (!Number.isInteger(page) || page <= 0) {
return jsonResponse(createApiError("无效的 page 参数", 400), { mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("无效的 pageSize 参数", 400), { mode, status: 400 });
}
if (pageSize > 200) {
return jsonResponse(createApiError("pageSize 不能超过 200", 400), { mode, status: 400 });
}
}
const keyword = url.searchParams.get("keyword") ?? undefined;
const sortBy = url.searchParams.get("sortBy") ?? undefined;
const sortOrderParam = url.searchParams.get("sortOrder") ?? undefined;
if (sortBy && options?.allowedSortBy && !options.allowedSortBy.includes(sortBy)) {
return jsonResponse(createApiError("无效的 sortBy 参数", 400), { mode, status: 400 });
}
let sortOrder: SortOrder | undefined;
if (sortOrderParam) {
if (sortOrderParam !== "asc" && sortOrderParam !== "desc") {
return jsonResponse(createApiError("无效的 sortOrder 参数", 400), { mode, status: 400 });
}
sortOrder = sortOrderParam;
}
return { keyword, page, pageSize, sortBy, sortOrder };
}

View File

@@ -13,7 +13,7 @@ import {
updateConversation, updateConversation,
updateConversationTimestamp, updateConversationTimestamp,
} from "../../db/conversations"; } from "../../db/conversations";
import { getModelWithProvider } from "../../db/models"; import { getModelWithProvider, listModels } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers"; import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware"; import { validateIdParam } from "../../middleware";
@@ -79,13 +79,23 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
let model; let model;
try { try {
const result = getModelWithProvider(db, conversation.modelId); let effectiveModelId = conversation.modelId;
if (!effectiveModelId) {
const fallback = listModels(db, { page: 1, pageSize: 1 });
const firstModel = fallback.items[0];
if (!firstModel) {
return jsonResponse(createApiError("没有可用的模型,请先配置模型", 400), { mode, status: 400 });
}
effectiveModelId = firstModel.id;
}
const result = getModelWithProvider(db, effectiveModelId);
if ("error" in result) { if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
} }
const registry = buildProviderRegistry(db); const registry = buildProviderRegistry(db);
model = registry.languageModel(`${result.provider.id}:${result.model.modelId}`); model = registry.languageModel(`${result.provider.id}:${result.model.externalId}`);
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 }); return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });

View File

@@ -25,8 +25,8 @@ export async function handleCreateModel(
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 }); return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
} }
if (!body.modelId || typeof body.modelId !== "string") { if (!body.externalId || typeof body.externalId !== "string") {
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 }); return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
} }
if (!body.providerId || typeof body.providerId !== "string") { if (!body.providerId || typeof body.providerId !== "string") {

View File

@@ -4,24 +4,26 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger"; import type { Logger } from "../../logger";
import { listModels } from "../../db/models"; import { listModels } from "../../db/models";
import { jsonResponse } from "../../helpers"; import { jsonResponse, parseListParams } from "../../helpers";
import { validatePagination } from "../../middleware";
const ALLOWED_SORT_BY = ["createdAt", "name"];
export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url); const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const keyword = url.searchParams.get("keyword");
const providerId = url.searchParams.get("providerId"); const providerId = url.searchParams.get("providerId");
const capabilities = url.searchParams.get("capabilities");
const pagination = validatePagination(pageParam, pageSizeParam, mode); const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
if (pagination instanceof Response) return pagination; if (parsed instanceof Response) return parsed;
const result = listModels(db, { const result = listModels(db, {
keyword: keyword ?? undefined, capabilities: capabilities ?? undefined,
page: pagination.page, keyword: parsed.keyword,
pageSize: pagination.pageSize, page: parsed.page,
pageSize: parsed.pageSize,
providerId: providerId ?? undefined, providerId: providerId ?? undefined,
sortBy: parsed.sortBy,
sortOrder: parsed.sortOrder,
}); });
return jsonResponse(result, { mode }); return jsonResponse(result, { mode });

View File

@@ -25,8 +25,8 @@ export async function handleTestModelConfig(
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 }); return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
} }
if (!body.modelId || typeof body.modelId !== "string") { if (!body.externalId || typeof body.externalId !== "string") {
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 }); return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
} }
const providerResult = getProvider(db, body.providerId); const providerResult = getProvider(db, body.providerId);
@@ -41,7 +41,7 @@ export async function handleTestModelConfig(
{ {
apiKey: providerResult.provider.apiKey, apiKey: providerResult.provider.apiKey,
baseUrl: providerResult.provider.baseUrl, baseUrl: providerResult.provider.baseUrl,
modelId: body.modelId, modelId: body.externalId,
name: providerResult.provider.name, name: providerResult.provider.name,
type: providerResult.provider.type, type: providerResult.provider.type,
}, },
@@ -50,7 +50,7 @@ export async function handleTestModelConfig(
if (!testResult.ok) { if (!testResult.ok) {
logger.warn( logger.warn(
{ message: testResult.message, modelId: body.modelId, providerId: body.providerId }, { externalId: body.externalId, message: testResult.message, providerId: body.providerId },
"模型连接测试失败", "模型连接测试失败",
); );
} }

View File

@@ -4,27 +4,27 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger"; import type { Logger } from "../../logger";
import { listProjects } from "../../db/projects"; import { listProjects } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers"; import { createApiError, jsonResponse, parseListParams } from "../../helpers";
import { validatePagination } from "../../middleware";
const ALLOWED_SORT_BY = ["createdAt", "name", "updatedAt"];
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url); const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const keyword = url.searchParams.get("keyword");
const statusParam = url.searchParams.get("status"); const statusParam = url.searchParams.get("status");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
if (statusParam && statusParam !== "active" && statusParam !== "archived") { if (statusParam && statusParam !== "active" && statusParam !== "archived") {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 }); return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
} }
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
if (parsed instanceof Response) return parsed;
const result = listProjects(db, { const result = listProjects(db, {
keyword: keyword ?? undefined, keyword: parsed.keyword,
page: pagination.page, page: parsed.page,
pageSize: pagination.pageSize, pageSize: parsed.pageSize,
sortBy: parsed.sortBy,
sortOrder: parsed.sortOrder,
status: (statusParam as "active" | "archived") ?? undefined, status: (statusParam as "active" | "archived") ?? undefined,
}); });

View File

@@ -4,22 +4,24 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger"; import type { Logger } from "../../logger";
import { listProviders } from "../../db/providers"; import { listProviders } from "../../db/providers";
import { jsonResponse } from "../../helpers"; import { jsonResponse, parseListParams } from "../../helpers";
import { validatePagination } from "../../middleware";
const ALLOWED_SORT_BY = ["createdAt", "name"];
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response { export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url); const url = new URL(req.url);
const pageParam = url.searchParams.get("page"); const typeParam = url.searchParams.get("type");
const pageSizeParam = url.searchParams.get("pageSize");
const keyword = url.searchParams.get("keyword");
const pagination = validatePagination(pageParam, pageSizeParam, mode); const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
if (pagination instanceof Response) return pagination; if (parsed instanceof Response) return parsed;
const result = listProviders(db, { const result = listProviders(db, {
keyword: keyword ?? undefined, keyword: parsed.keyword,
page: pagination.page, page: parsed.page,
pageSize: pagination.pageSize, pageSize: parsed.pageSize,
sortBy: parsed.sortBy,
sortOrder: parsed.sortOrder,
type: typeParam ?? undefined,
}); });
return jsonResponse(result, { mode }); return jsonResponse(result, { mode });

View File

@@ -6,7 +6,7 @@ export interface ApiErrorResponse {
export interface Conversation { export interface Conversation {
createdAt: string; createdAt: string;
id: string; id: string;
modelId: string; modelId: null | string;
projectId: string; projectId: string;
title: string; title: string;
updatedAt: string; updatedAt: string;
@@ -36,8 +36,8 @@ export interface CreateMaterialRequest {
export interface CreateModelRequest { export interface CreateModelRequest {
capabilities: ModelCapability[]; capabilities: ModelCapability[];
contextLength?: null | number; contextLength?: null | number;
externalId: string;
maxOutputTokens?: null | number; maxOutputTokens?: null | number;
modelId: string;
name: string; name: string;
providerId: string; providerId: string;
} }
@@ -59,6 +59,11 @@ export interface CreateProviderRequest {
// 前后端共享的类型都放在这个文件中 // 前后端共享的类型都放在这个文件中
// ========================================== // ==========================================
export interface ListSortParams {
sortBy?: string;
sortOrder?: SortOrder;
}
export interface Material { export interface Material {
associatedDate: string; associatedDate: string;
createdAt: string; createdAt: string;
@@ -89,6 +94,7 @@ export interface Message {
id: string; id: string;
parts: null | string; parts: null | string;
role: "assistant" | "system" | "user"; role: "assistant" | "system" | "user";
updatedAt: string;
} }
export interface MessageListResponse { export interface MessageListResponse {
@@ -109,9 +115,9 @@ export interface Model {
capabilities: ModelCapability[]; capabilities: ModelCapability[];
contextLength: null | number; contextLength: null | number;
createdAt: string; createdAt: string;
externalId: string;
id: string; id: string;
maxOutputTokens: null | number; maxOutputTokens: null | number;
modelId: string;
name: string; name: string;
providerId: string; providerId: string;
updatedAt: string; updatedAt: string;
@@ -127,6 +133,8 @@ export type ModelCapability =
| "video-generation" | "video-generation"
| "video-recognition"; | "video-recognition";
export type SortOrder = "asc" | "desc";
export interface UpdateConversationRequest { export interface UpdateConversationRequest {
modelId?: string; modelId?: string;
title?: string; title?: string;
@@ -164,7 +172,6 @@ export interface ModelTestResultResponse {
} }
export interface Project { export interface Project {
archivedAt: null | string;
createdAt: string; createdAt: string;
description: string; description: string;
id: string; id: string;
@@ -231,15 +238,15 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
export type RuntimeMode = "development" | "production" | "test"; export type RuntimeMode = "development" | "production" | "test";
export interface TestModelRequest { export interface TestModelRequest {
modelId: string; externalId: string;
providerId: string; providerId: string;
} }
export interface UpdateModelRequest { export interface UpdateModelRequest {
capabilities?: ModelCapability[]; capabilities?: ModelCapability[];
contextLength?: null | number; contextLength?: null | number;
externalId?: string;
maxOutputTokens?: null | number; maxOutputTokens?: null | number;
modelId?: string;
name?: string; name?: string;
providerId?: string; providerId?: string;
} }

View File

@@ -1,15 +1,12 @@
import { DeleteOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Conversations } from "@ant-design/x"; import { App } from "antd";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Button, Spin } from "antd";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Conversation } from "../../../shared/api"; import { createConversation, deleteConversation } from "../../shared/hooks/use-conversations";
import { createConversation, deleteConversation, fetchConversations } from "../../shared/hooks/use-conversations";
import { useCurrentProject } from "../../shared/hooks/use-current-project"; import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useModelList } from "../../shared/hooks/use-models"; import { useModelList } from "../../shared/hooks/use-models";
import { ChatPanel } from "./ChatPanel"; import { ChatPanel } from "./ChatPanel";
import { ConversationSidebar } from "./components/ConversationSidebar";
export function ChatPage() { export function ChatPage() {
const project = useCurrentProject(); const project = useCurrentProject();
@@ -19,11 +16,6 @@ export function ChatPage() {
const CONVERSATIONS_KEY = ["conversations", project.id] as const; const CONVERSATIONS_KEY = ["conversations", project.id] as const;
const { data, isLoading } = useQuery({
queryFn: () => fetchConversations(project.id),
queryKey: CONVERSATIONS_KEY,
});
const { data: modelsData } = useModelList({ pageSize: 200 }); const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo( const textModels = useMemo(
@@ -44,58 +36,26 @@ export function ChatPage() {
}, },
}); });
const conversations = useMemo( const handleAddConversation = () => {
() => (data?.items ?? []).map((c: Conversation) => ({ key: c.id, label: c.title })), void createConversation(project.id, defaultModelId ?? undefined)
[data], .then((conv) => {
); void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
};
return ( return (
<div className="app-chat-page"> <div className="app-chat-page">
<div className="app-chat-conversations"> <ConversationSidebar
<div className="app-chat-conversations-header"> onAddClick={handleAddConversation}
<Button onDelete={(id) => deleteMutation.mutate(id)}
block onSelect={setActiveConversationId}
icon={<PlusOutlined />} projectId={project.id}
onClick={() => { selectedId={activeConversationId}
void createConversation(project.id, defaultModelId ?? undefined) />
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
}}
type="primary"
>
</Button>
</div>
{isLoading ? (
<Spin />
) : (
<Conversations
activeKey={activeConversationId ?? ""}
items={conversations}
menu={(conv) => ({
items: [
{
danger: true,
icon: <DeleteOutlined />,
key: "delete",
label: "删除",
onClick: () => {
deleteMutation.mutate(conv.key);
},
},
],
trigger: <MoreOutlined />,
})}
onActiveChange={(key) => setActiveConversationId(key)}
rootClassName="app-chat-conversations-list"
/>
)}
</div>
<ChatPanel <ChatPanel
conversationId={activeConversationId} conversationId={activeConversationId}
defaultModelId={defaultModelId} defaultModelId={defaultModelId}

View File

@@ -0,0 +1,45 @@
import { DeleteOutlined } from "@ant-design/icons";
import { Button, Flex, Popconfirm, Typography } from "antd";
import type { Conversation } from "../../../../shared/api";
interface ConversationCardProps {
conversation: Conversation;
onDelete: () => void;
onSelect: () => void;
selected: boolean;
}
export function ConversationCard({ conversation, onDelete, onSelect, selected }: ConversationCardProps) {
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
return (
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
<Typography.Text ellipsis style={{ flex: 1, minWidth: 0 }}>
{conversation.title}
</Typography.Text>
<span className="app-sidebar-item-actions">
<Popconfirm
description="删除后不可恢复"
okButtonProps={{ danger: true }}
okText="删除"
onCancel={(e) => e?.stopPropagation()}
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
title="确认删除该对话?"
>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
</span>
</Flex>
);
}

View File

@@ -0,0 +1,94 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty, Input, Skeleton } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useMemo, useState } from "react";
import type { Conversation } from "../../../../shared/api";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { ConversationCard } from "./ConversationCard";
interface ConversationListProps {
conversations: readonly Conversation[];
loading: boolean;
onAddClick: () => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
selectedId: null | string;
}
export function ConversationList({
conversations,
loading,
onAddClick,
onDelete,
onSelect,
selectedId,
}: ConversationListProps) {
const [inputText, setInputText] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const isDark = useIsDark();
const filteredConversations = useMemo(() => {
if (!appliedSearch) return conversations;
const lower = appliedSearch.toLowerCase();
return conversations.filter((c) => c.title.toLowerCase().includes(lower));
}, [conversations, appliedSearch]);
const groupedConversations = useMemo(() => groupByDate(filteredConversations, "updatedAt"), [filteredConversations]);
return (
<div className="app-sidebar-list" style={{ width: 260 }}>
<div className="app-sidebar-list-header">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Input.Search
allowClear
onChange={(e) => setInputText(e.target.value)}
onSearch={(value) => setAppliedSearch(value.trim())}
placeholder="搜索对话"
value={inputText}
/>
</div>
<OverlayScrollbarsComponent
className="app-sidebar-list-body"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: {
autoHide: "move",
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
},
}}
>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : conversations.length === 0 ? (
<Empty description="暂无对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : filteredConversations.length === 0 ? (
<Empty description="无匹配对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
groupedConversations.map((group) => {
if (group.items.length === 0) return null;
return (
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
{group.items.map((conv) => (
<ConversationCard
conversation={conv}
key={conv.id}
onDelete={() => onDelete(conv.id)}
onSelect={() => onSelect(conv.id)}
selected={conv.id === selectedId}
/>
))}
</SidebarGroup>
);
})
)}
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { Result } from "antd";
import { fetchConversations } from "../../../shared/hooks/use-conversations";
import { ConversationList } from "./ConversationList";
interface ConversationSidebarProps {
onAddClick: () => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
projectId: string;
selectedId: null | string;
}
export function ConversationSidebar({
onAddClick,
onDelete,
onSelect,
projectId,
selectedId,
}: ConversationSidebarProps) {
const CONVERSATIONS_KEY = ["conversations", projectId] as const;
const { data, error, isLoading, refetch } = useQuery({
queryFn: () => fetchConversations(projectId),
queryKey: CONVERSATIONS_KEY,
});
if (error) {
return (
<div className="app-sidebar-list" style={{ width: 260 }}>
<Result
extra={<button onClick={() => void refetch()}></button>}
status="error"
subTitle="加载对话列表失败"
/>
</div>
);
}
return (
<ConversationList
conversations={data?.items ?? []}
loading={isLoading}
onAddClick={onAddClick}
onDelete={onDelete}
onSelect={onSelect}
selectedId={selectedId}
/>
);
}

View File

@@ -1,5 +1,5 @@
import { Typography } from "antd"; import { Typography } from "antd";
import Markdown from "markdown-to-jsx/react"; import { Markdown } from "markdown-to-jsx/react";
import type { PartProps } from "./types"; import type { PartProps } from "./types";

View File

@@ -10,11 +10,6 @@ interface MaterialCardProps {
selected: boolean; selected: boolean;
} }
function formatAssociatedDate(date: string): string {
if (!date) return "—";
return date;
}
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = { const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
approved: { color: "green", label: "已通过" }, approved: { color: "green", label: "已通过" },
discarded: { color: "red", label: "已放弃" }, discarded: { color: "red", label: "已放弃" },
@@ -27,15 +22,13 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
return ( return (
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}> <Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
<div style={{ flex: 1, minWidth: 0 }}> <Typography.Paragraph
<Typography.Text ellipsis strong={selected}> className="material-item-desc"
{material.description} ellipsis={{ rows: 2 }}
</Typography.Text> style={{ flex: 1, margin: 0, minWidth: 0 }}
<br /> >
<Typography.Text className="material-item-time" type="secondary"> {material.description}
{formatAssociatedDate(material.associatedDate)} </Typography.Paragraph>
</Typography.Text>
</div>
<div className="material-item-right"> <div className="material-item-right">
<span className="material-item-tag"> <span className="material-item-tag">
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>} {statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}

View File

@@ -1,39 +0,0 @@
import { CaretDownOutlined, CaretRightOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import { type ReactNode, useState } from "react";
interface MaterialGroupProps {
children: ReactNode;
count: number;
emptyText?: string;
label: string;
}
export function MaterialGroup({ children, count, emptyText, label }: MaterialGroupProps) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="app-inbox-group">
<div className="app-inbox-group-header" onClick={() => setCollapsed(!collapsed)}>
<span className="app-inbox-group-arrow">{collapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}</span>
<Typography.Text className="app-inbox-group-label" type="secondary">
{label}
</Typography.Text>
<Typography.Text className="app-inbox-group-count" type="secondary">
({count})
</Typography.Text>
</div>
{!collapsed && (
<div className="app-inbox-group-content">
{count === 0 && emptyText ? (
<Typography.Text className="app-inbox-group-empty" type="secondary">
{emptyText}
</Typography.Text>
) : (
children
)}
</div>
)}
</div>
);
}

View File

@@ -6,14 +6,16 @@ import {
PlusOutlined, PlusOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Button, Empty, Segmented, Skeleton } from "antd"; import { Button, Empty, Segmented, Skeleton } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Material } from "../types"; import type { Material } from "../types";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { MaterialCard } from "./MaterialCard"; import { MaterialCard } from "./MaterialCard";
import { MaterialGroup } from "./MaterialGroup";
type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
interface MaterialListProps { interface MaterialListProps {
loading: boolean; loading: boolean;
@@ -24,55 +26,6 @@ interface MaterialListProps {
selectedId: null | string; selectedId: null | string;
} }
const GROUP_LABELS: Record<DateGroup, string> = {
earlier: "更早",
thisMonth: "本月",
thisWeek: "本周",
today: "今天",
yesterday: "昨天",
};
const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"];
interface MaterialGroupData {
items: Material[];
key: DateGroup;
}
function getDateGroup(dateStr: string, now: Date): DateGroup {
const date = new Date(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86_400_000);
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateDay.getTime() >= today.getTime()) return "today";
if (dateDay.getTime() >= yesterday.getTime()) return "yesterday";
const dayOfWeek = today.getDay();
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today.getTime() - mondayOffset * 86_400_000);
if (dateDay.getTime() >= monday.getTime()) return "thisWeek";
if (dateDay.getFullYear() === today.getFullYear() && dateDay.getMonth() === today.getMonth()) {
return "thisMonth";
}
return "earlier";
}
function groupMaterialsByDate(materials: readonly Material[]): MaterialGroupData[] {
const now = new Date();
const groups = new Map<DateGroup, Material[]>();
for (const m of materials) {
const group = getDateGroup(m.createdAt, now);
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(m);
}
return GROUP_ORDER.map((key) => ({ items: groups.get(key) ?? [], key }));
}
const STATUS_FILTER_OPTIONS = [ const STATUS_FILTER_OPTIONS = [
{ icon: <AppstoreOutlined />, label: "全部", value: "all" }, { icon: <AppstoreOutlined />, label: "全部", value: "all" },
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" }, { color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
@@ -84,13 +37,14 @@ type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) { export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
const [filterStatus, setFilterStatus] = useState<FilterValue>("all"); const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
const isDark = useIsDark();
const filteredMaterials = useMemo(() => { const filteredMaterials = useMemo(() => {
if (filterStatus === "all") return materials; if (filterStatus === "all") return materials;
return materials.filter((m) => m.status === filterStatus); return materials.filter((m) => m.status === filterStatus);
}, [materials, filterStatus]); }, [materials, filterStatus]);
const groupedMaterials = useMemo(() => groupMaterialsByDate(filteredMaterials), [filteredMaterials]); const groupedMaterials = useMemo(() => groupByDate(filteredMaterials, "createdAt"), [filteredMaterials]);
const segmentedOptions = useMemo( const segmentedOptions = useMemo(
() => () =>
@@ -106,15 +60,24 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
[materials], [materials],
); );
const showAllGroups = filterStatus === "all";
return ( return (
<div className="app-inbox-sidebar"> <div className="app-sidebar-list" style={{ width: 260 }}>
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary"> <div className="app-sidebar-list-header">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} /> </Button>
<div className="app-inbox-list"> <Segmented block onChange={(value) => setFilterStatus(value)} options={segmentedOptions} value={filterStatus} />
</div>
<OverlayScrollbarsComponent
className="app-sidebar-list-body"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: {
autoHide: "move",
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
},
}}
>
{loading ? ( {loading ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} /> <Skeleton active paragraph={{ rows: 6 }} title={false} />
) : materials.length === 0 ? ( ) : materials.length === 0 ? (
@@ -123,14 +86,9 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
<Empty description="当前筛选条件下无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} /> <Empty description="当前筛选条件下无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : ( ) : (
groupedMaterials.map((group) => { groupedMaterials.map((group) => {
if (!showAllGroups && group.items.length === 0) return null; if (group.items.length === 0) return null;
return ( return (
<MaterialGroup <SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
count={group.items.length}
emptyText="暂无"
key={group.key}
label={GROUP_LABELS[group.key]}
>
{group.items.map((material) => ( {group.items.map((material) => (
<MaterialCard <MaterialCard
key={material.id} key={material.id}
@@ -140,11 +98,11 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
selected={material.id === selectedId} selected={material.id === selectedId}
/> />
))} ))}
</MaterialGroup> </SidebarGroup>
); );
}) })
)} )}
</div> </OverlayScrollbarsComponent>
</div> </div>
); );
} }

View File

@@ -0,0 +1,189 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Space } from "antd";
import { useCallback, useMemo, useState } from "react";
import type { Model, TestModelRequest } from "../../../shared/api";
import { FilterToolbar } from "../../shared/components/FilterToolbar";
import {
useCreateModel,
useDeleteModel,
useModelList,
useTestModelConnection,
useUpdateModel,
} from "../../shared/hooks/use-models";
import { useProviderOptions } from "../../shared/hooks/use-providers";
import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
import { ModelFormModal } from "./components/ModelFormModal";
import { ModelTable } from "./components/ModelTable";
const CAPABILITY_OPTIONS = [
{ label: "文本", value: "text" },
{ label: "推理", value: "reasoning" },
{ label: "图片识别", value: "image-recognition" },
{ label: "图片生成", value: "image-generation" },
{ label: "音频识别", value: "audio-recognition" },
{ label: "音频生成", value: "audio-generation" },
{ label: "视频识别", value: "video-recognition" },
{ label: "视频生成", value: "video-generation" },
];
export function ModelListPage() {
const { confirmAction } = useConfirmAction();
const { params, resetAll, setParams } = usePageSearchParams({
defaults: { page: "1", pageSize: "20" },
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const apiPage = Number(params["page"]) || 1;
const apiPageSize = Number(params["pageSize"]) || 20;
const keyword = params["keyword"] ?? "";
const providerIdFilter = params["providerId"];
const capabilitiesFilter = params["capabilities"];
const sortBy = params["sortBy"];
const sortOrder = params["sortOrder"];
const { data: modelData, isFetching: modelLoading } = useModelList({
capabilities: capabilitiesFilter ?? undefined,
keyword: keyword ?? undefined,
page: apiPage,
pageSize: apiPageSize,
providerId: providerIdFilter ?? undefined,
sortBy: sortBy ?? undefined,
sortOrder: sortOrder,
});
const {
data: providerOptionsData,
error: providerOptionsError,
isError: providerOptionsIsError,
isLoading: providerOptionsLoading,
} = useProviderOptions();
const createModelMutation = useCreateModel();
const updateModelMutation = useUpdateModel();
const deleteModelMutation = useDeleteModel();
const testModelMutation = useTestModelConnection();
const isSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
const modelProviders = useMemo(() => providerOptionsData?.items ?? [], [providerOptionsData?.items]);
const providerOptions = useMemo(() => modelProviders.map((p) => ({ label: p.name, value: p.id })), [modelProviders]);
const handleSearch = useCallback(
(value: string) => {
setParams({ keyword: value || undefined, page: "1" });
},
[setParams],
);
const handleReset = useCallback(() => {
resetAll();
}, [resetAll]);
const handleTableChange = useCallback(
(
pagination: { current?: number; pageSize?: number },
sorter: { columnKey?: string; field?: string | string[]; order?: string },
) => {
const patch: Record<string, string | undefined> = {
page: String(pagination.current ?? apiPage),
pageSize: String(pagination.pageSize ?? apiPageSize),
};
const sortField = sorter.columnKey! ?? (typeof sorter.field === "string" ? sorter.field : undefined);
if (sorter.order && sortField) {
patch["sortBy"] = sortField;
patch["sortOrder"] = sorter.order === "ascend" ? "asc" : "desc";
} else {
patch["sortBy"] = undefined;
patch["sortOrder"] = undefined;
}
setParams(patch);
},
[setParams, apiPage, apiPageSize],
);
const filters = useMemo(
() => [
{
key: "providerId",
label: "供应商",
onChange: (value: string | undefined) => {
setParams({ page: "1", providerId: value });
},
options: providerOptions,
placeholder: "供应商",
value: providerIdFilter,
},
{
key: "capabilities",
label: "能力",
onChange: (value: string | undefined) => {
setParams({ capabilities: value, page: "1" });
},
options: CAPABILITY_OPTIONS,
placeholder: "能力",
value: capabilitiesFilter,
},
],
[setParams, providerOptions, providerIdFilter, capabilitiesFilter],
);
const handleDelete = useCallback(
(id: string) => confirmAction(() => deleteModelMutation.mutateAsync(id), "模型已删除"),
[confirmAction, deleteModelMutation],
);
return (
<Space className="app-page-flex" orientation="vertical" size="large">
<FilterToolbar
actions={
<Button
icon={<PlusOutlined />}
onClick={() => {
setEditingModel(null);
setDialogOpen(true);
}}
type="primary"
>
</Button>
}
filters={filters}
search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索模型名称或 ID" }}
/>
<ModelTable
data={modelData}
loading={modelLoading || providerOptionsLoading}
onChange={handleTableChange}
onDelete={handleDelete}
onEdit={(model) => {
setEditingModel(model);
setDialogOpen(true);
}}
page={apiPage}
pageSize={apiPageSize}
providers={modelProviders}
sortBy={sortBy}
sortOrder={sortOrder}
/>
<ModelFormModal
editingModel={editingModel}
onCancel={() => setDialogOpen(false)}
onCreate={(data) => createModelMutation.mutateAsync(data)}
onOpenChange={setDialogOpen}
onUpdate={(args) => updateModelMutation.mutateAsync(args)}
open={dialogOpen}
providers={modelProviders}
providersError={providerOptionsIsError ? providerOptionsError : null}
providersLoading={providerOptionsLoading}
submitting={isSubmitting}
testModelConnection={(data: TestModelRequest) => testModelMutation.mutateAsync(data)}
/>
</Space>
);
}

View File

@@ -0,0 +1,157 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Space } from "antd";
import { useCallback, useMemo, useState } from "react";
import type { Provider } from "../../../shared/api";
import { FilterToolbar } from "../../shared/components/FilterToolbar";
import {
useCreateProvider,
useDeleteProvider,
useProviderList,
useTestProviderConfig,
useUpdateProvider,
} from "../../shared/hooks/use-providers";
import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
import { ProviderFormModal } from "./components/ProviderFormModal";
import { ProviderTable } from "./components/ProviderTable";
const TYPE_OPTIONS = [
{ label: "OpenAI", value: "openai" },
{ label: "OpenAI 兼容", value: "openai-compatible" },
{ label: "Anthropic", value: "anthropic" },
];
export function ProviderListPage() {
const { confirmAction } = useConfirmAction();
const { params, resetAll, setParams } = usePageSearchParams({
defaults: { page: "1", pageSize: "20" },
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<null | Provider>(null);
const apiPage = Number(params["page"]) || 1;
const apiPageSize = Number(params["pageSize"]) || 20;
const keyword = params["keyword"] ?? "";
const typeFilter = params["type"];
const sortBy = params["sortBy"];
const sortOrder = params["sortOrder"];
const { data: providerData, isFetching: providerLoading } = useProviderList({
keyword: keyword || undefined,
page: apiPage,
pageSize: apiPageSize,
sortBy: sortBy ?? undefined,
sortOrder: sortOrder,
type: typeFilter ?? undefined,
});
const createProviderMutation = useCreateProvider();
const updateProviderMutation = useUpdateProvider();
const deleteProviderMutation = useDeleteProvider();
const testProviderConfigMutation = useTestProviderConfig();
const isSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
const handleSearch = useCallback(
(value: string) => {
setParams({ keyword: value || undefined, page: "1" });
},
[setParams],
);
const handleReset = useCallback(() => {
resetAll();
}, [resetAll]);
const handleTableChange = useCallback(
(
pagination: { current?: number; pageSize?: number },
sorter: { columnKey?: string; field?: string | string[]; order?: string },
) => {
const patch: Record<string, string | undefined> = {
page: String(pagination.current ?? apiPage),
pageSize: String(pagination.pageSize ?? apiPageSize),
};
const sortField = sorter.columnKey! ?? (typeof sorter.field === "string" ? sorter.field : undefined);
if (sorter.order && sortField) {
patch["sortBy"] = sortField;
patch["sortOrder"] = sorter.order === "ascend" ? "asc" : "desc";
} else {
patch["sortBy"] = undefined;
patch["sortOrder"] = undefined;
}
setParams(patch);
},
[setParams, apiPage, apiPageSize],
);
const filters = useMemo(
() => [
{
key: "type",
label: "类型",
onChange: (value: string | undefined) => {
setParams({ page: "1", type: value });
},
options: TYPE_OPTIONS,
placeholder: "类型",
value: typeFilter,
},
],
[setParams, typeFilter],
);
const handleDelete = useCallback(
(id: string) => confirmAction(() => deleteProviderMutation.mutateAsync(id), "供应商已删除"),
[confirmAction, deleteProviderMutation],
);
return (
<Space className="app-page-flex" orientation="vertical" size="large">
<FilterToolbar
actions={
<Button
icon={<PlusOutlined />}
onClick={() => {
setEditingProvider(null);
setDialogOpen(true);
}}
type="primary"
>
</Button>
}
filters={filters}
search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索供应商名称" }}
/>
<ProviderTable
data={providerData}
loading={providerLoading}
onChange={handleTableChange}
onDelete={handleDelete}
onEdit={(provider) => {
setEditingProvider(provider);
setDialogOpen(true);
}}
page={apiPage}
pageSize={apiPageSize}
sortBy={sortBy}
sortOrder={sortOrder}
/>
<ProviderFormModal
editingProvider={editingProvider}
onCancel={() => setDialogOpen(false)}
onCreate={(data) => createProviderMutation.mutateAsync(data)}
onOpenChange={setDialogOpen}
onTest={(data) => testProviderConfigMutation.mutateAsync(data)}
onUpdate={(args) => updateProviderMutation.mutateAsync(args)}
open={dialogOpen}
submitting={isSubmitting}
/>
</Space>
);
}

View File

@@ -14,8 +14,8 @@ import type {
interface FormValues { interface FormValues {
capabilities: ModelCapability[]; capabilities: ModelCapability[];
contextLength: null | number; contextLength: null | number;
externalId: string;
maxOutputTokens: null | number; maxOutputTokens: null | number;
modelId: string;
name: string; name: string;
providerId: string; providerId: string;
} }
@@ -70,8 +70,8 @@ export function ModelFormModal({
form.setFieldsValue({ form.setFieldsValue({
capabilities: editingModel.capabilities, capabilities: editingModel.capabilities,
contextLength: editingModel.contextLength, contextLength: editingModel.contextLength,
externalId: editingModel.externalId,
maxOutputTokens: editingModel.maxOutputTokens, maxOutputTokens: editingModel.maxOutputTokens,
modelId: editingModel.modelId,
name: editingModel.name, name: editingModel.name,
providerId: editingModel.providerId, providerId: editingModel.providerId,
}); });
@@ -86,7 +86,7 @@ export function ModelFormModal({
if (editingModel) { if (editingModel) {
const reqData: UpdateModelRequest = {}; const reqData: UpdateModelRequest = {};
if (values.name !== editingModel.name) reqData.name = values.name; if (values.name !== editingModel.name) reqData.name = values.name;
if (values.modelId !== editingModel.modelId) reqData.modelId = values.modelId; if (values.externalId !== editingModel.externalId) reqData.externalId = values.externalId;
if (values.providerId !== editingModel.providerId) reqData.providerId = values.providerId; if (values.providerId !== editingModel.providerId) reqData.providerId = values.providerId;
const capsChanged = const capsChanged =
values.capabilities.length !== editingModel.capabilities.length || values.capabilities.length !== editingModel.capabilities.length ||
@@ -100,8 +100,8 @@ export function ModelFormModal({
const reqData: CreateModelRequest = { const reqData: CreateModelRequest = {
capabilities: values.capabilities, capabilities: values.capabilities,
contextLength: values.contextLength ?? undefined, contextLength: values.contextLength ?? undefined,
externalId: values.externalId,
maxOutputTokens: values.maxOutputTokens ?? undefined, maxOutputTokens: values.maxOutputTokens ?? undefined,
modelId: values.modelId,
name: values.name, name: values.name,
providerId: values.providerId, providerId: values.providerId,
}; };
@@ -119,18 +119,18 @@ export function ModelFormModal({
const handleTest = async () => { const handleTest = async () => {
if (!testModelConnection) return; if (!testModelConnection) return;
const providerId: unknown = form.getFieldValue("providerId"); const providerId: unknown = form.getFieldValue("providerId");
const modelId: unknown = form.getFieldValue("modelId"); const externalId: unknown = form.getFieldValue("externalId");
if (typeof providerId !== "string" || !providerId) { if (typeof providerId !== "string" || !providerId) {
message.warning("请先选择供应商"); message.warning("请先选择供应商");
return; return;
} }
if (typeof modelId !== "string" || !modelId) { if (typeof externalId !== "string" || !externalId) {
message.warning("请先输入模型 ID"); message.warning("请先输入模型 ID");
return; return;
} }
setTesting(true); setTesting(true);
try { try {
const result = await testModelConnection({ modelId, providerId }); const result = await testModelConnection({ externalId, providerId });
if (result.ok) { if (result.ok) {
message.success(result.message); message.success(result.message);
} else { } else {
@@ -177,7 +177,7 @@ export function ModelFormModal({
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label="模型 ID" label="模型 ID"
name="modelId" name="externalId"
rules={[{ message: "请输入模型 ID", required: true, whitespace: true }]} rules={[{ message: "请输入模型 ID", required: true, whitespace: true }]}
> >
<Input placeholder="gpt-4o, claude-3-opus-20240229, deepseek-chat 等" /> <Input placeholder="gpt-4o, claude-3-opus-20240229, deepseek-chat 等" />

View File

@@ -1,20 +1,27 @@
import type { TableColumnsType } from "antd"; import type { TableColumnsType, TableProps } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd"; import { Button, Popconfirm, Space, Table, Tag } from "antd";
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api"; import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api";
import { formatDatetime } from "../../../shared/utils/format";
interface ModelTableProps { interface ModelTableProps {
data: ModelListResponse | undefined; data: ModelListResponse | undefined;
loading: boolean; loading: boolean;
onDelete: (id: string) => Promise<unknown>; onChange: (
pagination: { current?: number; pageSize?: number },
sorter: { columnKey?: string; field?: string | string[]; order?: string },
) => void;
onDelete: (id: string) => Promise<void>;
onEdit: (model: Model) => void; onEdit: (model: Model) => void;
onPageChange: (page: number, pageSize: number) => void;
page: number; page: number;
pageSize: number; pageSize: number;
providers: ProviderOption[]; providers: ProviderOption[];
sortBy?: string;
sortOrder?: string;
} }
const CAPABILITY_LABELS: Record<string, string> = { const CAPABILITY_LABELS: Record<string, string> = {
@@ -28,110 +35,111 @@ const CAPABILITY_LABELS: Record<string, string> = {
"video-recognition": "视频识别", "video-recognition": "视频识别",
}; };
function getProviderName(providerId: string, providers: ProviderOption[]): string {
return providers.find((p) => p.id === providerId)?.name ?? providerId;
}
const COLUMNS: TableColumnsType<Model> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
{
dataIndex: "providerId",
ellipsis: true,
title: "供应商",
width: 120,
},
{
dataIndex: "capabilities",
render: (value: string[]) =>
value.length > 0 ? (
<Space size={[4, 4]} wrap>
{value.map((c) => (
<Tag key={c}>{CAPABILITY_LABELS[c] ?? c}</Tag>
))}
</Space>
) : null,
title: "能力",
},
];
export function ModelTable({ export function ModelTable({
data, data,
loading, loading,
onChange,
onDelete, onDelete,
onEdit, onEdit,
onPageChange,
page, page,
pageSize, pageSize,
providers, providers,
sortBy,
sortOrder,
}: ModelTableProps) { }: ModelTableProps) {
const { message } = AntApp.useApp(); const columns = useMemo<TableColumnsType<Model>>(
() => [
const handleDelete = useCallback( {
async (id: string) => { dataIndex: "name",
try { ellipsis: true,
await onDelete(id); sorter: true,
message.success("模型已删除"); sortOrder:
} catch (err: unknown) { sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
message.error((err as Error).message); title: "名称",
} width: 180,
}, },
[onDelete, message], {
); dataIndex: "providerId",
ellipsis: true,
const columnsWithProvider = useMemo<TableColumnsType<Model>>( render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
() => title: "供应商",
COLUMNS.map((col) => width: 120,
"dataIndex" in col && col.dataIndex === "providerId" },
? { {
...col, dataIndex: "capabilities",
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers), render: (value: string[]) =>
} value.length > 0 ? (
: col, <Space size={[4, 4]} wrap>
), {value.map((c) => (
[providers], <Tag key={c}>{CAPABILITY_LABELS[c] ?? c}</Tag>
); ))}
</Space>
const operationColumn = useMemo<TableColumnsType<Model>[number]>( ) : null,
() => ({ title: "能力",
dataIndex: "op", },
render: (_value: unknown, record: Model) => ( {
<Space size="small"> align: "center",
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link"> dataIndex: "createdAt",
render: (_value: unknown, record: Model) => formatDatetime(record.createdAt),
</Button> sorter: true,
<Popconfirm sortOrder:
description="此操作不可恢复。" sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
onConfirm={() => void handleDelete(record.id)} title: "创建时间",
title="确认删除此模型?" width: 180,
> },
<Button danger icon={<DeleteOutlined />} size="small" type="link"> {
dataIndex: "op",
render: (_value: unknown, record: Model) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button> </Button>
</Popconfirm> <Popconfirm
</Space> description="此操作不可恢复。"
), onConfirm={() => void onDelete(record.id)}
title: "操作", title="确认删除此模型?"
width: 180, >
}), <Button danger icon={<DeleteOutlined />} size="small" type="link">
[onEdit, handleDelete],
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
},
],
[onEdit, onDelete, providers, sortBy, sortOrder],
); );
const columns = useMemo(() => [...columnsWithProvider, operationColumn], [columnsWithProvider, operationColumn]); const handleTableChange: TableProps<Model>["onChange"] = (pagination, _filters, sorter) => {
const sortInfo =
sorter && typeof sorter === "object" && "columnKey" in sorter
? (sorter as { columnKey?: string; field?: string | string[]; order?: string })
: {};
onChange({ current: pagination.current, pageSize: pagination.pageSize }, sortInfo);
};
return ( return (
<Table <Table
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
loading={loading} loading={loading}
locale={{ emptyText: "暂无模型数据" }}
onChange={handleTableChange}
pagination={{ pagination={{
current: page, current: page,
hideOnSinglePage: false, hideOnSinglePage: false,
onChange: onPageChange,
pageSize, pageSize,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total}`,
total: data?.total ?? 0, total: data?.total ?? 0,
}} }}
rowKey="id" rowKey="id"
/> />
); );
} }
function getProviderName(providerId: string, providers: ProviderOption[]): string {
return providers.find((p) => p.id === providerId)?.name ?? providerId;
}

View File

@@ -1,53 +0,0 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Space, Tabs } from "antd";
import { useState } from "react";
interface ModelsToolbarProps {
activeTab: string;
keyword: string;
onSearch: (value: string) => void;
onSearchClear: () => void;
onTabChange: (key: string) => void;
openCreateDialog: () => void;
}
const TAB_ITEMS = [
{ key: "models", label: "模型" },
{ key: "providers", label: "供应商" },
];
export function ModelsToolbar({
activeTab,
keyword,
onSearch,
onSearchClear,
onTabChange,
openCreateDialog,
}: ModelsToolbarProps) {
const [draftKeyword, setDraftKeyword] = useState(keyword);
const placeholder = activeTab === "providers" ? "搜索供应商名称" : "搜索模型名称或 ID";
const createLabel = activeTab === "providers" ? "新建供应商" : "新建模型";
return (
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
<Tabs activeKey={activeTab} items={TAB_ITEMS} onChange={onTabChange} />
<Space size="small">
<Input.Search
allowClear
enterButton="搜索"
onChange={(event) => setDraftKeyword(event.target.value)}
onClear={() => {
setDraftKeyword("");
onSearchClear();
}}
onSearch={(value) => onSearch(value)}
placeholder={placeholder}
value={draftKeyword}
/>
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
{createLabel}
</Button>
</Space>
</Flex>
);
}

View File

@@ -1,91 +1,120 @@
import type { TableColumnsType } from "antd"; import type { TableColumnsType, TableProps } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table } from "antd"; import { Button, Popconfirm, Space, Table } from "antd";
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import type { Provider, ProviderListResponse } from "../../../../shared/api"; import type { Provider, ProviderListResponse } from "../../../../shared/api";
import { formatDatetime } from "../../../shared/utils/format";
interface ProviderTableProps { interface ProviderTableProps {
data: ProviderListResponse | undefined; data: ProviderListResponse | undefined;
loading: boolean; loading: boolean;
onDelete: (id: string) => Promise<unknown>; onChange: (
pagination: { current?: number; pageSize?: number },
sorter: { columnKey?: string; field?: string | string[]; order?: string },
) => void;
onDelete: (id: string) => Promise<void>;
onEdit: (provider: Provider) => void; onEdit: (provider: Provider) => void;
onPageChange: (page: number, pageSize: number) => void;
page: number; page: number;
pageSize: number; pageSize: number;
sortBy?: string;
sortOrder?: string;
} }
const TYPE_LABELS: Record<Provider["type"], string> = { export function ProviderTable({
anthropic: "Anthropic", data,
openai: "OpenAI", loading,
"openai-compatible": "OpenAI 兼容", onChange,
}; onDelete,
onEdit,
const COLUMNS: TableColumnsType<Provider> = [ page,
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 }, pageSize,
{ sortBy,
dataIndex: "type", sortOrder,
render: (value: Provider["type"]) => TYPE_LABELS[value] ?? value, }: ProviderTableProps) {
title: "类型", const columns = useMemo<TableColumnsType<Provider>>(
width: 140, () => [
}, {
{ dataIndex: "baseUrl", ellipsis: true, title: "Base URL" }, dataIndex: "name",
]; ellipsis: true,
sorter: true,
export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) { sortOrder:
const { message } = AntApp.useApp(); sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
title: "名称",
const handleDelete = useCallback( width: 180,
async (id: string) => { },
try { {
await onDelete(id); dataIndex: "type",
message.success("供应商已删除"); render: (_value: unknown, record: Provider) => {
} catch (err: unknown) { const labels: Record<string, string> = {
message.error((err as Error).message); anthropic: "Anthropic",
} openai: "OpenAI",
}, "openai-compatible": "OpenAI 兼容",
[onDelete, message], };
); return labels[record.type] ?? record.type;
},
const operationColumn = useMemo<TableColumnsType<Provider>[number]>( title: "类型",
() => ({ width: 140,
dataIndex: "op", },
render: (_value: unknown, record: Provider) => ( { dataIndex: "baseUrl", ellipsis: true, title: "Base URL" },
<Space size="small"> {
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link"> align: "center",
dataIndex: "createdAt",
</Button> render: (_value: unknown, record: Provider) => formatDatetime(record.createdAt),
<Popconfirm sorter: true,
description="该供应商下存在模型时无法删除,请先删除或迁移相关模型。" sortOrder:
onConfirm={() => void handleDelete(record.id)} sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
title="确认删除此供应商?" title: "创建时间",
> width: 180,
<Button danger icon={<DeleteOutlined />} size="small" type="link"> },
{
dataIndex: "op",
render: (_value: unknown, record: Provider) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button> </Button>
</Popconfirm> <Popconfirm
</Space> description="删除后关联的模型将无法使用。"
), onConfirm={() => void onDelete(record.id)}
title: "操作", title="确认删除此供应商?"
width: 180, >
}), <Button danger icon={<DeleteOutlined />} size="small" type="link">
[onEdit, handleDelete],
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
},
],
[onEdit, onDelete, sortBy, sortOrder],
); );
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]); const handleTableChange: TableProps<Provider>["onChange"] = (pagination, _filters, sorter) => {
const sortInfo =
sorter && typeof sorter === "object" && "columnKey" in sorter
? (sorter as { columnKey?: string; field?: string | string[]; order?: string })
: {};
onChange({ current: pagination.current, pageSize: pagination.pageSize }, sortInfo);
};
return ( return (
<Table <Table
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
loading={loading} loading={loading}
locale={{ emptyText: "暂无供应商数据" }}
onChange={handleTableChange}
pagination={{ pagination={{
current: page, current: page,
hideOnSinglePage: false, hideOnSinglePage: false,
onChange: onPageChange,
pageSize, pageSize,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total}`,
total: data?.total ?? 0, total: data?.total ?? 0,
}} }}
rowKey="id" rowKey="id"

View File

@@ -1,190 +0,0 @@
import { Space } from "antd";
import { useState } from "react";
import type { Model, Provider, TestModelRequest } from "../../../shared/api";
import {
useCreateModel,
useDeleteModel,
useModelList,
useTestModelConnection,
useUpdateModel,
} from "../../shared/hooks/use-models";
import {
useCreateProvider,
useDeleteProvider,
useProviderList,
useProviderOptions,
useTestProviderConfig,
useUpdateProvider,
} from "../../shared/hooks/use-providers";
import { ModelFormModal } from "./components/ModelFormModal";
import { ModelsToolbar } from "./components/ModelsToolbar";
import { ModelTable } from "./components/ModelTable";
import { ProviderFormModal } from "./components/ProviderFormModal";
import { ProviderTable } from "./components/ProviderTable";
export function ModelsPage() {
const [activeTab, setActiveTab] = useState<string>("models");
const [providerPage, setProviderPage] = useState(1);
const [providerPageSize, setProviderPageSize] = useState(20);
const [providerKeyword, setProviderKeyword] = useState("");
const [providerDialogOpen, setProviderDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<null | Provider>(null);
const [modelPage, setModelPage] = useState(1);
const [modelPageSize, setModelPageSize] = useState(20);
const [modelKeyword, setModelKeyword] = useState("");
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const { data: providerData, isLoading: providerLoading } = useProviderList({
keyword: providerKeyword || undefined,
page: providerPage,
pageSize: providerPageSize,
});
const {
data: providerOptionsData,
error: providerOptionsError,
isError: providerOptionsIsError,
isLoading: providerOptionsLoading,
} = useProviderOptions();
const { data: modelData, isLoading: modelLoading } = useModelList({
keyword: modelKeyword || undefined,
page: modelPage,
pageSize: modelPageSize,
});
const createProviderMutation = useCreateProvider();
const updateProviderMutation = useUpdateProvider();
const deleteProviderMutation = useDeleteProvider();
const testProviderConfigMutation = useTestProviderConfig();
const createModelMutation = useCreateModel();
const updateModelMutation = useUpdateModel();
const deleteModelMutation = useDeleteModel();
const testModelMutation = useTestModelConnection();
const isProviderSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
const isProviderActionPending = deleteProviderMutation.isPending;
const isModelSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
const isModelActionPending = deleteModelMutation.isPending;
const modelProviders = providerOptionsData?.items ?? [];
const currentKeyword = activeTab === "providers" ? providerKeyword : modelKeyword;
const handleSearch =
activeTab === "providers"
? (value: string) => {
setProviderKeyword(value);
setProviderPage(1);
}
: (value: string) => {
setModelKeyword(value);
setModelPage(1);
};
const handleSearchClear =
activeTab === "providers"
? () => {
setProviderKeyword("");
setProviderPage(1);
}
: () => {
setModelKeyword("");
setModelPage(1);
};
const handleOpenCreate =
activeTab === "providers"
? () => {
setEditingProvider(null);
setProviderDialogOpen(true);
}
: () => {
setEditingModel(null);
setModelDialogOpen(true);
};
return (
<Space className="app-page-flex" orientation="vertical" size="large">
<ModelsToolbar
activeTab={activeTab}
key={activeTab}
keyword={currentKeyword}
onSearch={handleSearch}
onSearchClear={handleSearchClear}
onTabChange={(key) => setActiveTab(key)}
openCreateDialog={handleOpenCreate}
/>
{activeTab === "providers" && (
<>
<ProviderTable
data={providerData}
loading={providerLoading || isProviderActionPending}
onDelete={(id) => deleteProviderMutation.mutateAsync(id)}
onEdit={(provider) => {
setEditingProvider(provider);
setProviderDialogOpen(true);
}}
onPageChange={(p, ps) => {
setProviderPage(p);
setProviderPageSize(ps);
}}
page={providerPage}
pageSize={providerPageSize}
/>
<ProviderFormModal
editingProvider={editingProvider}
onCancel={() => setProviderDialogOpen(false)}
onCreate={(data) => createProviderMutation.mutateAsync(data)}
onOpenChange={setProviderDialogOpen}
onTest={(data) => testProviderConfigMutation.mutateAsync(data)}
onUpdate={(args) => updateProviderMutation.mutateAsync(args)}
open={providerDialogOpen}
submitting={isProviderSubmitting}
/>
</>
)}
{activeTab === "models" && (
<>
<ModelTable
data={modelData}
loading={modelLoading || providerOptionsLoading || isModelActionPending}
onDelete={(id) => deleteModelMutation.mutateAsync(id)}
onEdit={(model) => {
setEditingModel(model);
setModelDialogOpen(true);
}}
onPageChange={(p, ps) => {
setModelPage(p);
setModelPageSize(ps);
}}
page={modelPage}
pageSize={modelPageSize}
providers={modelProviders}
/>
<ModelFormModal
editingModel={editingModel}
onCancel={() => setModelDialogOpen(false)}
onCreate={(data) => createModelMutation.mutateAsync(data)}
onOpenChange={setModelDialogOpen}
onUpdate={(args) => updateModelMutation.mutateAsync(args)}
open={modelDialogOpen}
providers={modelProviders}
providersError={providerOptionsIsError ? providerOptionsError : null}
providersLoading={providerOptionsLoading}
submitting={isModelSubmitting}
testModelConnection={(data: TestModelRequest) => testModelMutation.mutateAsync(data)}
/>
</>
)}
</Space>
);
}

View File

@@ -1,184 +1,169 @@
import type { TableColumnsType } from "antd"; import type { TableColumnsType, TableProps } from "antd";
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd"; import { Button, Popconfirm, Space, Table, Tag } from "antd";
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { Project, ProjectListResponse, ProjectStatus } from "../../../../shared/api"; import type { Project, ProjectListResponse } from "../../../../shared/api";
import { formatDatetime } from "../../../shared/utils/format";
interface ProjectTableProps { interface ProjectTableProps {
data: ProjectListResponse | undefined; data: ProjectListResponse | undefined;
loading: boolean; loading: boolean;
onArchive: (id: string) => Promise<unknown>; onArchive: (id: string) => Promise<void>;
onDelete: (id: string) => Promise<unknown>; onChange: (
pagination: { current?: number; pageSize?: number },
sorter: { columnKey?: string; field?: string | string[]; order?: string },
) => void;
onDelete: (id: string) => Promise<void>;
onEdit: (project: Project) => void; onEdit: (project: Project) => void;
onPageChange: (page: number, pageSize: number) => void; onRestore: (id: string) => Promise<void>;
onRestore: (id: string) => Promise<unknown>;
page: number; page: number;
pageSize: number; pageSize: number;
status: ProjectStatus; sortBy?: string;
sortOrder?: string;
} }
const COLUMNS: TableColumnsType<Project> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 140 },
{ dataIndex: "description", ellipsis: true, title: "描述" },
{
align: "center",
dataIndex: "status",
render: (_value, record: Project) => {
if (record.status === "archived") {
return <Tag></Tag>;
}
return <Tag color="blue"></Tag>;
},
title: "状态",
width: 90,
},
{
align: "center",
dataIndex: "createdAt",
render: (_value, record: Project) => formatDatetime(record.createdAt),
title: "创建时间",
width: 180,
},
{
align: "center",
dataIndex: "updatedAt",
render: (_value, record: Project) => formatDatetime(record.updatedAt),
title: "更新时间",
width: 180,
},
];
export function ProjectTable({ export function ProjectTable({
data, data,
loading, loading,
onArchive, onArchive,
onChange,
onDelete, onDelete,
onEdit, onEdit,
onPageChange,
onRestore, onRestore,
page, page,
pageSize, pageSize,
status, sortBy,
sortOrder,
}: ProjectTableProps) { }: ProjectTableProps) {
const { message } = AntApp.useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const handleArchive = useCallback( const columns = useMemo<TableColumnsType<Project>>(
async (id: string) => { () => [
try { {
await onArchive(id); dataIndex: "name",
message.success("项目已归档"); ellipsis: true,
} catch (err: unknown) { sorter: true,
message.error((err as Error).message); sortOrder:
} sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
}, title: "名称",
[onArchive, message], width: 140,
); },
{ dataIndex: "description", ellipsis: true, title: "描述" },
const handleRestore = useCallback( {
async (id: string) => { align: "center",
try { dataIndex: "status",
await onRestore(id); render: (_value: unknown, record: Project) => {
message.success("项目已恢复"); if (record.status === "archived") {
} catch (err: unknown) { return <Tag></Tag>;
message.error((err as Error).message); }
} return <Tag color="blue"></Tag>;
}, },
[onRestore, message], title: "状态",
); width: 90,
},
const handleDelete = useCallback( {
async (id: string) => { align: "center",
try { dataIndex: "createdAt",
await onDelete(id); render: (_value: unknown, record: Project) => formatDatetime(record.createdAt),
message.success("项目已永久删除"); sorter: true,
} catch (err: unknown) { sortOrder:
message.error((err as Error).message); sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
} title: "创建时间",
}, width: 180,
[onDelete, message], },
); {
align: "center",
const operationColumn = useMemo<TableColumnsType<Project>[number]>( dataIndex: "updatedAt",
() => ({ render: (_value: unknown, record: Project) => formatDatetime(record.updatedAt),
dataIndex: "op", sorter: true,
render: (_value, record: Project) => { sortOrder:
if (record.status === "active") { sortBy === "updatedAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
title: "更新时间",
width: 180,
},
{
dataIndex: "op",
render: (_value: unknown, record: Project) => {
if (record.status === "active") {
return (
<Space size="small">
<Button
icon={<LoginOutlined />}
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small"
type="link"
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm
description="归档后项目将变为只读。"
onConfirm={() => void onArchive(record.id)}
title="确认归档此项目?"
>
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link">
</Button>
</Popconfirm>
</Space>
);
}
return ( return (
<Space size="small"> <Space size="small">
<Button <Popconfirm onConfirm={() => void onRestore(record.id)} title="确认恢复此项目?">
icon={<LoginOutlined />} <Button icon={<RedoOutlined />} size="small" type="link">
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small" </Button>
type="link" </Popconfirm>
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm <Popconfirm
description="归档后项目将变为只读。" description="此操作不可恢复。"
onConfirm={() => void handleArchive(record.id)} onConfirm={() => void onDelete(record.id)}
title="确认归档此项目?" title="确认永久删除此项目?"
> >
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link"> <Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
); );
} },
return ( title: "操作",
<Space size="small"> width: 260,
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
<Button icon={<RedoOutlined />} size="small" type="link">
</Button>
</Popconfirm>
<Popconfirm
description="此操作不可恢复。"
onConfirm={() => void handleDelete(record.id)}
title="确认永久删除此项目?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
);
}, },
title: "操作", ],
width: status === "active" ? 260 : 160, [navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder],
}),
[navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
); );
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]); const handleTableChange: TableProps<Project>["onChange"] = (pagination, _filters, sorter) => {
const sortInfo =
sorter && typeof sorter === "object" && "columnKey" in sorter
? (sorter as { columnKey?: string; field?: string | string[]; order?: string })
: {};
onChange({ current: pagination.current, pageSize: pagination.pageSize }, sortInfo);
};
return ( return (
<Table <Table
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
loading={loading} loading={loading}
locale={{ emptyText: "暂无项目数据" }}
onChange={handleTableChange}
pagination={{ pagination={{
current: page, current: page,
hideOnSinglePage: false, hideOnSinglePage: false,
onChange: onPageChange,
pageSize, pageSize,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total}`,
total: data?.total ?? 0, total: data?.total ?? 0,
}} }}
rowKey="id" rowKey="id"
/> />
); );
} }
function formatDatetime(dateStr: string): string {
const d = new Date(dateStr);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

View File

@@ -1,55 +0,0 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Space, Tabs } from "antd";
import { useState } from "react";
import type { ProjectStatus } from "../../../../shared/api";
interface ProjectToolbarProps {
activeTab: ProjectStatus;
keyword: string;
onSearch: (value: string) => void;
onSearchClear: () => void;
onTabChange: (key: string) => void;
openCreateDialog: () => void;
}
const STATUS_TAB_ITEMS = [
{ key: "active", label: "进行中" },
{ key: "archived", label: "已归档" },
];
export function ProjectToolbar({
activeTab,
keyword,
onSearch,
onSearchClear,
onTabChange,
openCreateDialog,
}: ProjectToolbarProps) {
const [draftKeyword, setDraftKeyword] = useState(keyword);
return (
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
<Space size="small">
<Input.Search
allowClear
enterButton="搜索"
onChange={(event) => setDraftKeyword(event.target.value)}
onClear={() => {
setDraftKeyword("");
onSearchClear();
}}
onSearch={(value) => onSearch(value)}
placeholder="搜索名称或描述"
value={draftKeyword}
/>
{activeTab === "active" && (
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
</Button>
)}
</Space>
</Flex>
);
}

View File

@@ -1,8 +1,10 @@
import { Space } from "antd"; import { PlusOutlined } from "@ant-design/icons";
import { useState } from "react"; import { Button, Space } from "antd";
import { useCallback, useMemo, useState } from "react";
import type { Project, ProjectStatus } from "../../../shared/api"; import type { Project, ProjectStatus } from "../../../shared/api";
import { FilterToolbar } from "../../shared/components/FilterToolbar";
import { import {
useArchiveProject, useArchiveProject,
useCreateProject, useCreateProject,
@@ -11,20 +13,40 @@ import {
useRestoreProject, useRestoreProject,
useUpdateProject, useUpdateProject,
} from "../../shared/hooks/use-projects"; } from "../../shared/hooks/use-projects";
import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
import { ProjectFormModal } from "./components/ProjectFormModal"; import { ProjectFormModal } from "./components/ProjectFormModal";
import { ProjectTable } from "./components/ProjectTable"; import { ProjectTable } from "./components/ProjectTable";
import { ProjectToolbar } from "./components/ProjectToolbar";
const STATUS_OPTIONS = [
{ label: "进行中", value: "active" },
{ label: "已归档", value: "archived" },
];
export function ProjectsPage() { export function ProjectsPage() {
const [tabValue, setTabValue] = useState<ProjectStatus>("active"); const { confirmAction } = useConfirmAction();
const [page, setPage] = useState(1); const { params, resetAll, setParams } = usePageSearchParams({
const [pageSize, setPageSize] = useState(20); defaults: { page: "1", pageSize: "20" },
const [keyword, setKeyword] = useState(""); });
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState<null | Project>(null); const [editingProject, setEditingProject] = useState<null | Project>(null);
const { data, isLoading } = useProjectList({ keyword: keyword || undefined, page, pageSize, status: tabValue }); const apiPage = Number(params["page"]) || 1;
const apiPageSize = Number(params["pageSize"]) || 20;
const keyword = params["keyword"] ?? "";
const statusFilter = params["status"] as ProjectStatus | undefined;
const sortBy = params["sortBy"];
const sortOrder = params["sortOrder"];
const { data, isFetching } = useProjectList({
keyword: keyword || undefined,
page: apiPage,
pageSize: apiPageSize,
sortBy: sortBy ?? undefined,
sortOrder: sortOrder,
status: statusFilter,
});
const createMutation = useCreateProject(); const createMutation = useCreateProject();
const updateMutation = useUpdateProject(); const updateMutation = useUpdateProject();
const archiveMutation = useArchiveProject(); const archiveMutation = useArchiveProject();
@@ -32,54 +54,111 @@ export function ProjectsPage() {
const deleteMutation = useDeleteProject(); const deleteMutation = useDeleteProject();
const isSubmitting = createMutation.isPending || updateMutation.isPending; const isSubmitting = createMutation.isPending || updateMutation.isPending;
const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending;
const handleSearch = useCallback(
(value: string) => {
setParams({ keyword: value || undefined, page: "1" });
},
[setParams],
);
const handleReset = useCallback(() => {
resetAll();
}, [resetAll]);
const handleTableChange = useCallback(
(
pagination: { current?: number; pageSize?: number },
sorter: { columnKey?: string; field?: string | string[]; order?: string },
) => {
const patch: Record<string, string | undefined> = {
page: String(pagination.current ?? apiPage),
pageSize: String(pagination.pageSize ?? apiPageSize),
};
const sortField = sorter.columnKey! ?? (typeof sorter.field === "string" ? sorter.field : undefined);
if (sorter.order && sortField) {
patch["sortBy"] = sortField;
patch["sortOrder"] = sorter.order === "ascend" ? "asc" : "desc";
} else {
patch["sortBy"] = undefined;
patch["sortOrder"] = undefined;
}
setParams(patch);
},
[setParams, apiPage, apiPageSize],
);
const filters = useMemo(
() => [
{
key: "status",
label: "状态",
onChange: (value: string | undefined) => {
setParams({ page: "1", status: value });
},
options: STATUS_OPTIONS,
placeholder: "状态",
value: statusFilter,
},
],
[setParams, statusFilter],
);
const handleArchive = useCallback(
(id: string) => confirmAction(() => archiveMutation.mutateAsync(id), "项目已归档"),
[confirmAction, archiveMutation],
);
const handleRestore = useCallback(
(id: string) => confirmAction(() => restoreMutation.mutateAsync(id), "项目已恢复"),
[confirmAction, restoreMutation],
);
const handleDelete = useCallback(
(id: string) => confirmAction(() => deleteMutation.mutateAsync(id), "项目已永久删除"),
[confirmAction, deleteMutation],
);
return ( return (
<Space className="app-page-flex" orientation="vertical" size="large"> <Space className="app-page-flex" orientation="vertical" size="large">
<ProjectToolbar <FilterToolbar
activeTab={tabValue} actions={
keyword={keyword} <Button
onSearch={(value) => { icon={<PlusOutlined />}
setKeyword(value); onClick={() => {
setPage(1); setEditingProject(null);
}} setDialogOpen(true);
onSearchClear={() => { }}
setKeyword(""); type="primary"
setPage(1); >
}}
onTabChange={(key) => { </Button>
setTabValue(key as ProjectStatus); }
setPage(1); filters={filters}
}} search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索名称或描述" }}
openCreateDialog={() => {
setEditingProject(null);
setDialogOpen(true);
}}
/> />
<ProjectTable <ProjectTable
data={data} data={data}
loading={isLoading || isRowActionPending} loading={isFetching}
onArchive={(id) => archiveMutation.mutateAsync(id)} onArchive={handleArchive}
onDelete={(id) => deleteMutation.mutateAsync(id)} onChange={handleTableChange}
onDelete={handleDelete}
onEdit={(project) => { onEdit={(project) => {
setEditingProject(project); setEditingProject(project);
setDialogOpen(true); setDialogOpen(true);
}} }}
onPageChange={(p, ps) => { onRestore={handleRestore}
setPage(p); page={apiPage}
setPageSize(ps); pageSize={apiPageSize}
}} sortBy={sortBy}
onRestore={(id) => restoreMutation.mutateAsync(id)} sortOrder={sortOrder}
page={page}
pageSize={pageSize}
status={tabValue}
/> />
<ProjectFormModal <ProjectFormModal
editingProject={editingProject} editingProject={editingProject}
onCancel={() => setDialogOpen(false)} onCancel={() => setDialogOpen(false)}
onCreate={(data) => createMutation.mutateAsync(data)} onCreate={(formData) => createMutation.mutateAsync(formData)}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
onUpdate={(args) => updateMutation.mutateAsync(args)} onUpdate={(args) => updateMutation.mutateAsync(args)}
open={dialogOpen} open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons"; import { ApiOutlined, CloudServerOutlined, DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons";
import { createElement } from "react"; import { createElement } from "react";
import type { MenuItemConfig } from "../../menu"; import type { MenuItemConfig } from "../../menu";
@@ -6,5 +6,14 @@ import type { MenuItemConfig } from "../../menu";
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [ export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" }, { icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" }, { icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
{ icon: createElement(RobotOutlined), label: "模型管理", path: "/models", value: "models" }, {
children: [
{ icon: createElement(RobotOutlined), label: "模型", path: "/models", value: "models" },
{ icon: createElement(CloudServerOutlined), label: "供应商", path: "/models/providers", value: "providers" },
],
icon: createElement(ApiOutlined),
label: "模型管理",
path: "",
value: "model-management",
},
] as const; ] as const;

View File

@@ -1,8 +1,9 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
export interface MenuItemConfig { export interface MenuItemConfig {
icon: ReactElement; readonly children?: readonly MenuItemConfig[];
label: string; readonly icon: ReactElement;
path: string; readonly label: string;
value: string; readonly path: string;
readonly value: string;
} }

View File

@@ -3,7 +3,8 @@ import { Route, Routes } from "react-router";
import { ChatPage } from "./features/chat/ChatPage"; import { ChatPage } from "./features/chat/ChatPage";
import { DashboardPage } from "./features/dashboard"; import { DashboardPage } from "./features/dashboard";
import { InboxPage } from "./features/inbox"; import { InboxPage } from "./features/inbox";
import { ModelsPage } from "./features/models"; import { ModelListPage } from "./features/models/ModelListPage";
import { ProviderListPage } from "./features/models/ProviderListPage";
import { NotFoundPage } from "./features/not-found"; import { NotFoundPage } from "./features/not-found";
import { ProjectsPage } from "./features/projects"; import { ProjectsPage } from "./features/projects";
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout"; import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
@@ -16,7 +17,8 @@ export function AppRoutes() {
<Route element={<AdminConsoleLayout />} errorElement={<RouteError />}> <Route element={<AdminConsoleLayout />} errorElement={<RouteError />}>
<Route element={<DashboardPage />} path="/" /> <Route element={<DashboardPage />} path="/" />
<Route element={<ProjectsPage />} path="/projects" /> <Route element={<ProjectsPage />} path="/projects" />
<Route element={<ModelsPage />} path="/models" /> <Route element={<ModelListPage />} path="/models" />
<Route element={<ProviderListPage />} path="/models/providers" />
</Route> </Route>
<Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId"> <Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId">
<Route element={<ChatPage />} path="" /> <Route element={<ChatPage />} path="" />

View File

@@ -0,0 +1,74 @@
import type { ReactNode } from "react";
import { SearchOutlined, UndoOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Select, Space } from "antd";
import { useEffect, useRef, useState } from "react";
export interface FilterConfig {
key: string;
label: string;
onChange: (value: string | undefined) => void;
options: Array<{ label: string; value: string }>;
placeholder: string;
value?: string;
}
export interface FilterToolbarProps {
actions?: ReactNode;
filters?: FilterConfig[];
search?: SearchConfig;
}
export interface SearchConfig {
keyword?: string;
onReset: () => void;
onSearch: (value: string) => void;
placeholder: string;
}
export function FilterToolbar({ actions, filters, search }: FilterToolbarProps) {
const [draftKeyword, setDraftKeyword] = useState(search?.keyword ?? "");
const prevKeywordRef = useRef(search?.keyword);
useEffect(() => {
if (search?.keyword !== prevKeywordRef.current) {
prevKeywordRef.current = search?.keyword;
setDraftKeyword(search?.keyword ?? "");
}
}, [search?.keyword]);
return (
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
<Space size="small" wrap>
{filters?.map((filter) => (
<Select
allowClear
key={filter.key}
onChange={filter.onChange}
options={filter.options}
placeholder={filter.placeholder}
style={{ minWidth: 120 }}
value={filter.value}
/>
))}
{search && (
<Input.Search
allowClear
enterButton={<Button icon={<SearchOutlined />} type="primary" />}
onChange={(e) => setDraftKeyword(e.target.value)}
onClear={() => {
setDraftKeyword("");
search.onSearch("");
}}
onSearch={(value) => search.onSearch(value)}
placeholder={search.placeholder}
style={{ width: 220 }}
value={draftKeyword}
/>
)}
<Button icon={<UndoOutlined />} onClick={search?.onReset} title="重置" />
</Space>
{actions && <Space size="small">{actions}</Space>}
</Flex>
);
}

View File

@@ -1,6 +1,7 @@
import type { MenuProps } from "antd"; import type { MenuProps } from "antd";
import { Menu } from "antd"; import { Menu } from "antd";
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import type { MenuItemConfig } from "../../../menu"; import type { MenuItemConfig } from "../../../menu";
@@ -14,23 +15,101 @@ interface SidebarProps {
export function Sidebar({ menuItems }: SidebarProps) { export function Sidebar({ menuItems }: SidebarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const currentPath = location.pathname; const currentPath = location.pathname;
const currentItem = menuItems.find((item) => item.path === currentPath);
const selectedKeys = currentItem ? [currentItem.value] : [];
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({ const rootSubmenuKeys = useMemo(() => getRootSubmenuKeys(menuItems), [menuItems]);
icon: item.icon, const antdMenuItems = useMemo(() => toAntdMenuItems(menuItems), [menuItems]);
key: item.value,
label: item.label, const [openKeys, setOpenKeys] = useState<string[]>(() => {
})); return findAncestorKeys(menuItems, currentPath) ?? [];
});
const currentItem = findMenuItem(menuItems, currentPath);
const selectedKeys: string[] = currentItem ? [currentItem.value] : [];
const handleOpenChange: MenuProps["onOpenChange"] = (keys) => {
const latestOpenKey = keys.find((key) => !openKeys.includes(key));
if (latestOpenKey && rootSubmenuKeys.includes(latestOpenKey)) {
setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
} else {
setOpenKeys(keys);
}
};
const handleMenuClick: MenuProps["onClick"] = ({ key }) => { const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
const item = menuItems.find((i) => i.value === key); const item = findByValue(menuItems, key);
if (item) { if (item) {
void navigate(item.path); void navigate(item.path);
} }
}; };
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />; return (
<Menu
items={antdMenuItems}
mode="inline"
onClick={handleMenuClick}
onOpenChange={handleOpenChange}
openKeys={openKeys}
selectedKeys={selectedKeys}
/>
);
}
function findAncestorKeys(
items: readonly MenuItemConfig[],
path: string,
ancestors: string[] = [],
): string[] | undefined {
for (const item of items) {
if (item.path === path) return ancestors;
if (item.children) {
const result = findAncestorKeys(item.children, path, [...ancestors, item.value]);
if (result !== undefined) return result;
}
}
return undefined;
}
function findByValue(items: readonly MenuItemConfig[], value: string): MenuItemConfig | undefined {
for (const item of items) {
if (item.value === value) return item;
if (item.children) {
const found = findByValue(item.children, value);
if (found) return found;
}
}
return undefined;
}
function findMenuItem(items: readonly MenuItemConfig[], path: string): MenuItemConfig | undefined {
for (const item of items) {
if (item.path === path) return item;
if (item.children) {
const found = findMenuItem(item.children, path);
if (found) return found;
}
}
return undefined;
}
function getRootSubmenuKeys(items: readonly MenuItemConfig[]): string[] {
return items.filter((item) => item.children).map((item) => item.value);
}
function toAntdMenuItems(items: readonly MenuItemConfig[]): MenuItem[] {
return items.map((item): MenuItem => {
if (item.children && item.children.length > 0) {
return {
children: toAntdMenuItems(item.children),
icon: item.icon,
key: item.value,
label: item.label,
};
}
return {
icon: item.icon,
key: item.value,
label: item.label,
};
});
} }

View File

@@ -0,0 +1,28 @@
import { CaretDownOutlined, CaretRightOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import { type ReactNode, useState } from "react";
interface SidebarGroupProps {
children: ReactNode;
count: number;
label: string;
}
export function SidebarGroup({ children, count, label }: SidebarGroupProps) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="app-sidebar-group">
<div className="app-sidebar-group-header" onClick={() => setCollapsed(!collapsed)}>
<span className="app-sidebar-group-arrow">{collapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}</span>
<Typography.Text className="app-sidebar-group-label" type="secondary">
{label}
</Typography.Text>
<Typography.Text className="app-sidebar-group-count" type="secondary">
({count})
</Typography.Text>
</div>
{!collapsed && <div className="app-sidebar-group-content">{children}</div>}
</div>
);
}

View File

@@ -40,7 +40,7 @@ export async function fetchConversation(projectId: string, conversationId: strin
} }
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> { export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`); const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=200`);
return handleResponse(response, (data) => data as ConversationListResponse); return handleResponse(response, (data) => data as ConversationListResponse);
} }

View File

@@ -37,16 +37,22 @@ export async function fetchModel(id: string): Promise<Model> {
} }
export async function fetchModelList(params: { export async function fetchModelList(params: {
capabilities?: string;
keyword?: string; keyword?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
providerId?: string; providerId?: string;
sortBy?: string;
sortOrder?: string;
}): Promise<ModelListResponse> { }): Promise<ModelListResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page)); if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword); if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.providerId) searchParams.set("providerId", params.providerId); if (params.providerId) searchParams.set("providerId", params.providerId);
if (params.sortBy) searchParams.set("sortBy", params.sortBy);
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
if (params.capabilities) searchParams.set("capabilities", params.capabilities);
const qs = searchParams.toString(); const qs = searchParams.toString();
const url = `/api/models${qs ? `?${qs}` : ""}`; const url = `/api/models${qs ? `?${qs}` : ""}`;
const response = await fetch(url); const response = await fetch(url);
@@ -85,7 +91,7 @@ export function useCreateModel() {
return useMutation({ return useMutation({
mutationFn: createModel, mutationFn: createModel,
onSuccess: (data) => { onSuccess: (data) => {
logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId }); logger.info("模型创建成功", { externalId: data.externalId, providerId: data.providerId });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
}, },
}); });
@@ -110,7 +116,15 @@ export function useModel(id: string) {
}); });
} }
export function useModelList(params: { keyword?: string; page?: number; pageSize?: number; providerId?: string }) { export function useModelList(params: {
capabilities?: string;
keyword?: string;
page?: number;
pageSize?: number;
providerId?: string;
sortBy?: string;
sortOrder?: string;
}) {
return useQuery({ return useQuery({
queryFn: () => fetchModelList(params), queryFn: () => fetchModelList(params),
queryKey: [...MODELS_KEY, "list", params], queryKey: [...MODELS_KEY, "list", params],
@@ -128,7 +142,7 @@ export function useUpdateModel() {
return useMutation({ return useMutation({
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data), mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data),
onSuccess: (data) => { onSuccess: (data) => {
logger.info("模型更新成功", { modelId: data.modelId, providerId: data.providerId }); logger.info("模型更新成功", { externalId: data.externalId, providerId: data.providerId });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
}, },
}); });

View File

@@ -43,12 +43,16 @@ export async function fetchProjectList(params: {
keyword?: string; keyword?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string;
sortOrder?: string;
status?: ProjectStatus; status?: ProjectStatus;
}): Promise<ProjectListResponse> { }): Promise<ProjectListResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page)); if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword); if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.sortBy) searchParams.set("sortBy", params.sortBy);
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
if (params.status) searchParams.set("status", params.status); if (params.status) searchParams.set("status", params.status);
const qs = searchParams.toString(); const qs = searchParams.toString();
const url = `/api/projects${qs ? `?${qs}` : ""}`; const url = `/api/projects${qs ? `?${qs}` : ""}`;
@@ -115,7 +119,14 @@ export function useProject(id: string) {
}); });
} }
export function useProjectList(params: { keyword?: string; page?: number; pageSize?: number; status?: ProjectStatus }) { export function useProjectList(params: {
keyword?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
status?: ProjectStatus;
}) {
return useQuery({ return useQuery({
queryFn: () => fetchProjectList(params), queryFn: () => fetchProjectList(params),
queryKey: [...PROJECTS_KEY, "list", params], queryKey: [...PROJECTS_KEY, "list", params],

View File

@@ -41,11 +41,17 @@ export async function fetchProviderList(params: {
keyword?: string; keyword?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string;
sortOrder?: string;
type?: string;
}): Promise<ProviderListResponse> { }): Promise<ProviderListResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page)); if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword); if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.sortBy) searchParams.set("sortBy", params.sortBy);
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
if (params.type) searchParams.set("type", params.type);
const qs = searchParams.toString(); const qs = searchParams.toString();
const url = `/api/providers${qs ? `?${qs}` : ""}`; const url = `/api/providers${qs ? `?${qs}` : ""}`;
const response = await fetch(url); const response = await fetch(url);
@@ -119,7 +125,14 @@ export function useProvider(id: string) {
}); });
} }
export function useProviderList(params: { keyword?: string; page?: number; pageSize?: number }) { export function useProviderList(params: {
keyword?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
type?: string;
}) {
return useQuery({ return useQuery({
queryFn: () => fetchProviderList(params), queryFn: () => fetchProviderList(params),
queryKey: [...PROVIDERS_KEY, "list", params], queryKey: [...PROVIDERS_KEY, "list", params],

View File

@@ -0,0 +1,24 @@
import { App } from "antd";
import { useCallback } from "react";
export interface UseConfirmActionResult {
confirmAction: (action: () => Promise<unknown>, successMessage: string) => Promise<void>;
}
export function useConfirmAction(): UseConfirmActionResult {
const { message } = App.useApp();
const confirmAction = useCallback(
async (action: () => Promise<unknown>, successMessage: string) => {
try {
await action();
message.success(successMessage);
} catch (err: unknown) {
message.error((err as Error).message);
}
},
[message],
);
return { confirmAction };
}

View File

@@ -0,0 +1,74 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router";
export interface UsePageSearchParamsOptions {
defaults?: Record<string, string>;
}
export interface UsePageSearchParamsResult {
params: Record<string, string>;
resetAll: () => void;
setParam: (key: string, value: string | undefined) => void;
setParams: (patch: Record<string, string | undefined>) => void;
}
export function usePageSearchParams(options?: UsePageSearchParamsOptions): UsePageSearchParamsResult {
const [searchParams, setSearchParams] = useSearchParams();
const defaults = useMemo(() => options?.defaults ?? {}, [options?.defaults]);
const params = useMemo(() => {
const result: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
result[key] = value;
}
for (const [key, value] of Object.entries(defaults)) {
if (!(key in result)) result[key] = value;
}
return result;
}, [searchParams, defaults]);
const setParam = useCallback(
(key: string, value: string | undefined) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === undefined || value === "") {
next.delete(key);
} else {
next.set(key, value);
}
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const setParams = useCallback(
(patch: Record<string, string | undefined>) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [key, value] of Object.entries(patch)) {
if (value === undefined || value === "") {
next.delete(key);
} else {
next.set(key, value);
}
}
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const resetAll = useCallback(() => {
setSearchParams(new URLSearchParams(), { replace: true });
}, [setSearchParams]);
return { params, resetAll, setParam, setParams };
}

View File

@@ -0,0 +1,52 @@
export type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
export const GROUP_LABELS: Record<DateGroup, string> = {
earlier: "更早",
thisMonth: "本月",
thisWeek: "本周",
today: "今天",
yesterday: "昨天",
};
export const GROUP_ORDER: readonly DateGroup[] = ["today", "yesterday", "thisWeek", "thisMonth", "earlier"];
export interface DateGroupData<T> {
items: T[];
key: DateGroup;
}
export function getDateGroup(dateStr: string, now: Date): DateGroup {
const date = new Date(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86_400_000);
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateDay.getTime() >= today.getTime()) return "today";
if (dateDay.getTime() >= yesterday.getTime()) return "yesterday";
const dayOfWeek = today.getDay();
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today.getTime() - mondayOffset * 86_400_000);
if (dateDay.getTime() >= monday.getTime()) return "thisWeek";
if (dateDay.getFullYear() === today.getFullYear() && dateDay.getMonth() === today.getMonth()) {
return "thisMonth";
}
return "earlier";
}
export function groupByDate<T>(items: readonly T[], dateField: keyof T & string): Array<DateGroupData<T>> {
const now = new Date();
const groups = new Map<DateGroup, T[]>();
for (const item of items) {
const dateValue = item[dateField];
if (typeof dateValue !== "string") continue;
const group = getDateGroup(dateValue, now);
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(item);
}
return GROUP_ORDER.map((key) => ({ items: groups.get(key) ?? [], key }));
}

View File

@@ -0,0 +1,5 @@
export function formatDatetime(dateStr: string): string {
const d = new Date(dateStr);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}

View File

@@ -3,6 +3,23 @@ export function formatCountdown(seconds: number): string {
return `${Math.floor(seconds / 60)}${seconds % 60}`; return `${Math.floor(seconds / 60)}${seconds % 60}`;
} }
export function formatDateLabel(dateStr: string, now: Date = new Date()): string {
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return "—";
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86_400_000);
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateDay.getTime() >= today.getTime()) return "今天";
if (dateDay.getTime() >= yesterday.getTime()) return "昨天";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } { export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
if (ms === null) return { suffix: "", value: 0 }; if (ms === null) return { suffix: "", value: 0 };
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) }; if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };

View File

@@ -52,7 +52,7 @@ body {
.app-content { .app-content {
overflow: auto; overflow: auto;
padding: var(--ant-padding-xl) var(--ant-padding-xl); padding: var(--ant-padding) var(--ant-padding-lg);
} }
.app-chat-page { .app-chat-page {
@@ -81,25 +81,95 @@ body {
min-height: 60vh; min-height: 60vh;
} }
.app-chat-conversations { .app-sidebar-list {
display: flex; display: flex;
width: 260px;
flex-direction: column; flex-direction: column;
border-right: 1px solid var(--ant-color-border-secondary); border-right: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg); border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container); background: var(--ant-color-bg-container);
} }
.app-chat-conversations-header { .app-sidebar-list-header {
display: flex;
flex-direction: column;
gap: var(--ant-margin-sm);
padding: var(--ant-padding-sm); padding: var(--ant-padding-sm);
border-bottom: 1px solid var(--ant-color-border-secondary); border-bottom: 1px solid var(--ant-color-border-secondary);
} }
.app-chat-conversations-list { .app-sidebar-list-body {
display: flex;
flex: 1; flex: 1;
flex-direction: column;
min-height: 0; min-height: 0;
} }
.app-sidebar-list-item {
border: none;
margin: var(--ant-margin-xxs) var(--ant-margin-xxs);
padding: var(--ant-padding-xs) var(--ant-padding-sm);
border-radius: var(--ant-border-radius-lg);
cursor: pointer;
transition: background 0.15s ease;
}
.app-sidebar-list-item:hover {
background: var(--ant-color-bg-text-hover);
}
.app-sidebar-list-item--selected {
background: var(--ant-color-primary-bg);
}
.app-sidebar-list-item--selected:hover {
background: var(--ant-color-primary-bg);
}
.app-sidebar-item-actions {
opacity: 0;
transition: opacity 0.15s ease;
}
.app-sidebar-list-item:hover .app-sidebar-item-actions {
opacity: 1;
}
.app-sidebar-group {
margin-top: var(--ant-margin-xs);
}
.app-sidebar-group-header {
display: flex;
align-items: center;
gap: var(--ant-margin-xxs);
padding: var(--ant-padding-xs) var(--ant-padding-xs);
cursor: pointer;
user-select: none;
border-radius: var(--ant-border-radius-sm);
transition: background 0.15s ease;
}
.app-sidebar-group-header:hover {
background: var(--ant-color-fill-tertiary);
}
.app-sidebar-group-arrow {
display: inline-flex;
align-items: center;
font-size: var(--ant-font-size-sm);
color: var(--ant-color-text-quaternary);
width: 14px;
}
.app-sidebar-group-label {
font-size: var(--ant-font-size-sm);
font-weight: 500;
}
.app-sidebar-group-count {
font-size: var(--ant-font-size-sm);
}
.app-chat-panel { .app-chat-panel {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -255,25 +325,6 @@ body {
overflow: hidden; overflow: hidden;
} }
.app-inbox-sidebar {
display: flex;
width: 280px;
flex-direction: column;
gap: var(--ant-margin-sm);
padding: var(--ant-padding-sm);
border-right: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
}
.app-inbox-list {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.app-inbox-content { .app-inbox-content {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -290,28 +341,24 @@ body {
/* Inbox material list items */ /* Inbox material list items */
.material-list-item { .material-list-item {
border-left: 3px solid transparent; border: none;
border-bottom: 1px solid var(--ant-color-border-secondary); margin: var(--ant-margin-xxs) var(--ant-margin-xxs);
padding: var(--ant-padding-xs) var(--ant-padding-sm); padding: var(--ant-padding-xs) var(--ant-padding-sm);
padding-left: var(--ant-padding-sm); border-radius: var(--ant-border-radius-lg);
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease; transition: background 0.15s ease;
}
.material-list-item:last-child {
border-bottom: none;
} }
.material-list-item:hover { .material-list-item:hover {
background: var(--ant-color-fill-tertiary); background: var(--ant-color-bg-text-hover);
} }
.material-list-item--selected { .material-list-item--selected {
border-left-color: var(--ant-color-primary); background: var(--ant-color-primary-bg);
} }
.material-list-item--selected:hover { .material-list-item--selected:hover {
background: var(--ant-color-fill-tertiary); background: var(--ant-color-primary-bg);
} }
.material-item-right { .material-item-right {
@@ -346,56 +393,6 @@ body {
opacity: 1; opacity: 1;
} }
.material-item-time {
font-size: var(--ant-font-size-sm);
}
.app-inbox-group {
margin-top: var(--ant-margin-xs);
}
.app-inbox-group-header {
display: flex;
align-items: center;
gap: var(--ant-margin-xxs);
padding: var(--ant-padding-xs) var(--ant-padding-xs);
cursor: pointer;
user-select: none;
border-radius: var(--ant-border-radius-sm);
transition: background 0.15s ease;
}
.app-inbox-group-header:hover {
background: var(--ant-color-fill-tertiary);
}
.app-inbox-group-arrow {
display: inline-flex;
align-items: center;
font-size: var(--ant-font-size-sm);
color: var(--ant-color-text-quaternary);
width: 14px;
}
.app-inbox-group-label {
font-size: var(--ant-font-size-sm);
font-weight: 500;
}
.app-inbox-group-count {
font-size: var(--ant-font-size-sm);
}
.app-inbox-group-content {
padding-bottom: var(--ant-padding-xs);
}
.app-inbox-group-empty {
display: block;
padding: var(--ant-padding-xs) var(--ant-padding);
font-size: var(--ant-font-size-sm);
}
.app-inbox-filter-count { .app-inbox-filter-count {
margin-left: 4px; margin-left: 4px;
font-size: var(--ant-font-size-sm); font-size: var(--ant-font-size-sm);

View File

@@ -1,4 +1,5 @@
import Database from "bun:sqlite"; import Database from "bun:sqlite";
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs"; import { mkdirSync, rmSync } from "node:fs";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -105,7 +106,7 @@ export function createTestDatabase(prefix: string, migrations: MigrationRecord[]
} }
export function makeTempDir(prefix: string): string { export function makeTempDir(prefix: string): string {
const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); const dir = join(tmpdir(), `${prefix}-${randomUUID()}`);
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
return dir; return dir;
} }

View File

@@ -1,19 +1,8 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createNoopLogger } from "../../../src/server/logger"; import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers"; import { createMigratedTestDatabase } from "../../helpers";
import "../mocks/ai";
void mock.module("ai", () => ({
createProviderRegistry: (providers: Record<string, { languageModel: (modelId: string) => unknown }>) => ({
languageModel: (id: string) => {
const [providerId, modelId] = id.split(":");
const provider = providers[providerId ?? ""];
if (!provider || !modelId) throw new Error(`No such provider: ${id}`);
return provider.languageModel(modelId);
},
}),
generateText: () => Promise.resolve({ text: "Hi" }),
}));
async function withProviderServer( async function withProviderServer(
modelsResponse: Response, modelsResponse: Response,

View File

@@ -1,60 +0,0 @@
/* eslint-disable @typescript-eslint/require-await */
import { describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ResolvedConfig } from "../../src/server/config/types";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
import { createMemoryLogger } from "../../src/server/logger";
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
const base = join(tmpdir(), `bootstrap-db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(base, { recursive: true });
return {
configDir: base,
dataDir: join(base, "data"),
host: "127.0.0.1",
logging: {
consoleLevel: "info",
fileLevel: "info",
filePath: join(base, "data", "logs", "test.log"),
rotationFrequency: "daily",
rotationMaxFiles: 14,
rotationSizeBytes: 52428800,
rotationSizeRaw: "50MB",
},
port: 0,
...overrides,
};
}
describe("bootstrap 数据库集成", () => {
test("启动时将数据库传递给 startServer", async () => {
let started = false;
let receivedDb: unknown = undefined;
const cfg = makeTempConfig();
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
const mockOnSignal = (_signal: string, _handler: () => void) => {};
const mockStartServer = (options: { db: unknown }) => {
receivedDb = options.db;
started = true;
return { close: () => {} };
};
const deps: BootstrapDependencies = {
createLogger: async () => createMemoryLogger(),
loadConfig: mockLoadConfig,
onSignal: mockOnSignal,
startServer: mockStartServer,
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
expect(started).toBe(true);
expect(receivedDb).not.toBeUndefined();
expect(typeof (receivedDb as { close?: unknown }).close).toBe("function");
});
});

View File

@@ -265,4 +265,27 @@ describe("bootstrap", () => {
expect(flushed).toBe(true); expect(flushed).toBe(true);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
}); });
test("启动时将数据库传递给 startServer", async () => {
let started = false;
let receivedDb: unknown = undefined;
const cfg = makeTempConfig();
const deps: BootstrapDependencies = {
createLogger: async () => createMemoryLogger(),
loadConfig: async () => cfg,
onSignal: (_signal, _handler) => {},
startServer: (options: { db: unknown }) => {
receivedDb = options.db;
started = true;
return {};
},
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
expect(started).toBe(true);
expect(receivedDb).not.toBeUndefined();
expect(typeof (receivedDb as { close?: unknown }).close).toBe("function");
});
}); });

View File

@@ -41,17 +41,18 @@ describe("模型数据访问层", () => {
db, db,
{ {
capabilities: ["text", "reasoning"], capabilities: ["text", "reasoning"],
modelId: "gpt-4o", externalId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId, providerId,
}, },
createNoopLogger(), createNoopLogger(),
); );
expect("error" in result).toBe(false); expect("error" in result).toBe(false);
const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } }) const model = (
.model; result as { model: { capabilities: string[]; externalId: string; name: string; providerId: string } }
).model;
expect(model.name).toBe("GPT-4o"); expect(model.name).toBe("GPT-4o");
expect(model.modelId).toBe("gpt-4o"); expect(model.externalId).toBe("gpt-4o");
expect(model.providerId).toBe(providerId); expect(model.providerId).toBe(providerId);
expect(model.capabilities).toEqual(["text", "reasoning"]); expect(model.capabilities).toEqual(["text", "reasoning"]);
}); });
@@ -63,7 +64,7 @@ describe("模型数据访问层", () => {
db, db,
{ {
capabilities: ["text"], capabilities: ["text"],
modelId: "test", externalId: "test",
name: "Test", name: "Test",
providerId: "nonexistent", providerId: "nonexistent",
}, },
@@ -77,10 +78,10 @@ describe("模型数据访问层", () => {
test("同一供应商下模型 ID 唯一", () => { test("同一供应商下模型 ID 唯一", () => {
withDb((db) => { withDb((db) => {
const providerId = seedProvider(db); const providerId = seedProvider(db);
createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "gpt-4o", name: "Model1", providerId }, createNoopLogger());
const result = createModel( const result = createModel(
db, db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId }, { capabilities: ["text"], externalId: "gpt-4o", name: "Model2", providerId },
createNoopLogger(), createNoopLogger(),
); );
expect("error" in result).toBe(true); expect("error" in result).toBe(true);
@@ -94,12 +95,12 @@ describe("模型数据访问层", () => {
const p2 = seedProvider(db, "P2"); const p2 = seedProvider(db, "P2");
const r1 = createModel( const r1 = createModel(
db, db,
{ capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 }, { capabilities: ["text"], externalId: "same-id", name: "M1", providerId: p1 },
createNoopLogger(), createNoopLogger(),
); );
const r2 = createModel( const r2 = createModel(
db, db,
{ capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 }, { capabilities: ["text"], externalId: "same-id", name: "M2", providerId: p2 },
createNoopLogger(), createNoopLogger(),
); );
expect("error" in r1).toBe(false); expect("error" in r1).toBe(false);
@@ -112,7 +113,7 @@ describe("模型数据访问层", () => {
const providerId = seedProvider(db); const providerId = seedProvider(db);
const result = createModel( const result = createModel(
db, db,
{ capabilities: [], modelId: "test", name: "Test", providerId }, { capabilities: [], externalId: "test", name: "Test", providerId },
createNoopLogger(), createNoopLogger(),
); );
expect("error" in result).toBe(true); expect("error" in result).toBe(true);
@@ -124,9 +125,9 @@ describe("模型数据访问层", () => {
withDb((db) => { withDb((db) => {
const p1 = seedProvider(db, "P1"); const p1 = seedProvider(db, "P1");
const p2 = seedProvider(db, "P2"); const p2 = seedProvider(db, "P2");
createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
const all = listModels(db, { page: 1, pageSize: 20 }); const all = listModels(db, { page: 1, pageSize: 20 });
expect(all.total).toBe(3); expect(all.total).toBe(3);
@@ -144,7 +145,7 @@ describe("模型数据访问层", () => {
const providerId = seedProvider(db); const providerId = seedProvider(db);
const created = createModel( const created = createModel(
db, db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId }, { capabilities: ["text"], externalId: "gpt-4o", name: "GPT-4o", providerId },
createNoopLogger(), createNoopLogger(),
); );
const id = (created as { model: { id: string } }).model.id; const id = (created as { model: { id: string } }).model.id;
@@ -168,7 +169,7 @@ describe("模型数据访问层", () => {
const providerId = seedProvider(db); const providerId = seedProvider(db);
const created = createModel( const created = createModel(
db, db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId }, { capabilities: ["text"], externalId: "gpt-4o", name: "原名", providerId },
createNoopLogger(), createNoopLogger(),
); );
const id = (created as { model: { id: string } }).model.id; const id = (created as { model: { id: string } }).model.id;
@@ -186,7 +187,7 @@ describe("模型数据访问层", () => {
const providerId = seedProvider(db); const providerId = seedProvider(db);
const created = createModel( const created = createModel(
db, db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId }, { capabilities: ["text"], externalId: "gpt-4o", name: "删除测试", providerId },
createNoopLogger(), createNoopLogger(),
); );
const id = (created as { model: { id: string } }).model.id; const id = (created as { model: { id: string } }).model.id;
@@ -203,9 +204,9 @@ describe("模型数据访问层", () => {
withDb((db) => { withDb((db) => {
const p1 = seedProvider(db, "P1"); const p1 = seedProvider(db, "P1");
const p2 = seedProvider(db, "P2"); const p2 = seedProvider(db, "P2");
createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger()); createModel(db, { capabilities: ["text"], externalId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
expect(getModelsByProviderId(db, p1)).toBe(2); expect(getModelsByProviderId(db, p1)).toBe(2);
expect(getModelsByProviderId(db, p2)).toBe(1); expect(getModelsByProviderId(db, p2)).toBe(1);
@@ -220,8 +221,8 @@ describe("模型数据访问层", () => {
{ {
capabilities: ["text"], capabilities: ["text"],
contextLength: 128000, contextLength: 128000,
externalId: "gpt-4o",
maxOutputTokens: 4096, maxOutputTokens: 4096,
modelId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId, providerId,
}, },

View File

@@ -132,16 +132,13 @@ describe("项目数据访问层", () => {
const result = archiveProject(db, id, createNoopLogger()); const result = archiveProject(db, id, createNoopLogger());
expect("error" in result).toBe(false); expect("error" in result).toBe(false);
const archived = (result as { project: { archivedAt: null | string; status: string } }).project; const archived = (result as { project: { status: string } }).project;
expect(archived.status).toBe("archived"); expect(archived.status).toBe("archived");
expect(archived.archivedAt).not.toBeNull();
const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as { const row = db.query("SELECT status FROM projects WHERE id = ?").get(id) as {
archived_at: null | string;
status: string; status: string;
}; };
expect(row.status).toBe("archived"); expect(row.status).toBe("archived");
expect(row.archived_at).not.toBeNull();
}); });
}); });
@@ -165,9 +162,8 @@ describe("项目数据访问层", () => {
const result = restoreProject(db, id, createNoopLogger()); const result = restoreProject(db, id, createNoopLogger());
expect("error" in result).toBe(false); expect("error" in result).toBe(false);
const restored = (result as { project: { archivedAt: null | string; status: string } }).project; const restored = (result as { project: { status: string } }).project;
expect(restored.status).toBe("active"); expect(restored.status).toBe("active");
expect(restored.archivedAt).toBeNull();
}); });
}); });

View File

@@ -0,0 +1,154 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { TestDatabaseHandle } from "../../helpers";
import { createMigratedMemoryTestDatabase } from "../../helpers";
interface ForeignKey {
from: string;
id: number;
match: string;
on_delete: string;
on_update: string;
seq: number;
table: string;
to: string;
}
interface IndexInfo {
cid: number;
name: string;
seq: number;
}
interface IndexListEntry {
name: string;
origin: string;
partial: number;
seq: number;
unique: number;
}
interface TableColumn {
cid: number;
dflt_value: null | string;
name: string;
notnull: number;
pk: number;
type: string;
}
const BUSINESS_TABLES = ["conversations", "materials", "messages", "models", "projects", "providers"] as const;
const CHECK_CONSTRAINTS: Record<string, { column: string; invalidValue: string; validValue: string }> = {
materials: { column: "status", invalidValue: "'invalid_status'", validValue: "'pending'" },
messages: { column: "role", invalidValue: "'invalid_role'", validValue: "'user'" },
projects: { column: "status", invalidValue: "'invalid_status'", validValue: "'active'" },
providers: { column: "type", invalidValue: "'invalid_type'", validValue: "'openai-compatible'" },
} as const;
const FK_INDEX_REQUIRED: Record<string, readonly string[]> = {
conversations: ["model_id", "project_id"],
materials: ["project_id"],
messages: ["conversation_id"],
models: ["provider_id"],
} as const;
const TABLES_WITH_FK = Object.keys(FK_INDEX_REQUIRED);
describe("schema 契约", () => {
let handle!: TestDatabaseHandle;
beforeAll(() => {
handle = createMigratedMemoryTestDatabase("schema-contract");
});
afterAll(() => {
handle.cleanup();
});
describe("基础列id / created_at / updated_at / deleted_at", () => {
for (const table of BUSINESS_TABLES) {
test(`${table} 包含全部基础列且约束正确`, () => {
const columns = handle.db.query(`PRAGMA table_info(${table})`).all() as TableColumn[];
const colMap = new Map(columns.map((c) => [c.name, c]));
const idCol = colMap.get("id");
expect(idCol, `${table} 缺少 id 列`).toBeDefined();
expect(idCol!.pk, `${table}.id 必须为主键`).toBe(1);
const createdAtCol = colMap.get("created_at");
expect(createdAtCol, `${table} 缺少 created_at 列`).toBeDefined();
expect(createdAtCol!.notnull, `${table}.created_at 必须 NOT NULL`).toBe(1);
const updatedAtCol = colMap.get("updated_at");
expect(updatedAtCol, `${table} 缺少 updated_at 列`).toBeDefined();
expect(updatedAtCol!.notnull, `${table}.updated_at 必须 NOT NULL`).toBe(1);
const deletedAtCol = colMap.get("deleted_at");
expect(deletedAtCol, `${table} 缺少 deleted_at 列`).toBeDefined();
expect(deletedAtCol!.notnull, `${table}.deleted_at 必须可空`).toBe(0);
expect(deletedAtCol!.pk, `${table}.deleted_at 不可为主键`).toBe(0);
});
}
});
describe("外键索引", () => {
for (const table of TABLES_WITH_FK) {
const fkColumns = FK_INDEX_REQUIRED[table]!;
test(`${table} 外键列 ${fkColumns.join(", ")} 均有索引`, () => {
const indexes = handle.db.query(`PRAGMA index_list(${table})`).all() as IndexListEntry[];
const indexedColumns = new Set<string>();
for (const idx of indexes) {
const idxCols = handle.db.query(`PRAGMA index_info(${idx.name})`).all() as IndexInfo[];
for (const col of idxCols) {
indexedColumns.add(col.name);
}
}
for (const fkCol of fkColumns) {
expect(indexedColumns, `${table}.${fkCol} 应有索引`).toContain(fkCol);
}
});
}
});
describe("外键级联策略", () => {
for (const table of TABLES_WITH_FK) {
test(`${table} 所有外键使用 NO ACTION`, () => {
const fks = handle.db.query(`PRAGMA foreign_key_list(${table})`).all() as ForeignKey[];
expect(fks.length, `${table} 应至少有一个外键`).toBeGreaterThan(0);
for (const fk of fks) {
expect(
fk.on_delete.toLowerCase(),
`${table}.${fk.from}${fk.table}.${fk.to} 应为 no action实际为 ${fk.on_delete}`,
).toBe("no action");
}
});
}
});
describe("CHECK 约束(枚举列)", () => {
for (const [table, spec] of Object.entries(CHECK_CONSTRAINTS)) {
test(`${table}.${spec.column} 拒绝非法枚举值`, () => {
handle.db.exec("SAVEPOINT check_test");
try {
expect(() => {
handle.db
.query(
`INSERT INTO ${table} (id, ${spec.column}${table === "messages" ? ", conversation_id, created_at, updated_at" : table === "materials" ? ", project_id, associated_date, description, created_at, updated_at" : table === "providers" ? ", name, api_key, base_url, created_at, updated_at" : ", name, description, created_at, updated_at"}) VALUES ('__check_test__', ${spec.invalidValue}${table === "messages" ? ", 'conv-x', '2024-01-01', '2024-01-01'" : table === "materials" ? ", 'proj-x', '2024-01-01', '', '2024-01-01', '2024-01-01'" : table === "providers" ? ", 'p', '', '', '2024-01-01', '2024-01-01'" : ", 'pj', '', '2024-01-01', '2024-01-01'"})`,
)
.run();
}).toThrow();
} finally {
handle.db.exec("ROLLBACK TO SAVEPOINT check_test");
handle.db.exec("RELEASE SAVEPOINT check_test");
}
});
}
});
});

View File

@@ -0,0 +1,241 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { createConversation, createMessage, deleteConversation } from "../../../src/server/db/conversations";
import { createMaterial, deleteMaterial, listMaterials } from "../../../src/server/db/materials";
import { createModel, deleteModel, listModels } from "../../../src/server/db/models";
import { createProject, deleteProject, listProjects, updateProject } from "../../../src/server/db/projects";
import { createProvider, deleteProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
const log = createNoopLogger();
function withDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("soft-delete-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("软删除与级联", () => {
describe("V1: 删除 project 级联软删 conversations + materials + messages", () => {
test("归档项目软删除后,关联会话/消息/素材均被软删", () => {
withDb((db) => {
const projectRes = createProject(db, { name: "P1" }, log);
const projectId = (projectRes as { project: { id: string } }).project.id;
const providerRes = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "Prov", type: "openai" },
log,
);
const modelRes = createModel(
db,
{
capabilities: ["text"],
externalId: "gpt-4",
name: "GPT",
providerId: (providerRes as { provider: { id: string } }).provider.id,
},
log,
);
const modelId = (modelRes as { model: { id: string } }).model.id;
const convRes = createConversation(db, projectId, log, modelId);
const convId = (convRes as { conversation: { id: string } }).conversation.id;
createMessage(db, { content: "hi", conversationId: convId, role: "user" }, log);
createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "M1" }, log);
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [projectId]);
const del = deleteProject(db, projectId, log);
expect("error" in del).toBe(false);
const projectRow = db.query("SELECT deleted_at FROM projects WHERE id = ?").get(projectId) as {
deleted_at: null | string;
};
const convRow = db.query("SELECT deleted_at FROM conversations WHERE id = ?").get(convId) as {
deleted_at: null | string;
};
const messageRow = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").get(convId) as {
deleted_at: null | string;
};
const materialRow = db.query("SELECT deleted_at FROM materials WHERE project_id = ?").get(projectId) as {
deleted_at: null | string;
};
expect(projectRow.deleted_at).not.toBeNull();
expect(convRow.deleted_at).not.toBeNull();
expect(messageRow.deleted_at).not.toBeNull();
expect(materialRow.deleted_at).not.toBeNull();
});
});
});
describe("V2: 删除 conversation 级联软删 messages", () => {
test("会话软删除后,其下消息均被软删", () => {
withDb((db) => {
const projectRes = createProject(db, { name: "P2" }, log);
const projectId = (projectRes as { project: { id: string } }).project.id;
const providerRes = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "Prov2", type: "openai" },
log,
);
const modelRes = createModel(
db,
{
capabilities: ["text"],
externalId: "claude",
name: "Claude",
providerId: (providerRes as { provider: { id: string } }).provider.id,
},
log,
);
const modelId = (modelRes as { model: { id: string } }).model.id;
const convRes = createConversation(db, projectId, log, modelId);
const convId = (convRes as { conversation: { id: string } }).conversation.id;
createMessage(db, { content: "m1", conversationId: convId, role: "user" }, log);
createMessage(db, { content: "m2", conversationId: convId, role: "assistant" }, log);
const del = deleteConversation(db, convId, log);
expect("error" in del).toBe(false);
const messages = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").all(convId) as Array<{
deleted_at: null | string;
}>;
expect(messages.length).toBe(2);
expect(messages.every((m) => m.deleted_at !== null)).toBe(true);
});
});
});
describe("V3: paginateQuery softDelete 自动过滤已删除行", () => {
test("listProjects 自动排除软删除项目", () => {
withDb((db) => {
createProject(db, { name: "Alive1" }, log);
createProject(db, { name: "Alive2" }, log);
const toDelete = createProject(db, { name: "Dying" }, log);
const dyingId = (toDelete as { project: { id: string } }).project.id;
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [dyingId]);
deleteProject(db, dyingId, log);
const result = listProjects(db, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((p) => p.name).sort()).toEqual(["Alive1", "Alive2"]);
});
});
test("listMaterials 自动排除软删除素材", () => {
withDb((db) => {
const proj = createProject(db, { name: "PM" }, log);
const projectId = (proj as { project: { id: string } }).project.id;
createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "K1" }, log);
createMaterial(db, projectId, { associatedDate: "2024-01-02", description: "K2" }, log);
const toDelete = createMaterial(db, projectId, { associatedDate: "2024-01-03", description: "K3" }, log);
const materialId = (toDelete as { material: { id: string } }).material.id;
deleteMaterial(db, projectId, materialId, log);
const result = listMaterials(db, projectId, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((m) => m.description).sort()).toEqual(["K1", "K2"]);
});
});
test("listModels 自动排除软删除模型", () => {
withDb((db) => {
const prov = createProvider(db, { apiKey: "sk", baseUrl: "https://x.com", name: "Px", type: "openai" }, log);
const providerId = (prov as { provider: { id: string } }).provider.id;
createModel(db, { capabilities: ["text"], externalId: "a", name: "A", providerId }, log);
createModel(db, { capabilities: ["text"], externalId: "b", name: "B", providerId }, log);
const dying = createModel(db, { capabilities: ["text"], externalId: "c", name: "C", providerId }, log);
deleteModel(db, (dying as { model: { id: string } }).model.id, log);
const result = listModels(db, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((m) => m.name).sort()).toEqual(["A", "B"]);
});
});
});
describe("V4: 应用层唯一约束(软删后同名复活)", () => {
test("软删除项目后可以创建同名项目", () => {
withDb((db) => {
const first = createProject(db, { name: "SameName" }, log);
const firstId = (first as { project: { id: string } }).project.id;
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [firstId]);
deleteProject(db, firstId, log);
const second = createProject(db, { name: "SameName" }, log);
expect("error" in second).toBe(false);
});
});
test("未删除同名项目存在时创建失败409", () => {
withDb((db) => {
createProject(db, { name: "ClashName" }, log);
const result = createProject(db, { name: "ClashName" }, log);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
});
test("更新项目名称不与自身冲突", () => {
withDb((db) => {
const created = createProject(db, { name: "SelfUpdate" }, log);
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: "SelfUpdate" }, log);
expect("error" in result).toBe(false);
});
});
});
describe("V5: 删除 provider 时阻止(存在未删除 model", () => {
test("存在未删除 model 时删除 provider 返回错误", () => {
withDb((db) => {
const prov = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://p.com", name: "BlockProv", type: "openai" },
log,
);
const providerId = (prov as { provider: { id: string } }).provider.id;
createModel(db, { capabilities: ["text"], externalId: "blocking-model", name: "BlockM", providerId }, log);
const result = deleteProvider(db, providerId, log);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
});
test("所有 model 已软删除后可以删除 provider", () => {
withDb((db) => {
const prov = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://p.com", name: "FreeProv", type: "openai" },
log,
);
const providerId = (prov as { provider: { id: string } }).provider.id;
const m = createModel(db, { capabilities: ["text"], externalId: "free-model", name: "FreeM", providerId }, log);
deleteModel(db, (m as { model: { id: string } }).model.id, log);
const result = deleteProvider(db, providerId, log);
expect("error" in result).toBe(false);
});
});
});
});

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import type { RuntimeMode } from "../../src/shared/api";
import { parseListParams } from "../../src/server/helpers/list-params";
const mode: RuntimeMode = "test";
function makeUrl(params: Record<string, string> = {}): URL {
const sp = new URLSearchParams(params);
return new URL(`http://localhost/api/test?${sp.toString()}`);
}
describe("parseListParams", () => {
test("returns defaults when no params provided", () => {
const url = makeUrl();
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
expect(result.keyword).toBeUndefined();
expect(result.sortBy).toBeUndefined();
expect(result.sortOrder).toBeUndefined();
});
test("parses valid pagination params", () => {
const url = makeUrl({ page: "2", pageSize: "50" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.page).toBe(2);
expect(result.pageSize).toBe(50);
});
test("parses keyword param", () => {
const url = makeUrl({ keyword: "test" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.keyword).toBe("test");
});
test("keyword empty string becomes undefined", () => {
const url = makeUrl({ keyword: "" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.keyword).toBeUndefined();
});
test("parses valid sort params", () => {
const url = makeUrl({ sortBy: "name", sortOrder: "asc" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("name");
expect(result.sortOrder).toBe("asc");
});
test("parses desc sortOrder", () => {
const url = makeUrl({ sortBy: "createdAt", sortOrder: "desc" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortOrder).toBe("desc");
});
test("sortBy without sortOrder returns undefined sortOrder", () => {
const url = makeUrl({ sortBy: "name" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("name");
expect(result.sortOrder).toBeUndefined();
});
test("rejects invalid page", () => {
const url = makeUrl({ page: "0" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(400);
});
test("rejects negative page", () => {
const url = makeUrl({ page: "-1" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects non-integer page", () => {
const url = makeUrl({ page: "1.5" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects pageSize over 200", () => {
const url = makeUrl({ pageSize: "201" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects invalid sortOrder", () => {
const url = makeUrl({ sortBy: "name", sortOrder: "invalid" });
const result = parseListParams(url, mode);
expect(result).toBeInstanceOf(Response);
});
test("rejects sortBy not in whitelist", () => {
const url = makeUrl({ sortBy: "evil" });
const result = parseListParams(url, mode, { allowedSortBy: ["name", "createdAt"] });
expect(result).toBeInstanceOf(Response);
});
test("allows sortBy in whitelist", () => {
const url = makeUrl({ sortBy: "name" });
const result = parseListParams(url, mode, { allowedSortBy: ["name", "createdAt"] });
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("name");
});
test("allows sortBy when no whitelist provided", () => {
const url = makeUrl({ sortBy: "anything" });
const result = parseListParams(url, mode);
if (result instanceof Response) throw new Error("Should not return Response");
expect(result.sortBy).toBe("anything");
});
test("parses all params together", () => {
const url = makeUrl({ keyword: "hello", page: "3", pageSize: "10", sortBy: "createdAt", sortOrder: "desc" });
const result = parseListParams(url, mode, { allowedSortBy: ["createdAt"] });
if (result instanceof Response) throw new Error("Should not return Response");
expect(result).toEqual({
keyword: "hello",
page: 3,
pageSize: 10,
sortBy: "createdAt",
sortOrder: "desc",
});
});
});

36
tests/server/mocks/ai.ts Normal file
View File

@@ -0,0 +1,36 @@
import { mock } from "bun:test";
void mock.module("ai", () => ({
createAgentUIStreamResponse: (opts: {
agent: unknown;
messages: unknown[];
onFinish:
| ((event: { finishReason?: string; responseMessage: { parts?: Array<{ text: string; type: string }> } }) => void)
| undefined;
}) => {
if (opts.onFinish) {
opts.onFinish({
responseMessage: {
parts: [{ text: "test reply from AI", type: "text" }],
},
});
}
return Promise.resolve(
new Response(
'data: {"type":"start-step"}\n\ndata: {"type":"text-start","id":"txt-1"}\n\ndata: {"type":"text-delta","id":"txt-1","delta":"test reply from AI"}\n\ndata: {"type":"text-end","id":"txt-1"}\n\ndata: {"type":"finish-step"}\n\ndata: {"type":"finish"}\n\n',
{
headers: { "Content-Type": "text/event-stream" },
},
),
);
},
createProviderRegistry: () => ({
languageModel: () => ({}),
}),
generateText: () => Promise.resolve({ text: "AI\u603B\u7ED3\u6807\u9898", usage: {} }),
stepCountIs: () => () => true,
tool: () => ({ execute: async () => await Promise.resolve({}) }),
ToolLoopAgent: function M() {
// no-op: createAgentUIStreamResponse handles streaming
},
}));

View File

@@ -1,6 +1,6 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { Conversation, Message, RuntimeMode } from "../../../src/shared/api"; import type { Conversation, Message, RuntimeMode } from "../../../src/shared/api";
@@ -9,45 +9,11 @@ import { createProject } from "../../../src/server/db/projects";
import { createProvider } from "../../../src/server/db/providers"; import { createProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger"; import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers"; import { createMigratedMemoryTestDatabase } from "../../helpers";
import "../mocks/ai";
const MODE: RuntimeMode = "test"; const MODE: RuntimeMode = "test";
const LOG = createNoopLogger(); const LOG = createNoopLogger();
void mock.module("ai", () => ({
createAgentUIStreamResponse: (opts: {
agent: unknown;
messages: unknown[];
onFinish:
| ((event: { finishReason?: string; responseMessage: { parts?: Array<{ text: string; type: string }> } }) => void)
| undefined;
}) => {
if (opts.onFinish) {
opts.onFinish({
responseMessage: {
parts: [{ text: "test reply from AI", type: "text" }],
},
});
}
return Promise.resolve(
new Response(
'data: {"type":"start-step"}\n\ndata: {"type":"text-start","id":"txt-1"}\n\ndata: {"type":"text-delta","id":"txt-1","delta":"test reply from AI"}\n\ndata: {"type":"text-end","id":"txt-1"}\n\ndata: {"type":"finish-step"}\n\ndata: {"type":"finish"}\n\n',
{
headers: { "Content-Type": "text/event-stream" },
},
),
);
},
createProviderRegistry: () => ({
languageModel: () => ({}),
}),
generateText: () => Promise.resolve({ text: "AI总结标题", usage: {} }),
stepCountIs: () => () => true,
tool: () => ({ execute: async () => await Promise.resolve({}) }),
ToolLoopAgent: function M() {
// no-op: createAgentUIStreamResponse handles streaming
},
}));
async function createConversationViaHandler(req: Request, db: Database): Promise<Response> { async function createConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create"); const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create");
return h(req, db, MODE, LOG); return h(req, db, MODE, LOG);
@@ -78,12 +44,12 @@ async function patchConversationViaHandler(req: Request, db: Database): Promise<
return h(req, db, MODE, LOG); return h(req, db, MODE, LOG);
} }
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string { function seedModel(db: Database, providerId: string, modelName = "GPT-4o", externalId = "gpt-4o"): string {
const result = createModel( const result = createModel(
db, db,
{ {
capabilities: ["text"], capabilities: ["text"],
modelId, externalId,
name: modelName, name: modelName,
providerId, providerId,
}, },
@@ -145,7 +111,7 @@ describe("聊天 API 路由", () => {
} }
}); });
test("无可用模型时返回 400", async () => { test("无可用模型时创建会话 modelId 为 null", async () => {
const handle = createMigratedMemoryTestDatabase("chat-create-no-model"); const handle = createMigratedMemoryTestDatabase("chat-create-no-model");
try { try {
const db = handle.db; const db = handle.db;
@@ -157,9 +123,9 @@ describe("聊天 API 路由", () => {
method: "POST", method: "POST",
}); });
const res = await createConversationViaHandler(req, db); const res = await createConversationViaHandler(req, db);
expect(res.status).toBe(400); expect(res.status).toBe(201);
const body = (await res.json()) as { error: string }; const body = (await res.json()) as { conversation: Conversation };
expect(body.error).toContain("模型"); expect(body.conversation.modelId).toBeNull();
handle.close(); handle.close();
} finally { } finally {
handle.cleanup(); handle.cleanup();

View File

@@ -1,11 +1,12 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { Model, RuntimeMode } from "../../../src/shared/api"; import type { Model, RuntimeMode } from "../../../src/shared/api";
import { createNoopLogger } from "../../../src/server/logger"; import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers"; import { createMigratedMemoryTestDatabase } from "../../helpers";
import "../mocks/ai";
const MODE: RuntimeMode = "test"; const MODE: RuntimeMode = "test";
const LOG = createNoopLogger(); const LOG = createNoopLogger();
@@ -21,7 +22,7 @@ function createTestModel(db: Database, pName: string, providerId?: string): Mode
db, db,
{ {
capabilities: ["text"], capabilities: ["text"],
modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), externalId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
name: pName, name: pName,
providerId: pid, providerId: pid,
}, },
@@ -49,13 +50,6 @@ async function listModelsViaHandler(req: Request, db: Database): Promise<Respons
import { createModel } from "../../../src/server/db/models"; import { createModel } from "../../../src/server/db/models";
import { createProvider } from "../../../src/server/db/providers"; import { createProvider } from "../../../src/server/db/providers";
void mock.module("ai", () => ({
createProviderRegistry: () => ({
languageModel: () => ({}),
}),
generateText: () => Promise.resolve({ text: "Hi" }),
}));
function seedProvider(db: Database, name?: string): string { function seedProvider(db: Database, name?: string): string {
const result = createProvider( const result = createProvider(
db, db,
@@ -99,7 +93,7 @@ describe("models API routes", () => {
const req = new Request("http://localhost/api/models", { const req = new Request("http://localhost/api/models", {
body: JSON.stringify({ body: JSON.stringify({
capabilities: ["text", "reasoning"], capabilities: ["text", "reasoning"],
modelId: "gpt-4o", externalId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId, providerId,
}), }),
@@ -110,7 +104,7 @@ describe("models API routes", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = (await res.json()) as { model: Model }; const body = (await res.json()) as { model: Model };
expect(body.model.name).toBe("GPT-4o"); expect(body.model.name).toBe("GPT-4o");
expect(body.model.modelId).toBe("gpt-4o"); expect(body.model.externalId).toBe("gpt-4o");
}); });
}); });
@@ -130,6 +124,48 @@ describe("models API routes", () => {
}); });
}); });
test("GET /api/models sortBy + sortOrder", async () => {
await withRouteDb(async (db) => {
const p = seedProvider(db, "SortP");
createTestModel(db, "Beta", p);
createTestModel(db, "Alpha", p);
const req = new Request("http://localhost/api/models?page=1&pageSize=20&sortBy=name&sortOrder=asc");
const res = await listModelsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Model[] };
expect(body.items[0]!.name).toBe("Alpha");
expect(body.items[1]!.name).toBe("Beta");
});
});
test("GET /api/models filter by capabilities", async () => {
await withRouteDb(async (db) => {
const p = seedProvider(db, "CapP");
createModel(db, { capabilities: ["text"], externalId: "text-1", name: "TextModel", providerId: p }, LOG);
createModel(
db,
{ capabilities: ["reasoning"], externalId: "reasoning-1", name: "ReasoningModel", providerId: p },
LOG,
);
const req = new Request("http://localhost/api/models?page=1&pageSize=20&capabilities=text");
const res = await listModelsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Model[]; total: number };
expect(body.total).toBe(1);
expect(body.items[0]!.name).toBe("TextModel");
});
});
test("GET /api/models rejects invalid sortBy", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/models?page=1&pageSize=20&sortBy=evil");
const res = await listModelsViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("GET /api/models filter by providerId", async () => { test("GET /api/models filter by providerId", async () => {
await withRouteDb(async (db) => { await withRouteDb(async (db) => {
const p1 = seedProvider(db, "P1"); const p1 = seedProvider(db, "P1");
@@ -192,7 +228,7 @@ describe("models API routes", () => {
const req = new Request("http://localhost/api/models", { const req = new Request("http://localhost/api/models", {
body: JSON.stringify({ body: JSON.stringify({
capabilities: ["invalid-cap"], capabilities: ["invalid-cap"],
modelId: "test", externalId: "test",
name: "Test", name: "Test",
providerId, providerId,
}), }),
@@ -212,7 +248,7 @@ describe("models API routes", () => {
body: JSON.stringify({ body: JSON.stringify({
capabilities: ["text"], capabilities: ["text"],
contextLength: 0, contextLength: 0,
modelId: "test", externalId: "test",
name: "Test", name: "Test",
providerId, providerId,
}), }),
@@ -238,7 +274,7 @@ describe("models API routes", () => {
const providerId = seedProvider(db); const providerId = seedProvider(db);
const req = new Request("http://localhost/api/models/test", { const req = new Request("http://localhost/api/models/test", {
body: JSON.stringify({ modelId: "gpt-4o", providerId }), body: JSON.stringify({ externalId: "gpt-4o", providerId }),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -253,7 +289,7 @@ describe("models API routes", () => {
test("POST /api/models/test 缺少 providerId 返回 400", async () => { test("POST /api/models/test 缺少 providerId 返回 400", async () => {
await withRouteDb(async (db) => { await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/models/test", { const req = new Request("http://localhost/api/models/test", {
body: JSON.stringify({ modelId: "gpt-4o" }), body: JSON.stringify({ externalId: "gpt-4o" }),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -265,7 +301,7 @@ describe("models API routes", () => {
test("POST /api/models/test 不存在的供应商返回 404", async () => { test("POST /api/models/test 不存在的供应商返回 404", async () => {
await withRouteDb(async (db) => { await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/models/test", { const req = new Request("http://localhost/api/models/test", {
body: JSON.stringify({ modelId: "gpt-4o", providerId: "nonexistent" }), body: JSON.stringify({ externalId: "gpt-4o", providerId: "nonexistent" }),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });

View File

@@ -1,192 +0,0 @@
import type Database from "bun:sqlite";
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, 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, LOG);
}
function createTestProject(db: Database, name = "测试项目"): Project {
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, 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, 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, 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, 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, LOG);
}
// Need db/projects for setup
import { archiveProject, createProject, getProject } from "../../../src/server/db/projects";
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
const handle = createMigratedMemoryTestDatabase("route-test");
try {
await callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("项目 API 路由", () => {
test("POST /api/projects 创建项目", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/projects", {
body: JSON.stringify({ description: "路由测试", name: "路由项目" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createProjectViaHandler(req, db);
expect(res.status).toBe(201);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("路由项目");
});
});
test("GET /api/projects 列表查询", async () => {
await withRouteDb(async (db) => {
createTestProject(db, "A项目");
createTestProject(db, "B项目");
const req = new Request("http://localhost/api/projects?page=1&pageSize=20");
const res = await listProjectsViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Project[]; total: number };
expect(body.total).toBe(2);
expect(body.items.length).toBe(2);
});
});
test("GET /api/projects/:id 获取详情", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "详情路由");
const req = new Request(`http://localhost/api/projects/${project.id}`);
const res = await getProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("详情路由");
});
});
test("PATCH /api/projects/:id 更新项目", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "更新路由");
const req = new Request(`http://localhost/api/projects/${project.id}`, {
body: JSON.stringify({ name: "已更新" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
const res = await updateProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.name).toBe("已更新");
});
});
test("POST /api/projects/:id/archive 归档项目", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "归档路由");
const req = new Request(`http://localhost/api/projects/${project.id}/archive`, { method: "POST" });
const res = await archiveProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.status).toBe("archived");
});
});
test("POST /api/projects/:id/restore 恢复项目", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "恢复路由");
archiveProject(db, project.id, LOG);
const req = new Request(`http://localhost/api/projects/${project.id}/restore`, { method: "POST" });
const res = await restoreProjectViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { project: Project };
expect(body.project.status).toBe("active");
});
});
test("DELETE /api/projects/:id 永久删除已归档项目", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "删除路由");
archiveProject(db, project.id, LOG);
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);
expect(res.status).toBe(204);
const after = getProject(db, project.id);
expect("error" in after).toBe(true);
});
});
test("创建同名项目返回 409", async () => {
await withRouteDb(async (db) => {
const req1 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createProjectViaHandler(req1, db);
const req2 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createProjectViaHandler(req2, db);
expect(res.status).toBe(409);
});
});
test("删除 active 项目返回 409", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "活项目");
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);
expect(res.status).toBe(409);
});
});
});

View File

@@ -1,6 +1,6 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { Provider, ProviderOption, RuntimeMode } from "../../../src/shared/api"; import type { Provider, ProviderOption, RuntimeMode } from "../../../src/shared/api";
@@ -8,16 +8,11 @@ import { createModel } from "../../../src/server/db/models";
import { createProvider } from "../../../src/server/db/providers"; import { createProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger"; import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers"; import { createMigratedMemoryTestDatabase } from "../../helpers";
import "../mocks/ai";
const MODE: RuntimeMode = "test"; const MODE: RuntimeMode = "test";
const LOG = createNoopLogger(); const LOG = createNoopLogger();
void mock.module("ai", () => ({
createProviderRegistry: () => ({
languageModel: () => ({}),
}),
}));
async function createProviderViaHandler(req: Request, db: Database): Promise<Response> { async function createProviderViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateProvider: h } = await import("../../../src/server/routes/providers/create"); const { handleCreateProvider: h } = await import("../../../src/server/routes/providers/create");
return h(req, db, MODE, LOG); return h(req, db, MODE, LOG);
@@ -131,6 +126,55 @@ describe("供应商 API 路由", () => {
}); });
}); });
test("GET /api/providers sortBy + sortOrder", async () => {
await withRouteDb(async (db) => {
createTestProvider(db, "Beta");
createTestProvider(db, "Alpha");
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=name&sortOrder=asc");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Provider[] };
expect(body.items[0]!.name).toBe("Alpha");
expect(body.items[1]!.name).toBe("Beta");
});
});
test("GET /api/providers filter by type", async () => {
await withRouteDb(async (db) => {
createTestProvider(db, "OpenAI Provider");
const compatResult = createProvider(
db,
{ apiKey: "sk-test", baseUrl: "https://compat.test.com", name: "Compat", type: "openai-compatible" },
LOG,
);
if ("error" in compatResult) throw new Error(compatResult.error);
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&type=openai");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Provider[]; total: number };
expect(body.total).toBe(1);
expect(body.items[0]!.name).toBe("OpenAI Provider");
});
});
test("GET /api/providers rejects invalid sortBy", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=evil");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("GET /api/providers rejects invalid sortOrder", async () => {
await withRouteDb(async (db) => {
const req = new Request("http://localhost/api/providers?page=1&pageSize=20&sortBy=name&sortOrder=invalid");
const res = await listProvidersViaHandler(req, db);
expect(res.status).toBe(400);
});
});
test("GET /api/providers/options 返回最小字段", async () => { test("GET /api/providers/options 返回最小字段", async () => {
await withRouteDb(async (db) => { await withRouteDb(async (db) => {
createTestProvider(db, "选项供应商"); createTestProvider(db, "选项供应商");
@@ -192,7 +236,7 @@ describe("供应商 API 路由", () => {
db, db,
{ {
capabilities: ["text"], capabilities: ["text"],
modelId: "gpt-4o", externalId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId: provider.id, providerId: provider.id,
}, },

View File

@@ -0,0 +1,106 @@
import { fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "bun:test";
import { createElement } from "react";
import { FilterToolbar } from "../../src/web/shared/components/FilterToolbar";
import { renderWithProviders } from "./test-utils";
describe("FilterToolbar", () => {
it("renders filter Select with placeholder", () => {
const { getByText } = renderWithProviders(
createElement(FilterToolbar, {
filters: [
{
key: "status",
label: "状态",
onChange: () => {},
options: [
{ label: "进行中", value: "active" },
{ label: "已归档", value: "archived" },
],
placeholder: "选择状态",
},
],
}),
);
expect(getByText("选择状态")).toBeTruthy();
});
it("renders search input with placeholder", () => {
const { getByPlaceholderText } = renderWithProviders(
createElement(FilterToolbar, {
search: {
onReset: () => {},
onSearch: () => {},
placeholder: "搜索名称",
},
}),
);
expect(getByPlaceholderText("搜索名称")).toBeTruthy();
});
it("renders reset button", () => {
const { container } = renderWithProviders(
createElement(FilterToolbar, {
search: {
onReset: () => {},
onSearch: () => {},
placeholder: "搜索",
},
}),
);
const resetBtn = container.querySelector('button[title="重置"]');
expect(resetBtn).toBeTruthy();
});
it("renders action buttons", () => {
const { container } = renderWithProviders(
createElement(FilterToolbar, {
actions: createElement("button", null, "新建项目"),
search: {
onReset: () => {},
onSearch: () => {},
placeholder: "搜索",
},
}),
);
const buttons = container.querySelectorAll("button");
const hasAction = Array.from(buttons).some((btn) => btn.textContent?.includes("新建项目"));
expect(hasAction).toBe(true);
});
it("calls onSearch when search entered after typing", () => {
const onSearch = vi.fn();
const onReset = vi.fn();
const { getByPlaceholderText } = renderWithProviders(
createElement(FilterToolbar, {
search: {
keyword: "",
onReset,
onSearch,
placeholder: "搜索名称",
},
}),
);
const input = getByPlaceholderText("搜索名称");
fireEvent.change(input, { target: { value: "test" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSearch).toHaveBeenCalledWith("test");
});
it("calls onReset when reset button clicked", () => {
const onReset = vi.fn();
const { container } = renderWithProviders(
createElement(FilterToolbar, {
search: {
onReset,
onSearch: () => {},
placeholder: "搜索",
},
}),
);
const resetBtn = container.querySelector<HTMLButtonElement>('button[title="重置"]');
resetBtn?.click();
expect(onReset).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,176 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Model, Project } from "../../../src/shared/api";
import { ChatPage } from "../../../src/web/features/chat/ChatPage";
import { ProjectContext } from "../../../src/web/shared/hooks/use-current-project";
import { installFetchMock, jsonResponse, renderWithProviders } from "../test-utils";
const PROJECT_ID = "proj-1";
const MOCK_PROJECT: Project = {
createdAt: "2026-01-01T00:00:00.000Z",
description: "",
id: PROJECT_ID,
name: "测试项目",
status: "active",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const TEXT_MODEL: Model = {
capabilities: ["text"],
contextLength: null,
createdAt: "2024-01-01T00:00:00.000Z",
externalId: "gpt-4o",
id: "model-1",
maxOutputTokens: null,
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const CONVERSATION = {
createdAt: "2026-06-03T00:00:00.000Z",
id: "conv-1",
modelId: "model-1",
projectId: PROJECT_ID,
title: "测试对话",
updatedAt: "2026-06-03T00:00:00.000Z",
};
function renderChatPage() {
return renderWithProviders(
createElement(ProjectContext.Provider, {
children: createElement(ChatPage),
value: MOCK_PROJECT,
}),
);
}
function setupFetchMock() {
return installFetchMock((call) => {
if (call.url.includes("/models")) {
return jsonResponse({ items: [TEXT_MODEL], total: 1 });
}
if (call.url.includes("/conversations") && call.method === "GET") {
return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 });
}
if (call.url.endsWith("/conversations") && call.method === "POST") {
return jsonResponse({ conversation: { ...CONVERSATION, id: "conv-new", title: "新会话" } }, { status: 201 });
}
if (call.method === "DELETE" && call.url.includes("/conversations/")) {
return new Response(null, { status: 204 });
}
if (call.url.includes("/messages")) {
return jsonResponse({ items: [], total: 0 });
}
if (/\/conversations\/conv-1$/.exec(call.url)) {
return jsonResponse({ conversation: CONVERSATION });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
}
void vi.mock("@ai-sdk/react", () => ({
useChat: () => ({
messages: [],
regenerate: () => undefined,
sendMessage: () => undefined,
setMessages: (msgs: unknown) => msgs,
status: "ready",
stop: () => undefined,
}),
}));
void vi.mock("ai", () => ({
DefaultChatTransport: function () {
return undefined;
},
}));
describe("ChatPage", () => {
test("渲染对话侧边栏和欢迎页", async () => {
setupFetchMock();
renderChatPage();
await waitFor(() => {
expect(screen.getByText("测试对话")).not.toBeNull();
});
expect(screen.getByText("你好,我是阿福")).not.toBeNull();
});
test("点击新对话按钮创建并选中对话", async () => {
const calls = setupFetchMock();
renderChatPage();
await waitFor(() => {
expect(screen.getByText("新对话")).not.toBeNull();
});
fireEvent.click(screen.getByText("新对话"));
await waitFor(() => {
const createCall = calls.find((c) => c.url.endsWith("/conversations") && c.method === "POST");
expect(createCall).toBeTruthy();
});
});
test("点击对话切换选中", async () => {
setupFetchMock();
renderChatPage();
await waitFor(() => {
expect(screen.getByText("测试对话")).not.toBeNull();
});
fireEvent.click(screen.getByText("测试对话"));
await waitFor(() => {
expect(screen.queryByText("你好,我是阿福")).toBeNull();
});
});
test("删除对话后列表更新", async () => {
let deleted = false;
installFetchMock((call) => {
if (call.method === "DELETE" && call.url.includes("/conversations/conv-1")) {
deleted = true;
return new Response(null, { status: 204 });
}
if (call.url.includes("/models")) {
return jsonResponse({ items: [TEXT_MODEL], total: 1 });
}
if (call.url.includes("/conversations") && call.method === "GET") {
if (deleted) {
return jsonResponse({ items: [], page: 1, pageSize: 200, total: 0 });
}
return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 });
}
if (call.url.includes("/messages")) {
return jsonResponse({ items: [], total: 0 });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
renderChatPage();
await waitFor(() => {
expect(screen.getByText("测试对话")).not.toBeNull();
});
fireEvent.click(screen.getByLabelText("删除"));
await waitFor(() => {
expect(screen.getByText("确认删除该对话?")).not.toBeNull();
});
fireEvent.click(screen.getByText("删 除"));
await waitFor(() => {
expect(screen.getByText("暂无对话")).not.toBeNull();
});
});
});

View File

@@ -13,9 +13,9 @@ const TEXT_MODEL: Model = {
capabilities: ["text"], capabilities: ["text"],
contextLength: null, contextLength: null,
createdAt: "2024-01-01T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z",
externalId: "gpt-4o",
id: "model-1", id: "model-1",
maxOutputTokens: null, maxOutputTokens: null,
modelId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId: "pv1", providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",

View File

@@ -0,0 +1,95 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Conversation } from "../../../src/shared/api";
import { ConversationCard } from "../../../src/web/features/chat/components/ConversationCard";
import { renderWithProviders } from "../test-utils";
const MOCK_CONVERSATION: Conversation = {
createdAt: "2026-06-03T00:00:00.000Z",
id: "conv-1",
modelId: "model-1",
projectId: "proj-1",
title: "测试对话",
updatedAt: "2026-06-03T00:00:00.000Z",
};
describe("ConversationCard", () => {
test("渲染对话标题", () => {
renderWithProviders(
createElement(ConversationCard, {
conversation: MOCK_CONVERSATION,
onDelete: vi.fn(),
onSelect: vi.fn(),
selected: false,
}),
);
expect(screen.getByText("测试对话")).not.toBeNull();
});
test("点击卡片触发 onSelect", () => {
const onSelect = vi.fn();
renderWithProviders(
createElement(ConversationCard, {
conversation: MOCK_CONVERSATION,
onDelete: vi.fn(),
onSelect,
selected: false,
}),
);
const item = screen.getByText("测试对话").closest(".app-sidebar-list-item")!;
fireEvent.click(item);
expect(onSelect).toHaveBeenCalledTimes(1);
});
test("点击删除按钮弹出确认框,确认后触发 onDelete", async () => {
const onDelete = vi.fn();
renderWithProviders(
createElement(ConversationCard, {
conversation: MOCK_CONVERSATION,
onDelete,
onSelect: vi.fn(),
selected: false,
}),
);
fireEvent.click(screen.getByLabelText("删除"));
await waitFor(() => {
expect(screen.getByText("确认删除该对话?")).not.toBeNull();
});
fireEvent.click(screen.getByText("删 除"));
await waitFor(() => {
expect(onDelete).toHaveBeenCalledTimes(1);
});
});
test("选中时包含 app-sidebar-list-item--selected 类名", () => {
renderWithProviders(
createElement(ConversationCard, {
conversation: MOCK_CONVERSATION,
onDelete: vi.fn(),
onSelect: vi.fn(),
selected: true,
}),
);
const item = screen.getByText("测试对话").closest(".app-sidebar-list-item--selected");
expect(item).not.toBeNull();
});
test("未选中时不包含 app-sidebar-list-item--selected 类名", () => {
renderWithProviders(
createElement(ConversationCard, {
conversation: MOCK_CONVERSATION,
onDelete: vi.fn(),
onSelect: vi.fn(),
selected: false,
}),
);
const item = screen.getByText("测试对话").closest(".app-sidebar-list-item--selected");
expect(item).toBeNull();
});
});

View File

@@ -0,0 +1,160 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Conversation } from "../../../src/shared/api";
import { ConversationList } from "../../../src/web/features/chat/components/ConversationList";
import { renderWithProviders } from "../test-utils";
const CONVERSATIONS: Conversation[] = [
{
createdAt: "2026-06-03T00:00:00.000Z",
id: "conv-1",
modelId: "model-1",
projectId: "proj-1",
title: "今天对话",
updatedAt: "2026-06-03T00:00:00.000Z",
},
{
createdAt: "2026-06-02T00:00:00.000Z",
id: "conv-2",
modelId: "model-1",
projectId: "proj-1",
title: "昨天对话",
updatedAt: "2026-06-02T00:00:00.000Z",
},
{
createdAt: "2026-05-01T00:00:00.000Z",
id: "conv-3",
modelId: "model-1",
projectId: "proj-1",
title: "更早对话",
updatedAt: "2026-05-01T00:00:00.000Z",
},
];
describe("ConversationList", () => {
test("列表为空时显示暂无对话", () => {
renderWithProviders(
createElement(ConversationList, {
conversations: [],
loading: false,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
expect(screen.getByText("暂无对话")).not.toBeNull();
});
test("渲染对话列表并按日期分组", () => {
renderWithProviders(
createElement(ConversationList, {
conversations: CONVERSATIONS,
loading: false,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
expect(screen.getByText("今天对话")).not.toBeNull();
expect(screen.getByText("昨天对话")).not.toBeNull();
expect(screen.getByText("更早对话")).not.toBeNull();
});
test("加载中显示 Skeleton", () => {
renderWithProviders(
createElement(ConversationList, {
conversations: [],
loading: true,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
expect(document.querySelector(".ant-skeleton")).not.toBeNull();
});
test("点击新对话按钮触发 onAddClick", () => {
const onAddClick = vi.fn();
renderWithProviders(
createElement(ConversationList, {
conversations: [],
loading: false,
onAddClick,
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
screen.getByText("新对话").click();
expect(onAddClick).toHaveBeenCalledTimes(1);
});
test("点击搜索按钮过滤对话标题", async () => {
renderWithProviders(
createElement(ConversationList, {
conversations: CONVERSATIONS,
loading: false,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
const searchInput = screen.getByPlaceholderText("搜索对话");
fireEvent.change(searchInput, { target: { value: "今天" } });
expect(screen.getByText("昨天对话")).not.toBeNull();
fireEvent.keyDown(searchInput, { key: "Enter" });
await waitFor(() => {
expect(screen.getByText("今天对话")).not.toBeNull();
expect(screen.queryByText("昨天对话")).toBeNull();
expect(screen.queryByText("更早对话")).toBeNull();
});
});
test("输入文字未点击搜索时不触发过滤", () => {
renderWithProviders(
createElement(ConversationList, {
conversations: CONVERSATIONS,
loading: false,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
const searchInput = screen.getByPlaceholderText("搜索对话");
fireEvent.change(searchInput, { target: { value: "今天" } });
expect(screen.getByText("今天对话")).not.toBeNull();
expect(screen.getByText("昨天对话")).not.toBeNull();
expect(screen.getByText("更早对话")).not.toBeNull();
});
test("搜索无匹配结果时显示无匹配对话", async () => {
renderWithProviders(
createElement(ConversationList, {
conversations: CONVERSATIONS,
loading: false,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
const searchInput = screen.getByPlaceholderText("搜索对话");
fireEvent.change(searchInput, { target: { value: "不存在的对话" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
await waitFor(() => {
expect(screen.getByText("无匹配对话")).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,104 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import { ConversationSidebar } from "../../../src/web/features/chat/components/ConversationSidebar";
import { installFetchMock, jsonResponse, renderWithProviders } from "../test-utils";
const PROJECT_ID = "proj-1";
const CONVERSATION = {
createdAt: "2026-06-03T00:00:00.000Z",
id: "conv-1",
modelId: "model-1",
projectId: PROJECT_ID,
title: "测试对话",
updatedAt: "2026-06-03T00:00:00.000Z",
};
function setupSuccessMock() {
return installFetchMock((call) => {
if (call.url.includes("/conversations") && call.method === "GET") {
return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
}
describe("ConversationSidebar", () => {
test("加载成功后渲染对话列表", async () => {
setupSuccessMock();
renderWithProviders(
createElement(ConversationSidebar, {
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
projectId: PROJECT_ID,
selectedId: null,
}),
);
await waitFor(() => {
expect(screen.getByText("测试对话")).not.toBeNull();
});
});
test("加载失败时显示错误和重试按钮", async () => {
installFetchMock((call) => {
if (call.url.includes("/conversations") && call.method === "GET") {
return jsonResponse({ error: "服务器错误" }, { status: 500 });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
renderWithProviders(
createElement(ConversationSidebar, {
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
projectId: PROJECT_ID,
selectedId: null,
}),
);
await waitFor(() => {
expect(screen.getByText("加载对话列表失败")).not.toBeNull();
});
expect(screen.getByText("重试")).not.toBeNull();
});
test("点击重试重新请求", async () => {
let callCount = 0;
installFetchMock((call) => {
if (call.url.includes("/conversations") && call.method === "GET") {
callCount++;
if (callCount === 1) {
return jsonResponse({ error: "服务器错误" }, { status: 500 });
}
return jsonResponse({ items: [CONVERSATION], page: 1, pageSize: 200, total: 1 });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
renderWithProviders(
createElement(ConversationSidebar, {
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
projectId: PROJECT_ID,
selectedId: null,
}),
);
await waitFor(() => {
expect(screen.getByText("加载对话列表失败")).not.toBeNull();
});
fireEvent.click(screen.getByText("重试"));
await waitFor(() => {
expect(screen.getByText("测试对话")).not.toBeNull();
});
});
});

View File

@@ -1,99 +0,0 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import type { Model, ProviderOption } from "../../../src/shared/api";
import { ModelTable } from "../../../src/web/features/models/components/ModelTable";
import { renderWithProviders } from "../test-utils";
const OPENAI_PROVIDER: ProviderOption = {
id: "pv1",
name: "OpenAI",
type: "openai",
};
const DEEPSEEK_PROVIDER: ProviderOption = {
id: "pv2",
name: "DeepSeek",
type: "openai-compatible",
};
const ENABLED_MODEL: Model = {
capabilities: ["text", "reasoning"],
contextLength: 128000,
createdAt: "2024-01-01T00:00:00.000Z",
id: "m1",
maxOutputTokens: 4096,
modelId: "gpt-4o",
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const DISABLED_MODEL: Model = {
capabilities: ["text"],
contextLength: null,
createdAt: "2024-01-01T00:00:00.000Z",
id: "m2",
maxOutputTokens: null,
modelId: "deepseek-chat",
name: "DeepSeek Chat",
providerId: "pv2",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
describe("ModelTable", () => {
test("渲染模型表格数据", () => {
renderWithProviders(
createElement(ModelTable, {
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
loading: false,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
onPageChange: () => undefined,
page: 1,
pageSize: 20,
providers: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER],
}),
);
expect(screen.getByText("GPT-4o")).not.toBeNull();
expect(screen.getByText("DeepSeek Chat")).not.toBeNull();
expect(screen.getByText("OpenAI")).not.toBeNull();
expect(screen.getByText("DeepSeek")).not.toBeNull();
expect(screen.queryByText("状态")).toBeNull();
expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull();
});
test("模型表格操作触发 edit/delete", async () => {
const onDelete = mock(() => Promise.resolve());
const onEdit = mock(() => undefined);
renderWithProviders(
createElement(ModelTable, {
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
loading: false,
onDelete,
onEdit,
onPageChange: () => undefined,
page: 1,
pageSize: 20,
providers: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER],
}),
);
fireEvent.click(screen.getAllByRole("button", { name: /编辑/ })[0]!);
expect(onEdit).toHaveBeenCalledWith(ENABLED_MODEL);
fireEvent.click(screen.getAllByRole("button", { name: /删除/ })[0]!);
await waitFor(() => expect(screen.getByText("确认删除此模型?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("m1"));
});
});

View File

@@ -1,81 +0,0 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import type { Provider } from "../../../src/shared/api";
import { ProviderTable } from "../../../src/web/features/models/components/ProviderTable";
import { renderWithProviders } from "../test-utils";
const OPENAI_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const DEEPSEEK_PROVIDER: Provider = {
apiKey: "sk-off",
baseUrl: "https://api.deepseek.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv2",
name: "DeepSeek",
type: "openai-compatible",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
describe("ProviderTable", () => {
test("渲染供应商表格数据", () => {
renderWithProviders(
createElement(ProviderTable, {
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
loading: false,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
onPageChange: () => undefined,
page: 1,
pageSize: 20,
}),
);
expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0);
expect(screen.getByText("DeepSeek")).not.toBeNull();
expect(screen.getByText("https://api.openai.com/v1")).not.toBeNull();
expect(screen.queryByText("状态")).toBeNull();
expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull();
expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull();
});
test("供应商表格操作触发 edit/delete", async () => {
const onDelete = mock(() => Promise.resolve());
const onEdit = mock(() => undefined);
renderWithProviders(
createElement(ProviderTable, {
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
loading: false,
onDelete,
onEdit,
onPageChange: () => undefined,
page: 1,
pageSize: 20,
}),
);
fireEvent.click(screen.getAllByRole("button", { name: /编辑/ })[0]!);
expect(onEdit).toHaveBeenCalledWith(OPENAI_PROVIDER);
fireEvent.click(screen.getAllByRole("button", { name: /删除/ })[0]!);
await waitFor(() => expect(screen.getByText("确认删除此供应商?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("pv1"));
});
});

View File

@@ -0,0 +1,176 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import type { Model, Provider, ProviderOption } from "../../../src/shared/api";
import { ModelTable } from "../../../src/web/features/models/components/ModelTable";
import { ProviderTable } from "../../../src/web/features/models/components/ProviderTable";
import { renderWithProviders } from "../test-utils";
const OPENAI_PROVIDER_OPTION: ProviderOption = {
id: "pv1",
name: "OpenAI",
type: "openai",
};
const DEEPSEEK_PROVIDER_OPTION: ProviderOption = {
id: "pv2",
name: "DeepSeek",
type: "openai-compatible",
};
const ENABLED_MODEL: Model = {
capabilities: ["text", "reasoning"],
contextLength: 128000,
createdAt: "2024-01-01T00:00:00.000Z",
externalId: "gpt-4o",
id: "m1",
maxOutputTokens: 4096,
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const DISABLED_MODEL: Model = {
capabilities: ["text"],
contextLength: null,
createdAt: "2024-01-01T00:00:00.000Z",
externalId: "deepseek-chat",
id: "m2",
maxOutputTokens: null,
name: "DeepSeek Chat",
providerId: "pv2",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const OPENAI_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const DEEPSEEK_PROVIDER: Provider = {
apiKey: "sk-off",
baseUrl: "https://api.deepseek.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv2",
name: "DeepSeek",
type: "openai-compatible",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
function renderModelTable(overrides?: Record<string, unknown>) {
renderWithProviders(
createElement(ModelTable, {
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
loading: false,
onChange: () => undefined,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
page: 1,
pageSize: 20,
providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION],
...overrides,
}),
);
}
function renderProviderTable(overrides?: Record<string, unknown>) {
renderWithProviders(
createElement(ProviderTable, {
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
loading: false,
onChange: () => undefined,
onDelete: () => Promise.resolve(),
onEdit: () => undefined,
page: 1,
pageSize: 20,
...overrides,
}),
);
}
const TABLE_TEST_CASES = [
{
assertData: () => {
expect(screen.getByText("GPT-4o")).not.toBeNull();
expect(screen.getByText("DeepSeek Chat")).not.toBeNull();
expect(screen.getByText("OpenAI")).not.toBeNull();
expect(screen.getByText("DeepSeek")).not.toBeNull();
},
assertNoExtra: () => {
expect(screen.queryByText("状态")).toBeNull();
expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull();
},
componentName: "ModelTable",
render: () => renderModelTable(),
},
{
assertData: () => {
expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0);
expect(screen.getByText("DeepSeek")).not.toBeNull();
expect(screen.getByText("https://api.openai.com/v1")).not.toBeNull();
},
assertNoExtra: () => {
expect(screen.queryByText("状态")).toBeNull();
expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull();
expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull();
},
componentName: "ProviderTable",
render: () => renderProviderTable(),
},
];
const TABLE_ACTION_TEST_CASES = [
{
componentName: "ModelTable",
deleteConfirmText: "确认删除此模型?",
deleteId: "m1",
editArg: ENABLED_MODEL,
render: (overrides?: Record<string, unknown>) => renderModelTable(overrides),
},
{
componentName: "ProviderTable",
deleteConfirmText: "确认删除此供应商?",
deleteId: "pv1",
editArg: OPENAI_PROVIDER,
render: (overrides?: Record<string, unknown>) => renderProviderTable(overrides),
},
];
describe("ResourceTable", () => {
for (const tc of TABLE_TEST_CASES) {
test(`${tc.componentName} 渲染表格数据`, () => {
tc.render();
tc.assertData();
tc.assertNoExtra();
});
}
for (const tc of TABLE_ACTION_TEST_CASES) {
test(`${tc.componentName} 表格操作触发 edit/delete`, async () => {
const onDelete = mock(() => Promise.resolve());
const onEdit = mock(() => undefined);
tc.render({ onDelete, onEdit });
fireEvent.click(screen.getAllByRole("button", { name: /编辑/ })[0]!);
expect(onEdit).toHaveBeenCalledWith(tc.editArg);
fireEvent.click(screen.getAllByRole("button", { name: /删除/ })[0]!);
await waitFor(() => expect(screen.getByText(tc.deleteConfirmText)).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(tc.deleteId));
});
}
});

View File

@@ -1,43 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { MarkdownTable } from "../../../../src/web/features/chat/parts/MarkdownTable";
import { renderWithProviders } from "../../test-utils";
describe("MarkdownTable 渲染表格", () => {
test("渲染原生 table 元素并添加 class", () => {
const children = createElement(
"thead",
null,
createElement("tr", null, createElement("th", null, "列1"), createElement("th", null, "列2")),
);
renderWithProviders(createElement(MarkdownTable, { children }));
const table = document.querySelector(".markdown-table");
expect(table).toBeTruthy();
expect(table!.tagName).toBe("TABLE");
expect(screen.getByText("列1")).toBeTruthy();
expect(screen.getByText("列2")).toBeTruthy();
});
test("正确传递 children 内容", () => {
const children = createElement(
"tbody",
null,
createElement("tr", null, createElement("td", null, "值1"), createElement("td", null, "值2")),
);
renderWithProviders(createElement(MarkdownTable, { children }));
expect(screen.getByText("值1")).toBeTruthy();
expect(screen.getByText("值2")).toBeTruthy();
});
test("只有传入 table 元素时才有 class", () => {
const { container } = renderWithProviders(createElement(MarkdownTable, { children: null }));
expect(container.querySelector(".markdown-table")).toBeTruthy();
});
});

View File

@@ -1,102 +0,0 @@
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/shared/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" },
{
onError: () => {},
},
);
});
}
return null;
}
render(
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(TestComponent, { onResult: (fn) => fn() }),
),
);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(errors.length).toBe(1);
expect(errors[0]).toBe("项目名称已存在");
});
});
describe("QueryClient QueryCache onError", () => {
test("query 错误触发 QueryCache onError 回调", async () => {
installFetchMock(() => new Response("broken", { status: 500 }));
const errors: string[] = [];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
queryCache: new QueryCache({
onError: (error: Error) => {
errors.push(error.message);
},
}),
});
function TestComponent({ onResult }: { onResult: (trigger: () => void) => void }) {
const called = useRef(false);
useQuery({
queryFn: () => Promise.reject(new Error("test query error")),
queryKey: ["test-query-error"],
});
if (!called.current) {
called.current = true;
onResult(() => {
// no-op trigger
});
}
return null;
}
render(
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(TestComponent, { onResult: (fn) => fn() }),
),
);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(errors.length).toBe(1);
expect(errors[0]).toBe("test query error");
});
});

View File

@@ -9,7 +9,6 @@ import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-pro
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils"; import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
const MOCK_PROJECT: Project = { const MOCK_PROJECT: Project = {
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
description: "", description: "",
id: "project-1", id: "project-1",

View File

@@ -18,7 +18,7 @@ const MOCK_MATERIAL: Material = {
}; };
describe("MaterialCard", () => { describe("MaterialCard", () => {
test("渲染素材描述、时间和状态标签", () => { test("渲染素材描述和状态标签", () => {
renderWithProviders( renderWithProviders(
createElement(MaterialCard, { createElement(MaterialCard, {
material: MOCK_MATERIAL, material: MOCK_MATERIAL,
@@ -28,7 +28,6 @@ describe("MaterialCard", () => {
}), }),
); );
expect(screen.getByText("测试素材描述")).not.toBeNull(); expect(screen.getByText("测试素材描述")).not.toBeNull();
expect(screen.getByText("今天")).not.toBeNull();
expect(screen.getByText("待审核")).not.toBeNull(); expect(screen.getByText("待审核")).not.toBeNull();
}); });

View File

@@ -1,40 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/shared/api";
import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent";
import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIAL: Material = {
associatedDate: "2026-06-03",
createdAt: "2026-06-03T00:00:00.000Z",
description: "详细描述内容",
id: "test-id",
projectId: "project-1",
status: "pending",
updatedAt: "2026-06-03T00:00:00.000Z",
};
describe("MaterialContent", () => {
test("展示素材详情和状态", () => {
renderWithProviders(createElement(MaterialContent, { material: MOCK_MATERIAL }));
expect(screen.getByText("素材详情")).not.toBeNull();
expect(screen.getByText("详细描述内容")).not.toBeNull();
expect(screen.getByText("2026-06-03")).not.toBeNull();
expect(screen.getByText("待审核")).not.toBeNull();
});
test("展示已通过状态", () => {
const approved: Material = { ...MOCK_MATERIAL, status: "approved" };
renderWithProviders(createElement(MaterialContent, { material: approved }));
expect(screen.getByText("已通过")).not.toBeNull();
});
test("展示已放弃状态", () => {
const discarded: Material = { ...MOCK_MATERIAL, status: "discarded" };
renderWithProviders(createElement(MaterialContent, { material: discarded }));
expect(screen.getByText("已放弃")).not.toBeNull();
});
});

View File

@@ -74,7 +74,7 @@ describe("MaterialList", () => {
expect(onAddClick).toHaveBeenCalledTimes(1); expect(onAddClick).toHaveBeenCalledTimes(1);
}); });
test("加载中显示 Spin", () => { test("加载中显示 Skeleton", () => {
renderWithProviders( renderWithProviders(
createElement(MaterialList, { createElement(MaterialList, {
loading: true, loading: true,
@@ -85,6 +85,6 @@ describe("MaterialList", () => {
selectedId: null, selectedId: null,
}), }),
); );
expect(document.querySelector(".ant-spin")).not.toBeNull(); expect(document.querySelector(".ant-skeleton")).not.toBeNull();
}); });
}); });

25
tests/web/format.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "bun:test";
import { formatDatetime } from "../../src/web/shared/utils/format";
describe("formatDatetime", () => {
it("formats ISO date string to YYYY-MM-DD HH:mm:ss", () => {
expect(formatDatetime("2024-06-15T14:30:45.123Z")).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it("pads single-digit month, day, hour, minute, second", () => {
const result = formatDatetime("2024-01-05T09:08:07.000Z");
expect(result).toMatch(/2024-0[1-9]-0[1-9] 0[0-9]:0[0-9]:0[0-9]/);
});
it("handles end-of-year date", () => {
const result = formatDatetime("2024-12-31T23:59:59.000Z");
expect(result).toContain("2024");
expect(result).toContain("59:59");
});
it("produces consistent output format", () => {
const result = formatDatetime("2024-06-15T14:30:45.123Z");
expect(result.length).toBe(19);
});
});

View File

@@ -1,398 +0,0 @@
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/shared/hooks/use-models";
import {
useArchiveProject,
useCreateProject,
useDeleteProject,
useRestoreProject,
useUpdateProject,
} from "../../../src/web/shared/hooks/use-projects";
import {
useCreateProvider,
useDeleteProvider,
useTestProviderConfig,
useUpdateProvider,
} from "../../../src/web/shared/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);
});
});

View File

@@ -14,9 +14,9 @@ const MODEL = {
capabilities: ["text"] as Array<"text">, capabilities: ["text"] as Array<"text">,
contextLength: null, contextLength: null,
createdAt: "2024-01-01T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z",
externalId: "gpt-4o",
id: "m1", id: "m1",
maxOutputTokens: null, maxOutputTokens: null,
modelId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId: "pv1", providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
@@ -59,7 +59,7 @@ describe("use-models request helpers", () => {
await createModel({ await createModel({
capabilities: ["text"], capabilities: ["text"],
modelId: "gpt-4o", externalId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId: "pv1", providerId: "pv1",
}); });
@@ -75,7 +75,7 @@ describe("use-models request helpers", () => {
]); ]);
expect(jsonBody(calls[0]?.body)).toEqual({ expect(jsonBody(calls[0]?.body)).toEqual({
capabilities: ["text"], capabilities: ["text"],
modelId: "gpt-4o", externalId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId: "pv1", providerId: "pv1",
}); });
@@ -86,7 +86,7 @@ describe("use-models request helpers", () => {
installFetchMock(() => jsonResponse({ error: "模型名称已存在" }, { status: 409 })); installFetchMock(() => jsonResponse({ error: "模型名称已存在" }, { status: 409 }));
await expectRejectsWithMessage( await expectRejectsWithMessage(
() => createModel({ capabilities: ["text"], modelId: "gpt-4o", name: "重复", providerId: "pv1" }), () => createModel({ capabilities: ["text"], externalId: "gpt-4o", name: "重复", providerId: "pv1" }),
"模型名称已存在", "模型名称已存在",
); );
}); });
@@ -100,12 +100,12 @@ describe("use-models request helpers", () => {
test("testModelConnection 调用正确 URL 和 body", async () => { test("testModelConnection 调用正确 URL 和 body", async () => {
const calls = installFetchMock(() => jsonResponse({ modelTestResponse: { message: "模型连接成功", ok: true } })); const calls = installFetchMock(() => jsonResponse({ modelTestResponse: { message: "模型连接成功", ok: true } }));
const result = await testModelConnection({ modelId: "gpt-4o", providerId: "pv1" }); const result = await testModelConnection({ externalId: "gpt-4o", providerId: "pv1" });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.message).toBe("模型连接成功"); expect(result.message).toBe("模型连接成功");
expect(calls[0]?.method).toBe("POST"); expect(calls[0]?.method).toBe("POST");
expect(calls[0]?.url).toBe("/api/models/test"); expect(calls[0]?.url).toBe("/api/models/test");
expect(jsonBody(calls[0]?.body)).toEqual({ modelId: "gpt-4o", providerId: "pv1" }); expect(jsonBody(calls[0]?.body)).toEqual({ externalId: "gpt-4o", providerId: "pv1" });
}); });
}); });

View File

@@ -12,7 +12,6 @@ import {
import { installFetchMock, jsonResponse } from "../test-utils"; import { installFetchMock, jsonResponse } from "../test-utils";
const PROJECT = { const PROJECT = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z",
description: "描述", description: "描述",
id: "p1", id: "p1",

View File

@@ -1,25 +0,0 @@
/* eslint-disable @typescript-eslint/require-await */
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { DashboardPage } from "../../../src/web/features/dashboard";
import { renderWithProviders } from "../test-utils";
describe("DashboardPage", () => {
test("渲染欢迎信息", () => {
window.fetch = (async () => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(DashboardPage));
expect(screen.getByText(/欢迎使用/)).not.toBeNull();
});
});

View File

@@ -4,9 +4,9 @@ import { createElement } from "react";
import type { Model, Provider } from "../../../src/shared/api"; import type { Model, Provider } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { ModelFormModal } from "../../../src/web/features/models/components/ModelFormModal"; import { ModelFormModal } from "../../../src/web/features/models/components/ModelFormModal";
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal"; import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
import { renderWithProviders } from "../test-utils";
const ENABLED_PROVIDER: Provider = { const ENABLED_PROVIDER: Provider = {
apiKey: "sk-test", apiKey: "sk-test",
@@ -32,9 +32,9 @@ const ENABLED_MODEL: Model = {
capabilities: ["text", "reasoning"], capabilities: ["text", "reasoning"],
contextLength: 128000, contextLength: 128000,
createdAt: "2024-01-01T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z",
externalId: "gpt-4o",
id: "m1", id: "m1",
maxOutputTokens: 4096, maxOutputTokens: 4096,
modelId: "gpt-4o",
name: "GPT-4o", name: "GPT-4o",
providerId: "pv1", providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
@@ -45,107 +45,6 @@ function clickLatestConfirmButton() {
fireEvent.click(buttons[buttons.length - 1]!); fireEvent.click(buttons[buttons.length - 1]!);
} }
describe("ProviderFormModal", () => {
test("编辑供应商表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: ENABLED_PROVIDER,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: (args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
},
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
clickLatestConfirmButton();
await waitFor(() => expect(updateCalls.length).toBe(1));
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
});
test("新建供应商默认使用 openai-compatible 类型", async () => {
const createCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: (data: unknown) => {
createCalls.push(data);
return Promise.resolve();
},
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
clickLatestConfirmButton();
await waitFor(() => expect(createCalls.length).toBe(1));
expect(createCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
test("供应商表单可使用当前表单配置测试连接", async () => {
const testCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: (data: unknown) => {
testCalls.push(data);
return Promise.resolve({ message: "连接成功", ok: true });
},
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await waitFor(() => expect(testCalls.length).toBe(1));
expect(testCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
});
describe("ModelFormModal", () => { describe("ModelFormModal", () => {
test("编辑模型表单只提交变更字段", async () => { test("编辑模型表单只提交变更字段", async () => {
const updateCalls: unknown[] = []; const updateCalls: unknown[] = [];
@@ -291,7 +190,7 @@ describe("ModelFormModal", () => {
await waitFor(() => await waitFor(() =>
expect(testModelConnection).toHaveBeenCalledWith({ expect(testModelConnection).toHaveBeenCalledWith({
modelId: "gpt-4o", externalId: "gpt-4o",
providerId: "pv1", providerId: "pv1",
}), }),
); );
@@ -317,3 +216,113 @@ describe("ModelFormModal", () => {
await waitFor(() => expect(screen.getByRole("button", { name: "测试连接" })).not.toBeNull()); await waitFor(() => expect(screen.getByRole("button", { name: "测试连接" })).not.toBeNull());
}); });
}); });
const TEST_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const TEST_MODEL: Model = {
capabilities: ["text"],
contextLength: 128000,
createdAt: "2024-01-01T00:00:00.000Z",
externalId: "gpt-4o",
id: "m1",
maxOutputTokens: 4096,
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function createModelFetchMock() {
let models = [TEST_MODEL];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/providers/options" && call.method === "GET") {
return jsonResponse({ items: [TEST_PROVIDER] });
}
if (url.pathname === "/api/models" && call.method === "POST") {
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const created: Model = {
...TEST_MODEL,
...data,
createdAt: "2024-01-02T00:00:00.000Z",
id: "m-new",
updatedAt: "2024-01-02T00:00:00.000Z",
};
models = [created, ...models];
return jsonResponse({ model: created }, { status: 201 });
}
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "PATCH") {
const id = url.pathname.split("/").pop()!;
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const existing = models.find((m) => m.id === id) ?? TEST_MODEL;
const updated = { ...existing, ...(data as Partial<Model>) };
models = models.map((m) => (m.id === id ? updated : m));
return jsonResponse({ model: updated });
}
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
const id = url.pathname.split("/").pop()!;
models = models.filter((m) => m.id !== id);
return new Response(null, { status: 204 });
}
if (url.pathname === "/api/models" && call.method === "GET") {
const keyword = url.searchParams.get("keyword") ?? "";
const items = keyword ? models.filter((m) => `${m.name}${m.externalId}`.includes(keyword)) : models;
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
describe("ModelListPage", () => {
test("渲染模型列表页并请求模型数据", async () => {
const calls = createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => {
expect(screen.getByText("GPT-4o")).not.toBeNull();
});
expect(screen.getByPlaceholderText("搜索模型名称或 ID")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull();
expect(calls.some((call) => call.url.includes("/api/models"))).toBe(true);
}, 15000);
test("搜索模型更新请求参数", async () => {
const calls = createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
const input = screen.getByPlaceholderText("搜索模型名称或 ID");
fireEvent.change(input, { target: { value: "gpt" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
}, 15000);
test("新建模型弹窗可以打开", async () => {
createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建模型/ }));
await waitFor(() => expect(screen.getByPlaceholderText("请输入模型名称")).not.toBeNull());
}, 15000);
});

View File

@@ -11,7 +11,6 @@ import { ProjectTable } from "../../../src/web/features/projects/components/Proj
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils"; import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ACTIVE_PROJECT: Project = { const ACTIVE_PROJECT: Project = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z",
description: "活跃描述", description: "活跃描述",
id: "p1", id: "p1",
@@ -21,7 +20,6 @@ const ACTIVE_PROJECT: Project = {
}; };
const ARCHIVED_PROJECT: Project = { const ARCHIVED_PROJECT: Project = {
archivedAt: "2024-01-02T00:00:00.000Z",
createdAt: "2024-01-01T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z",
description: "归档描述", description: "归档描述",
id: "p2", id: "p2",
@@ -57,7 +55,6 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR
if (url.pathname === "/api/projects" && call.method === "POST") { if (url.pathname === "/api/projects" && call.method === "POST") {
const data = jsonBody(call.body) as { description?: string; name: string }; const data = jsonBody(call.body) as { description?: string; name: string };
const created: Project = { const created: Project = {
archivedAt: null,
createdAt: "2024-01-03T00:00:00.000Z", createdAt: "2024-01-03T00:00:00.000Z",
description: data.description ?? "", description: data.description ?? "",
id: "p-created", id: "p-created",
@@ -83,13 +80,13 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR
} }
if (call.method === "POST" && action === "archive") { if (call.method === "POST" && action === "archive") {
const archived = { ...project, archivedAt: "2024-01-04T00:00:00.000Z", status: "archived" as const }; const archived = { ...project, status: "archived" as const };
projects = projects.map((item) => (item.id === id ? archived : item)); projects = projects.map((item) => (item.id === id ? archived : item));
return jsonResponse({ project: archived }); return jsonResponse({ project: archived });
} }
if (call.method === "POST" && action === "restore") { if (call.method === "POST" && action === "restore") {
const restored = { ...project, archivedAt: null, status: "active" as const }; const restored = { ...project, status: "active" as const };
projects = projects.map((item) => (item.id === id ? restored : item)); projects = projects.map((item) => (item.id === id ? restored : item));
return jsonResponse({ project: restored }); return jsonResponse({ project: restored });
} }
@@ -114,7 +111,7 @@ function LocationProbe() {
} }
describe("ProjectsPage", () => { describe("ProjectsPage", () => {
test("渲染项目管理入口并按状态请求项目列表", async () => { test("渲染项目管理入口并展示项目列表", async () => {
const calls = createProjectFetchMock(); const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" }); renderWithProviders(createElement(App), { initialRoute: "/projects" });
@@ -123,27 +120,34 @@ describe("ProjectsPage", () => {
expect(screen.getByText("活跃项目")).not.toBeNull(); expect(screen.getByText("活跃项目")).not.toBeNull();
}); });
expect(screen.getByText("归档")).not.toBeNull(); expect(screen.getByText("归档项目")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull(); expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
expect(screen.getByPlaceholderText("搜索名称或描述")).not.toBeNull(); expect(screen.getByPlaceholderText("搜索名称或描述")).not.toBeNull();
expect(calls.some((call) => call.url.includes("status=active"))).toBe(true); expect(calls.filter((call) => !call.url.includes("/api/meta")).length).toBeGreaterThan(0);
}); });
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => { test("搜索和状态筛选会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock(); const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" }); renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull()); await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } }); const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ })); fireEvent.change(searchInput1, { target: { value: "归档" } });
fireEvent.keyDown(searchInput1, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true)); await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.click(screen.getByText("已归档"));
const statusLabels = screen.getAllByText("状态");
const selectLabel = statusLabels.find((el) => el.closest(".ant-select"));
if (selectLabel) fireEvent.mouseDown(selectLabel);
await waitFor(() => {
const archivedOptions = screen.getAllByText("已归档");
const dropdownOption = archivedOptions.find((el) => el.closest(".ant-select-item"));
if (dropdownOption) fireEvent.click(dropdownOption);
});
await waitFor(() => expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true));
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull()); await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true);
expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true);
}); });
test("清空搜索条件复位请求参数并重新展示全部项目", async () => { test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
@@ -152,12 +156,19 @@ describe("ProjectsPage", () => {
renderWithProviders(createElement(App), { initialRoute: "/projects" }); renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull()); await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } }); const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ })); fireEvent.change(searchInput2, { target: { value: "归档" } });
fireEvent.keyDown(searchInput2, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true)); await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "" } }); const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ })); const clearButton = searchInput3.closest(".ant-input-search")?.querySelector(".ant-input-clear-icon");
if (clearButton) fireEvent.click(clearButton);
await waitFor(() => {
const lastProjectCall = [...calls].reverse().find((call) => call.url.includes("/api/projects"));
expect(lastProjectCall && !lastProjectCall.url.includes("keyword=")).toBe(true);
});
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull()); await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
}); });
@@ -249,13 +260,12 @@ describe("ProjectsPage", () => {
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 }, data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
loading: false, loading: false,
onArchive, onArchive,
onChange: () => undefined,
onDelete, onDelete,
onEdit: () => undefined, onEdit: () => undefined,
onPageChange: () => undefined,
onRestore, onRestore,
page: 1, page: 1,
pageSize: 20, pageSize: 20,
status: "active",
}), }),
), ),
); );

View File

@@ -0,0 +1,223 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Provider } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ENABLED_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
describe("ProviderFormModal", () => {
test("编辑供应商表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: ENABLED_PROVIDER,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: (args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
},
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
clickLatestConfirmButton();
await waitFor(() => expect(updateCalls.length).toBe(1));
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
});
test("新建供应商默认使用 openai-compatible 类型", async () => {
const createCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: (data: unknown) => {
createCalls.push(data);
return Promise.resolve();
},
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
clickLatestConfirmButton();
await waitFor(() => expect(createCalls.length).toBe(1));
expect(createCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
test("供应商表单可使用当前表单配置测试连接", async () => {
const testCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: (data: unknown) => {
testCalls.push(data);
return Promise.resolve({ message: "连接成功", ok: true });
},
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await waitFor(() => expect(testCalls.length).toBe(1));
expect(testCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
});
const TEST_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function createProviderFetchMock() {
let providers = [TEST_PROVIDER];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/providers" && call.method === "POST") {
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const created: Provider = {
...TEST_PROVIDER,
...data,
createdAt: "2024-01-02T00:00:00.000Z",
id: "pv-new",
updatedAt: "2024-01-02T00:00:00.000Z",
};
providers = [created, ...providers];
return jsonResponse({ provider: created }, { status: 201 });
}
if (/^\/api\/providers\/[^/]+$/.exec(url.pathname) && call.method === "PATCH") {
const id = url.pathname.split("/").pop()!;
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const existing = providers.find((p) => p.id === id) ?? TEST_PROVIDER;
const updated = { ...existing, ...(data as Partial<Provider>) };
providers = providers.map((p) => (p.id === id ? updated : p));
return jsonResponse({ provider: updated });
}
if (/^\/api\/providers\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
const id = url.pathname.split("/").pop()!;
providers = providers.filter((p) => p.id !== id);
return new Response(null, { status: 204 });
}
if (url.pathname === "/api/providers" && call.method === "GET") {
const keyword = url.searchParams.get("keyword") ?? "";
const items = keyword ? providers.filter((p) => p.name.includes(keyword)) : providers;
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
if (/\/api\/providers\/[^/]+\/test$/.exec(url.pathname) && call.method === "POST") {
return jsonResponse({ message: "连接成功", ok: true });
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
describe("ProviderListPage", () => {
test("渲染供应商列表页并请求供应商数据", async () => {
const calls = createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => {
expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0);
});
expect(screen.getByPlaceholderText("搜索供应商名称")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull();
expect(calls.some((call) => call.url.includes("/api/providers"))).toBe(true);
}, 15000);
test("搜索供应商更新请求参数", async () => {
const calls = createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
const input = screen.getByPlaceholderText("搜索供应商名称");
fireEvent.change(input, { target: { value: "Open" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
}, 15000);
test("新建供应商弹窗可以打开", async () => {
createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建供应商/ }));
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
}, 15000);
});

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