Compare commits
9 Commits
ad10134c20
...
db40d04dc5
| Author | SHA1 | Date | |
|---|---|---|---|
| db40d04dc5 | |||
| e25b2537fd | |||
| 6f547560d1 | |||
| 61b479e2be | |||
| f67cfa84ef | |||
| dc7d9e83b8 | |||
| 525278870f | |||
| eb93de52d8 | |||
| 83cc28fe1b |
@@ -5,12 +5,13 @@
|
||||
## 目录索引
|
||||
|
||||
```text
|
||||
docs/
|
||||
docs/
|
||||
README.md
|
||||
development/
|
||||
README.md
|
||||
architecture.md
|
||||
backend.md
|
||||
crud.md
|
||||
frontend.md
|
||||
release.md
|
||||
user/
|
||||
@@ -40,12 +41,12 @@ docs/
|
||||
## 按任务阅读路径
|
||||
|
||||
| 任务 | 必读文档 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
|
||||
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.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) |
|
||||
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
|
||||
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
|
||||
@@ -63,6 +64,7 @@ docs/
|
||||
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
||||
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
|
||||
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
|
||||
| 管理页面 CRUD 模式(筛选工具条、URL 同步、分页排序约定) | `docs/development/crud.md` |
|
||||
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
||||
| 快速开始、安装配置 | `docs/user/usage.md` |
|
||||
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
|
||||
|
||||
@@ -217,6 +217,9 @@ features/<name>/
|
||||
- 输入输出类型来自 `src/shared/api.ts`。
|
||||
- 列表查询使用 `paginateQuery()`,不重复实现分页。
|
||||
- 列名 snake_case,TS 类型 camelCase,Drizzle 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 调用层
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
- `response.ts`:`createApiError(error, status)`、`createHeaders(mode, init)`、`createMetaResponse(version)`、`formatDuration(ms)`、`jsonResponse(body, options)`
|
||||
- `url.ts`:`parseIdFromUrl(url)`
|
||||
- `list-params.ts`:`parseListParams(url, mode, options?)` — 统一校验分页/排序参数,替代 validatePagination
|
||||
- `pagination.ts`:`paginateQuery()` — Drizzle 分页查询封装
|
||||
|
||||
`src/server/middleware/`:
|
||||
|
||||
@@ -18,9 +20,14 @@
|
||||
|
||||
SQLite + bun:sqlite + Drizzle ORM。
|
||||
|
||||
- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 camelCase。
|
||||
- `src/server/db/connection.ts`:`createDatabase(dataDir, logger)` 打开 `alfred.db`,PRAGMA:foreign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转为 Drizzle 实例。`paginateQuery()` 分页工具。
|
||||
- Migration:开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/backups/`,事务中执行,失败回滚。
|
||||
- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 camelCase。所有业务表通过 `helpers.ts` 的 `baseColumns` 获取 id/created_at/updated_at/deleted_at。
|
||||
- `src/server/db/helpers.ts`:`baseColumns` 常量(id、createdAt、updatedAt、deletedAt)+ Drizzle 构建器再导出。`src/server/db/` 内禁止直接从 `drizzle-orm/sqlite-core` 导入 `sqliteTable`(ESLint 强制)。
|
||||
- `src/server/db/connection.ts`:`createDatabase(dataDir, logger)` 打开 `alfred.db`,PRAGMA:foreign_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 服务层
|
||||
|
||||
- `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、capabilities)。注:AI 层 `modelId` 对应 DB 层 `Model.externalId`。
|
||||
- `src/server/ai/registry.ts`:
|
||||
- `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry,每次调用重建,不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。
|
||||
- `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/models/test` — 用模型关联供应商 + modelId 测试。
|
||||
- `POST /api/models/test` — 用模型关联供应商 + externalId 测试。
|
||||
|
||||
## 素材 API
|
||||
|
||||
@@ -64,9 +71,9 @@ SQLite + bun:sqlite + Drizzle ORM。
|
||||
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
|
||||
| POST | `/api/projects/:id/materials` | 创建素材 |
|
||||
| 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
|
||||
|
||||
@@ -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
167
docs/development/crud.md
Normal 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 页 |
|
||||
| 分页 | 点击分页器 | 更新 URL(page+pageSize) |
|
||||
| 排序 | 点击列头排序 | 重置到第 1 页,更新 URL |
|
||||
| 删除 | Popconfirm 确认后 | toast 通知,刷新列表 |
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
两个布局入口共享 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 项目渲染。
|
||||
|
||||
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和布局标题。
|
||||
@@ -18,7 +18,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
| 功能模块 | 路径 | 说明 |
|
||||
| -------- | --------------------- | --------------------------- |
|
||||
| 仪表盘 | `features/dashboard/` | 总览页面 |
|
||||
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索 |
|
||||
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索、排序 |
|
||||
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
|
||||
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
|
||||
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
|
||||
@@ -26,19 +26,19 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
## 页面
|
||||
|
||||
| 页面 | 路径 | 入口 |
|
||||
| -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 总览 | `/` | `features/dashboard/index.tsx` |
|
||||
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
|
||||
| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
|
||||
| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
|
||||
| 模型管理 | `/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/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
|
||||
| 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` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。
|
||||
- **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。
|
||||
|
||||
@@ -47,15 +47,19 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
### 共享组件
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
| ------------- | ------------------------------------- | --------------------------------- |
|
||||
| ------------- | ------------------------------------- | ------------------------------------------------------ |
|
||||
| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳(Provider + Layout) |
|
||||
| FilterToolbar | `shared/components/FilterToolbar.tsx` | 统一筛选工具条(Select 筛选 + 搜索 + 重置 + 操作按钮) |
|
||||
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
|
||||
| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
|
||||
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
|
||||
|
||||
### 共享 Hooks
|
||||
|
||||
| Hook | 路径 | 说明 |
|
||||
| ----------------------- | --------------------------------------- | ---------------------------------------------------------- |
|
||||
| ------------------------ | --------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `use-page-search-params` | `shared/hooks/usePageSearchParams.ts` | URL 查询参数同步(筛选/分页/排序),批量更新 `setParams` 避免闭包覆盖 |
|
||||
| `use-confirm-action` | `shared/hooks/useConfirmAction.ts` | 包装异步操作 + toast 成功/失败通知 |
|
||||
| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`(30s 轮询,5s staleTime) |
|
||||
| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
|
||||
| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
|
||||
@@ -71,13 +75,15 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
### 共享工具函数
|
||||
|
||||
| 文件 | 导出 |
|
||||
| --------------- | --------------------------------------------------------------------------------------------- |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `utils/api.ts` | `handleResponse(response, extract)`、`handleVoidResponse(response)` |
|
||||
| `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)。
|
||||
|
||||
## 日志模块
|
||||
|
||||
|
||||
@@ -33,10 +33,11 @@ bun run dev config.yaml
|
||||
## 功能介绍
|
||||
|
||||
| 功能 | 路径 | 说明 |
|
||||
| -------- | ----------------------- | ---------------------------------------- |
|
||||
| -------- | ----------------------- | -------------------------------------- |
|
||||
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
|
||||
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
||||
| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 |
|
||||
| 模型 | `/models` | 管理 AI 模型,供后续 AI 功能使用 |
|
||||
| 供应商 | `/models/providers` | 配置 AI 供应商(API Key、Base URL 等) |
|
||||
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
|
||||
|
||||
平台提供两个入口:
|
||||
@@ -46,12 +47,14 @@ bun run dev config.yaml
|
||||
|
||||
从项目管理页面的 active 项目行可点击"工作台"跳转到对应项目的工作台。
|
||||
|
||||
## 模型管理
|
||||
## 模型与供应商管理
|
||||
|
||||
在 Admin 侧栏进入 `/models` 后,页面通过两个标签页管理 AI 基础配置:
|
||||
在 Admin 侧栏的"模型管理"分组下包含两个独立页面:
|
||||
|
||||
- **供应商**:新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`,baseURL 和 API Key 由用户填写。
|
||||
- **模型**:为供应商新增模型,填写模型显示名称、实际调用用的 modelId、能力标签,以及可选的上下文长度和最大输出 token。
|
||||
- **模型**(`/models`):新增、编辑、删除 AI 模型。填写模型显示名称、实际调用用的 modelId、能力标签,以及可选的上下文长度和最大输出 token。新建模型时下拉选择已配置的供应商。
|
||||
- **供应商**(`/models/providers`):新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`,baseURL 和 API Key 由用户填写。
|
||||
|
||||
侧栏"模型管理"为分组标签,点击展开/收起子项,不直接导航。
|
||||
|
||||
供应商表单提供"测试连接"操作:系统先测试 Base URL 是否可达,再尝试请求 `/models` 验证 API Key 和模型列表接口。若服务不支持 `/models`,页面会提示接口可达但可能不支持模型列表;该结果只作为提醒,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。
|
||||
|
||||
@@ -67,4 +70,4 @@ bun run dev config.yaml
|
||||
- **编辑**:最后一条用户消息可编辑,确认后重新发送
|
||||
- **重新生成**:最后一条 AI 消息可重新生成回复
|
||||
|
||||
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
|
||||
使用聊天功能前,需先在 Admin 管理台的模型和供应商页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
|
||||
|
||||
109
drizzle/0004_db_schema_standardization.sql
Normal file
109
drizzle/0004_db_schema_standardization.sql
Normal 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`);
|
||||
530
drizzle/meta/0004_snapshot.json
Normal file
530
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1780463734721,
|
||||
"tag": "0003_lying_cassandra_nova",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1780587528226,
|
||||
"tag": "0004_db_schema_standardization",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -95,6 +95,25 @@ export default tseslint.config(
|
||||
"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"],
|
||||
ignores: ["src/server/logger.ts"],
|
||||
|
||||
@@ -5,37 +5,37 @@
|
||||
"source": "vercel/ai",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/use-ai-sdk/SKILL.md",
|
||||
"computedHash": "2249889eb47ef0f61c4dba4cf2afe01c1c8dd793bb4c24230347c1ab909bb7dd"
|
||||
"computedHash": "f9381aea9aa207157c88348c6b0ae3551137955f2bd48c855c27fa86ac03cd56"
|
||||
},
|
||||
"ant-design": {
|
||||
"source": "ant-design/antd-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/ant-design/SKILL.md",
|
||||
"computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466"
|
||||
"computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179"
|
||||
},
|
||||
"antd": {
|
||||
"source": "ant-design/antd-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/antd/SKILL.md",
|
||||
"computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2"
|
||||
"computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94"
|
||||
},
|
||||
"react-router-data-mode": {
|
||||
"source": "remix-run/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/react-router-data-mode/SKILL.md",
|
||||
"computedHash": "76e3e0f70ff47b743bd90999e676515221e25fd7ee89cd9e5b340417b1a601e2"
|
||||
"computedHash": "cbbe1b1cfa8f6ceae1ab26d26b38c612279c9c272cf956471838796d85659860"
|
||||
},
|
||||
"react-router-declarative-mode": {
|
||||
"source": "remix-run/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/react-router-declarative-mode/SKILL.md",
|
||||
"computedHash": "d7ebbf1ede90809618f02cb3b3d37b9871cdd6c88a81cf338e63de50a0df6a42"
|
||||
"computedHash": "b399ee32fa82efdbdad1121421702b7725fcffac36424529a0ea452796f3bc92"
|
||||
},
|
||||
"react-router-framework-mode": {
|
||||
"source": "remix-run/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/react-router-framework-mode/SKILL.md",
|
||||
"computedHash": "26c5bdac2f686c47eb4c4b48b6cb52401cde1dc833e6d26408ddfb22ea83c5ca"
|
||||
"computedHash": "a3294459f3a5065c837929d9700fe7d35730d5051f2979090e0f715e8fea693f"
|
||||
},
|
||||
"vercel-react-best-practices": {
|
||||
"source": "vercel-labs/agent-skills",
|
||||
@@ -48,14 +48,14 @@
|
||||
"ref": "main",
|
||||
"sourceType": "github",
|
||||
"skillPath": "packages/x-skill/skills/x-components/SKILL.md",
|
||||
"computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
|
||||
"computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2"
|
||||
},
|
||||
"x-markdown": {
|
||||
"source": "ant-design/x",
|
||||
"ref": "main",
|
||||
"sourceType": "github",
|
||||
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
|
||||
"computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17"
|
||||
"computedHash": "2d26b8eda1692929e99a8b6163ef8b206f1f096a4a84507b50dbe836a7ec041e"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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 { join } from "node:path";
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { Logger } from "../logger";
|
||||
|
||||
const DB_FILENAME = "alfred.db";
|
||||
|
||||
export type DrizzleDB = ReturnType<typeof wrap>;
|
||||
|
||||
export interface PaginateResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
@@ -30,6 +32,10 @@ export function createDatabase(dataDir: string, logger: Logger): Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function notDeleted(table: { deletedAt: Column }): SQL {
|
||||
return isNull(table.deletedAt);
|
||||
}
|
||||
|
||||
export function paginateQuery<T extends SQLiteTable, R>(
|
||||
raw: Database,
|
||||
table: T,
|
||||
@@ -39,11 +45,16 @@ export function paginateQuery<T extends SQLiteTable, R>(
|
||||
orderBy?: (table: T) => SQL | undefined;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
softDelete?: Column;
|
||||
},
|
||||
): PaginateResult<R> {
|
||||
const db = wrap(raw);
|
||||
const where = options.conditions?.filter((c): c is SQL => c !== undefined);
|
||||
const whereClause = where && where.length > 0 ? and(...where) : undefined;
|
||||
const conditions = [...(options.conditions ?? [])];
|
||||
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
|
||||
.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) {
|
||||
return drizzle(raw);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
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 { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||
import { conversations, messages, models } from "./schema";
|
||||
|
||||
export function createConversation(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
defaultModelId?: string,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
|
||||
let modelId = defaultModelId;
|
||||
if (!modelId) {
|
||||
const firstModel = db.select().from(models).limit(1).get();
|
||||
if (!firstModel) return { error: "没有可用的模型,请先配置模型", status: 400 };
|
||||
modelId = firstModel.id;
|
||||
} else {
|
||||
const model = db.select().from(models).where(eq(models.id, modelId)).get();
|
||||
let modelId: null | string = defaultModelId ?? null;
|
||||
if (defaultModelId) {
|
||||
const model = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, defaultModelId), notDeleted(models)))
|
||||
.get();
|
||||
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 now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(conversations)
|
||||
.values({
|
||||
@@ -56,7 +59,7 @@ export function createMessage(
|
||||
): Message {
|
||||
const db = wrap(raw);
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(messages)
|
||||
.values({
|
||||
@@ -66,6 +69,7 @@ export function createMessage(
|
||||
id,
|
||||
parts: data.parts ?? null,
|
||||
role: data.role,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
@@ -84,7 +88,7 @@ export function createMessages(
|
||||
_logger: Logger,
|
||||
): Message[] {
|
||||
const db = wrap(raw);
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
const results: Message[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
@@ -97,6 +101,7 @@ export function createMessages(
|
||||
id,
|
||||
parts: item.parts ?? null,
|
||||
role: item.role,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
const row = db.select().from(messages).where(eq(messages.id, id)).get();
|
||||
@@ -112,11 +117,23 @@ export function deleteConversation(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
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 };
|
||||
|
||||
db.delete(messages).where(eq(messages.conversationId, id)).run();
|
||||
db.delete(conversations).where(eq(conversations.id, id)).run();
|
||||
const now = timestamp();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -125,7 +142,11 @@ export function getConversation(
|
||||
id: string,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
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 };
|
||||
return { conversation: toConversation(row) };
|
||||
}
|
||||
@@ -141,6 +162,7 @@ export function listConversations(
|
||||
orderBy: () => desc(conversations.updatedAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: conversations.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +177,7 @@ export function listMessages(
|
||||
orderBy: () => desc(messages.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: messages.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,13 +188,21 @@ export function updateConversation(
|
||||
_logger: Logger,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
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 };
|
||||
|
||||
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) {
|
||||
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 };
|
||||
updates.modelId = data.modelId;
|
||||
}
|
||||
@@ -188,7 +219,7 @@ export function updateConversation(
|
||||
|
||||
export function updateConversationTimestamp(raw: Database, id: string): void {
|
||||
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 {
|
||||
@@ -210,5 +241,6 @@ function toMessage(row: typeof messages.$inferSelect): Message {
|
||||
id: row.id,
|
||||
parts: row.parts,
|
||||
role: row.role,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
12
src/server/db/helpers.ts
Normal file
12
src/server/db/helpers.ts
Normal 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(),
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { materials, projects } from "./schema";
|
||||
|
||||
export function createMaterial(
|
||||
@@ -15,7 +15,11 @@ export function createMaterial(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
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.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
||||
|
||||
@@ -28,7 +32,7 @@ export function createMaterial(
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(materials)
|
||||
.values({
|
||||
@@ -53,11 +57,15 @@ export function deleteMaterial(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
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.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
|
||||
db.delete(materials).where(eq(materials.id, materialId)).run();
|
||||
softDeleteRecord(db, materials, materialId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -67,7 +75,11 @@ export function getMaterial(
|
||||
materialId: string,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
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.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
|
||||
@@ -91,6 +103,7 @@ export function listMaterials(
|
||||
orderBy: () => desc(materials.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: materials.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD
|
||||
|
||||
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
|
||||
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
db.transaction(() => {
|
||||
for (const migration of pending) {
|
||||
try {
|
||||
@@ -46,6 +48,14 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD
|
||||
}
|
||||
}
|
||||
})();
|
||||
} 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 全部执行完成");
|
||||
}
|
||||
|
||||
@@ -1,59 +1,61 @@
|
||||
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 { paginateQuery, wrap } from "./connection";
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { models, providers } from "./schema";
|
||||
|
||||
export function createModel(
|
||||
raw: Database,
|
||||
request: CreateModelRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { model: Model } {
|
||||
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 };
|
||||
|
||||
const name = request.name.trim();
|
||||
if (!name) return { error: "模型名称不能为空", status: 400 };
|
||||
|
||||
const modelId = request.modelId.trim();
|
||||
if (!modelId) return { error: "模型 ID 不能为空", status: 400 };
|
||||
const externalId = request.externalId.trim();
|
||||
if (!externalId) return { error: "模型 ID 不能为空", status: 400 };
|
||||
|
||||
const capabilities = request.capabilities;
|
||||
if (!capabilities || capabilities.length === 0) {
|
||||
return { error: "至少选择一个能力标签", status: 400 };
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const duplicate = db
|
||||
.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 };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = timestamp();
|
||||
|
||||
try {
|
||||
db.insert(models)
|
||||
.values({
|
||||
capabilities: JSON.stringify(capabilities),
|
||||
contextLength: request.contextLength ?? null,
|
||||
createdAt: now,
|
||||
externalId,
|
||||
id,
|
||||
maxOutputTokens: request.maxOutputTokens ?? null,
|
||||
modelId,
|
||||
name,
|
||||
providerId: request.providerId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.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: "create", table: "models" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
|
||||
const row = db.select().from(models).where(eq(models.id, id)).get();
|
||||
return { model: toModel(row!) };
|
||||
@@ -65,16 +67,24 @@ export function deleteModel(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
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 };
|
||||
|
||||
db.delete(models).where(eq(models.id, id)).run();
|
||||
softDeleteRecord(db, models, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
|
||||
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 };
|
||||
return { model: toModel(row) };
|
||||
@@ -85,7 +95,7 @@ export function getModelsByProviderId(raw: Database, providerId: string): number
|
||||
const result = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(models)
|
||||
.where(eq(models.providerId, providerId))
|
||||
.where(and(eq(models.providerId, providerId), isNull(models.deletedAt)))
|
||||
.get();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
@@ -96,20 +106,28 @@ export function getModelWithProvider(
|
||||
):
|
||||
| { 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 };
|
||||
} {
|
||||
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 };
|
||||
|
||||
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 };
|
||||
|
||||
return {
|
||||
model: {
|
||||
modelId: row.modelId,
|
||||
externalId: row.externalId,
|
||||
name: row.name,
|
||||
providerId: row.providerId,
|
||||
},
|
||||
@@ -124,7 +142,15 @@ export function getModelWithProvider(
|
||||
|
||||
export function listModels(
|
||||
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 } {
|
||||
const conditions = [];
|
||||
|
||||
@@ -134,15 +160,22 @@ export function listModels(
|
||||
|
||||
if (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, {
|
||||
conditions,
|
||||
mapRow: toModel,
|
||||
orderBy: () => desc(models.createdAt),
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: models.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,14 +183,18 @@ export function updateModel(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateModelRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { model: Model } {
|
||||
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 };
|
||||
|
||||
const updates: Partial<typeof models.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
const name = request.name?.trim();
|
||||
@@ -166,14 +203,32 @@ export function updateModel(
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
const modelId = request.modelId?.trim();
|
||||
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 };
|
||||
if (modelId !== undefined) {
|
||||
updates.modelId = modelId;
|
||||
const externalId = request.externalId?.trim();
|
||||
if (externalId === "") return { error: "模型 ID 不能为空", status: 400 };
|
||||
if (externalId !== undefined) {
|
||||
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) {
|
||||
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 };
|
||||
updates.providerId = request.providerId;
|
||||
}
|
||||
@@ -197,29 +252,31 @@ export function updateModel(
|
||||
return { model: toModel(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
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();
|
||||
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 {
|
||||
return {
|
||||
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
|
||||
contextLength: row.contextLength,
|
||||
createdAt: row.createdAt,
|
||||
externalId: row.externalId,
|
||||
id: row.id,
|
||||
maxOutputTokens: row.maxOutputTokens,
|
||||
modelId: row.modelId,
|
||||
name: row.name,
|
||||
providerId: row.providerId,
|
||||
updatedAt: row.updatedAt,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 { paginateQuery, wrap } from "./connection";
|
||||
import { projects } from "./schema";
|
||||
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||
import { conversations, materials, messages, projects } from "./schema";
|
||||
|
||||
export function archiveProject(
|
||||
raw: Database,
|
||||
@@ -14,12 +14,16 @@ export function archiveProject(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
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.status === "archived") return { error: "项目已归档", status: 409 };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
const now = timestamp();
|
||||
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();
|
||||
return { project: toProject(updated!) };
|
||||
@@ -28,21 +32,26 @@ export function archiveProject(
|
||||
export function createProject(
|
||||
raw: Database,
|
||||
request: CreateProjectRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
if (!name) return { error: "项目名称不能为空", 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 id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
try {
|
||||
db.insert(projects)
|
||||
.values({
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
@@ -51,14 +60,6 @@ export function createProject(
|
||||
updatedAt: now,
|
||||
})
|
||||
.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();
|
||||
return { project: toProject(row!) };
|
||||
@@ -70,17 +71,53 @@ export function deleteProject(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
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.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 };
|
||||
}
|
||||
|
||||
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
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 };
|
||||
return { project: toProject(row) };
|
||||
@@ -88,7 +125,14 @@ export function getProject(raw: Database, id: string): { error: string; status:
|
||||
|
||||
export function listProjects(
|
||||
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 } {
|
||||
const conditions = [];
|
||||
|
||||
@@ -101,12 +145,15 @@ export function listProjects(
|
||||
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
||||
}
|
||||
|
||||
const orderByFn = buildProjectOrderBy(options.sortBy, options.sortOrder);
|
||||
|
||||
return paginateQuery(raw, projects, {
|
||||
conditions,
|
||||
mapRow: toProject,
|
||||
orderBy: () => desc(projects.createdAt),
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: projects.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,12 +163,16 @@ export function restoreProject(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
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.status === "active") return { error: "项目已是活跃状态", status: 409 };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
const now = timestamp();
|
||||
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();
|
||||
return { project: toProject(updated!) };
|
||||
@@ -131,10 +182,14 @@ export function updateProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProjectRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
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.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
|
||||
|
||||
@@ -143,10 +198,16 @@ export function updateProject(
|
||||
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
|
||||
|
||||
const updates: Partial<typeof projects.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -159,24 +220,25 @@ export function updateProject(
|
||||
return { project: toProject(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
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();
|
||||
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 {
|
||||
return {
|
||||
archivedAt: row.archivedAt,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
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 { paginateQuery, wrap } from "./connection";
|
||||
import { providers } from "./schema";
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { models, providers } from "./schema";
|
||||
|
||||
export function createProvider(
|
||||
raw: Database,
|
||||
request: CreateProviderRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { provider: Provider } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
@@ -23,10 +29,16 @@ export function createProvider(
|
||||
const apiKey = request.apiKey.trim();
|
||||
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const duplicate = db
|
||||
.select({ id: providers.id })
|
||||
.from(providers)
|
||||
.where(and(eq(providers.name, name), notDeleted(providers)))
|
||||
.get();
|
||||
if (duplicate) return { error: "供应商名称已存在", status: 409 };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = timestamp();
|
||||
|
||||
try {
|
||||
db.insert(providers)
|
||||
.values({
|
||||
apiKey,
|
||||
@@ -38,14 +50,6 @@ export function createProvider(
|
||||
updatedAt: now,
|
||||
})
|
||||
.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: "providers" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
|
||||
const row = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||
return { provider: toProvider(row!) };
|
||||
@@ -57,16 +61,31 @@ export function deleteProvider(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
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 };
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
|
||||
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 };
|
||||
return { provider: toProvider(row) };
|
||||
@@ -77,6 +96,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
|
||||
const rows = db
|
||||
.select({ id: providers.id, name: providers.name, type: providers.type })
|
||||
.from(providers)
|
||||
.where(notDeleted(providers))
|
||||
.orderBy(desc(providers.createdAt))
|
||||
.all();
|
||||
|
||||
@@ -85,7 +105,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
|
||||
|
||||
export function listProviders(
|
||||
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 } {
|
||||
const conditions = [];
|
||||
|
||||
@@ -94,12 +114,19 @@ export function listProviders(
|
||||
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, {
|
||||
conditions,
|
||||
mapRow: toProvider,
|
||||
orderBy: () => desc(providers.createdAt),
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: providers.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,19 +134,29 @@ export function updateProvider(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProviderRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { provider: Provider } {
|
||||
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 };
|
||||
|
||||
const updates: Partial<typeof providers.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
const name = request.name?.trim();
|
||||
if (name === "") return { error: "供应商名称不能为空", status: 400 };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -143,21 +180,23 @@ export function updateProvider(
|
||||
return { provider: toProvider(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
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();
|
||||
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 {
|
||||
return {
|
||||
apiKey: row.apiKey,
|
||||
|
||||
@@ -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", {
|
||||
archivedAt: text("archived_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
...baseColumns,
|
||||
description: text("description").notNull().default(""),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
status: text("status", { enum: ["active", "archived"] })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const providers = sqliteTable("providers", {
|
||||
...baseColumns,
|
||||
apiKey: text("api_key").notNull(),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
|
||||
.notNull()
|
||||
.default("openai-compatible"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const models = sqliteTable(
|
||||
"models",
|
||||
{
|
||||
...baseColumns,
|
||||
capabilities: text("capabilities").notNull(),
|
||||
contextLength: integer("context_length"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
externalId: text("external_id").notNull(),
|
||||
maxOutputTokens: integer("max_output_tokens"),
|
||||
modelId: text("model_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
providerId: text("provider_id")
|
||||
.notNull()
|
||||
.references(() => providers.id),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId),
|
||||
index("models_provider_id_idx").on(table.providerId),
|
||||
],
|
||||
(table) => [index("models_provider_id_idx").on(table.providerId)],
|
||||
);
|
||||
|
||||
export const conversations = sqliteTable(
|
||||
"conversations",
|
||||
{
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
modelId: text("model_id")
|
||||
.notNull()
|
||||
.references(() => models.id),
|
||||
...baseColumns,
|
||||
modelId: text("model_id").references(() => models.id),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
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(
|
||||
"materials",
|
||||
{
|
||||
...baseColumns,
|
||||
associatedDate: text("associated_date").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
description: text("description").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [index("materials_project_id_idx").on(table.projectId)],
|
||||
);
|
||||
@@ -83,12 +70,11 @@ export const materials = sqliteTable(
|
||||
export const messages = sqliteTable(
|
||||
"messages",
|
||||
{
|
||||
...baseColumns,
|
||||
content: text("content").notNull().default(""),
|
||||
conversationId: text("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
.references(() => conversations.id),
|
||||
parts: text("parts"),
|
||||
role: text("role", { enum: ["assistant", "system", "user"] }).notNull(),
|
||||
},
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export type { ParsedListParams } from "./list-params";
|
||||
export { parseListParams } from "./list-params";
|
||||
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
|
||||
export { parseIdFromUrl } from "./url";
|
||||
|
||||
59
src/server/helpers/list-params.ts
Normal file
59
src/server/helpers/list-params.ts
Normal 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 };
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
updateConversation,
|
||||
updateConversationTimestamp,
|
||||
} from "../../db/conversations";
|
||||
import { getModelWithProvider } from "../../db/models";
|
||||
import { getModelWithProvider, listModels } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
@@ -79,13 +79,23 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
|
||||
|
||||
let model;
|
||||
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) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
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) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function handleCreateModel(
|
||||
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.modelId || typeof body.modelId !== "string") {
|
||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
||||
if (!body.externalId || typeof body.externalId !== "string") {
|
||||
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.providerId || typeof body.providerId !== "string") {
|
||||
|
||||
@@ -4,24 +4,26 @@ import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { listModels } from "../../db/models";
|
||||
import { jsonResponse } from "../../helpers";
|
||||
import { validatePagination } from "../../middleware";
|
||||
import { jsonResponse, parseListParams } from "../../helpers";
|
||||
|
||||
const ALLOWED_SORT_BY = ["createdAt", "name"];
|
||||
|
||||
export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const keyword = url.searchParams.get("keyword");
|
||||
const providerId = url.searchParams.get("providerId");
|
||||
const capabilities = url.searchParams.get("capabilities");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
|
||||
if (parsed instanceof Response) return parsed;
|
||||
|
||||
const result = listModels(db, {
|
||||
keyword: keyword ?? undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
capabilities: capabilities ?? undefined,
|
||||
keyword: parsed.keyword,
|
||||
page: parsed.page,
|
||||
pageSize: parsed.pageSize,
|
||||
providerId: providerId ?? undefined,
|
||||
sortBy: parsed.sortBy,
|
||||
sortOrder: parsed.sortOrder,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function handleTestModelConfig(
|
||||
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.modelId || typeof body.modelId !== "string") {
|
||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
||||
if (!body.externalId || typeof body.externalId !== "string") {
|
||||
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const providerResult = getProvider(db, body.providerId);
|
||||
@@ -41,7 +41,7 @@ export async function handleTestModelConfig(
|
||||
{
|
||||
apiKey: providerResult.provider.apiKey,
|
||||
baseUrl: providerResult.provider.baseUrl,
|
||||
modelId: body.modelId,
|
||||
modelId: body.externalId,
|
||||
name: providerResult.provider.name,
|
||||
type: providerResult.provider.type,
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export async function handleTestModelConfig(
|
||||
|
||||
if (!testResult.ok) {
|
||||
logger.warn(
|
||||
{ message: testResult.message, modelId: body.modelId, providerId: body.providerId },
|
||||
{ externalId: body.externalId, message: testResult.message, providerId: body.providerId },
|
||||
"模型连接测试失败",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,27 +4,27 @@ import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { listProjects } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validatePagination } from "../../middleware";
|
||||
import { createApiError, jsonResponse, parseListParams } from "../../helpers";
|
||||
|
||||
const ALLOWED_SORT_BY = ["createdAt", "name", "updatedAt"];
|
||||
|
||||
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const keyword = url.searchParams.get("keyword");
|
||||
const statusParam = url.searchParams.get("status");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
|
||||
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, {
|
||||
keyword: keyword ?? undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: parsed.keyword,
|
||||
page: parsed.page,
|
||||
pageSize: parsed.pageSize,
|
||||
sortBy: parsed.sortBy,
|
||||
sortOrder: parsed.sortOrder,
|
||||
status: (statusParam as "active" | "archived") ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,22 +4,24 @@ import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { listProviders } from "../../db/providers";
|
||||
import { jsonResponse } from "../../helpers";
|
||||
import { validatePagination } from "../../middleware";
|
||||
import { jsonResponse, parseListParams } from "../../helpers";
|
||||
|
||||
const ALLOWED_SORT_BY = ["createdAt", "name"];
|
||||
|
||||
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const keyword = url.searchParams.get("keyword");
|
||||
const typeParam = url.searchParams.get("type");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
|
||||
if (parsed instanceof Response) return parsed;
|
||||
|
||||
const result = listProviders(db, {
|
||||
keyword: keyword ?? undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: parsed.keyword,
|
||||
page: parsed.page,
|
||||
pageSize: parsed.pageSize,
|
||||
sortBy: parsed.sortBy,
|
||||
sortOrder: parsed.sortOrder,
|
||||
type: typeParam ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ApiErrorResponse {
|
||||
export interface Conversation {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
modelId: string;
|
||||
modelId: null | string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
@@ -36,8 +36,8 @@ export interface CreateMaterialRequest {
|
||||
export interface CreateModelRequest {
|
||||
capabilities: ModelCapability[];
|
||||
contextLength?: null | number;
|
||||
externalId: string;
|
||||
maxOutputTokens?: null | number;
|
||||
modelId: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
}
|
||||
@@ -59,6 +59,11 @@ export interface CreateProviderRequest {
|
||||
// 前后端共享的类型都放在这个文件中
|
||||
// ==========================================
|
||||
|
||||
export interface ListSortParams {
|
||||
sortBy?: string;
|
||||
sortOrder?: SortOrder;
|
||||
}
|
||||
|
||||
export interface Material {
|
||||
associatedDate: string;
|
||||
createdAt: string;
|
||||
@@ -89,6 +94,7 @@ export interface Message {
|
||||
id: string;
|
||||
parts: null | string;
|
||||
role: "assistant" | "system" | "user";
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
@@ -109,9 +115,9 @@ export interface Model {
|
||||
capabilities: ModelCapability[];
|
||||
contextLength: null | number;
|
||||
createdAt: string;
|
||||
externalId: string;
|
||||
id: string;
|
||||
maxOutputTokens: null | number;
|
||||
modelId: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
updatedAt: string;
|
||||
@@ -127,6 +133,8 @@ export type ModelCapability =
|
||||
| "video-generation"
|
||||
| "video-recognition";
|
||||
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export interface UpdateConversationRequest {
|
||||
modelId?: string;
|
||||
title?: string;
|
||||
@@ -164,7 +172,6 @@ export interface ModelTestResultResponse {
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
archivedAt: null | string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
@@ -231,15 +238,15 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
export interface TestModelRequest {
|
||||
modelId: string;
|
||||
externalId: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface UpdateModelRequest {
|
||||
capabilities?: ModelCapability[];
|
||||
contextLength?: null | number;
|
||||
externalId?: string;
|
||||
maxOutputTokens?: null | number;
|
||||
modelId?: string;
|
||||
name?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { DeleteOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Conversations } from "@ant-design/x";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Spin } from "antd";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Conversation } from "../../../shared/api";
|
||||
|
||||
import { createConversation, deleteConversation, fetchConversations } from "../../shared/hooks/use-conversations";
|
||||
import { createConversation, deleteConversation } from "../../shared/hooks/use-conversations";
|
||||
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
||||
import { useModelList } from "../../shared/hooks/use-models";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { ConversationSidebar } from "./components/ConversationSidebar";
|
||||
|
||||
export function ChatPage() {
|
||||
const project = useCurrentProject();
|
||||
@@ -19,11 +16,6 @@ export function ChatPage() {
|
||||
|
||||
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 textModels = useMemo(
|
||||
@@ -44,19 +36,7 @@ export function ChatPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const conversations = useMemo(
|
||||
() => (data?.items ?? []).map((c: Conversation) => ({ key: c.id, label: c.title })),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-chat-page">
|
||||
<div className="app-chat-conversations">
|
||||
<div className="app-chat-conversations-header">
|
||||
<Button
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
const handleAddConversation = () => {
|
||||
void createConversation(project.id, defaultModelId ?? undefined)
|
||||
.then((conv) => {
|
||||
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
|
||||
@@ -65,37 +45,17 @@ export function ChatPage() {
|
||||
.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"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-chat-page">
|
||||
<ConversationSidebar
|
||||
onAddClick={handleAddConversation}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
onSelect={setActiveConversationId}
|
||||
projectId={project.id}
|
||||
selectedId={activeConversationId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChatPanel
|
||||
conversationId={activeConversationId}
|
||||
defaultModelId={defaultModelId}
|
||||
|
||||
45
src/web/features/chat/components/ConversationCard.tsx
Normal file
45
src/web/features/chat/components/ConversationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/web/features/chat/components/ConversationList.tsx
Normal file
94
src/web/features/chat/components/ConversationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/web/features/chat/components/ConversationSidebar.tsx
Normal file
51
src/web/features/chat/components/ConversationSidebar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Typography } from "antd";
|
||||
import Markdown from "markdown-to-jsx/react";
|
||||
import { Markdown } from "markdown-to-jsx/react";
|
||||
|
||||
import type { PartProps } from "./types";
|
||||
|
||||
|
||||
@@ -10,11 +10,6 @@ interface MaterialCardProps {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
function formatAssociatedDate(date: string): string {
|
||||
if (!date) return "—";
|
||||
return date;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
||||
approved: { color: "green", label: "已通过" },
|
||||
discarded: { color: "red", label: "已放弃" },
|
||||
@@ -27,15 +22,13 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
||||
|
||||
return (
|
||||
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text ellipsis strong={selected}>
|
||||
<Typography.Paragraph
|
||||
className="material-item-desc"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ flex: 1, margin: 0, minWidth: 0 }}
|
||||
>
|
||||
{material.description}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text className="material-item-time" type="secondary">
|
||||
{formatAssociatedDate(material.associatedDate)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Typography.Paragraph>
|
||||
<div className="material-item-right">
|
||||
<span className="material-item-tag">
|
||||
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Empty, Segmented, Skeleton } from "antd";
|
||||
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
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 { MaterialGroup } from "./MaterialGroup";
|
||||
|
||||
type DateGroup = "earlier" | "thisMonth" | "thisWeek" | "today" | "yesterday";
|
||||
|
||||
interface MaterialListProps {
|
||||
loading: boolean;
|
||||
@@ -24,55 +26,6 @@ interface MaterialListProps {
|
||||
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 = [
|
||||
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
||||
{ 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) {
|
||||
const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
|
||||
const isDark = useIsDark();
|
||||
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (filterStatus === "all") return materials;
|
||||
return materials.filter((m) => m.status === filterStatus);
|
||||
}, [materials, filterStatus]);
|
||||
|
||||
const groupedMaterials = useMemo(() => groupMaterialsByDate(filteredMaterials), [filteredMaterials]);
|
||||
const groupedMaterials = useMemo(() => groupByDate(filteredMaterials, "createdAt"), [filteredMaterials]);
|
||||
|
||||
const segmentedOptions = useMemo(
|
||||
() =>
|
||||
@@ -106,15 +60,24 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
||||
[materials],
|
||||
);
|
||||
|
||||
const showAllGroups = filterStatus === "all";
|
||||
|
||||
return (
|
||||
<div className="app-inbox-sidebar">
|
||||
<div className="app-sidebar-list" style={{ width: 260 }}>
|
||||
<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} />
|
||||
<div className="app-inbox-list">
|
||||
</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} />
|
||||
) : materials.length === 0 ? (
|
||||
@@ -123,14 +86,9 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
||||
<Empty description="当前筛选条件下无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
groupedMaterials.map((group) => {
|
||||
if (!showAllGroups && group.items.length === 0) return null;
|
||||
if (group.items.length === 0) return null;
|
||||
return (
|
||||
<MaterialGroup
|
||||
count={group.items.length}
|
||||
emptyText="暂无"
|
||||
key={group.key}
|
||||
label={GROUP_LABELS[group.key]}
|
||||
>
|
||||
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
|
||||
{group.items.map((material) => (
|
||||
<MaterialCard
|
||||
key={material.id}
|
||||
@@ -140,11 +98,11 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
|
||||
selected={material.id === selectedId}
|
||||
/>
|
||||
))}
|
||||
</MaterialGroup>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
189
src/web/features/models/ModelListPage.tsx
Normal file
189
src/web/features/models/ModelListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/web/features/models/ProviderListPage.tsx
Normal file
157
src/web/features/models/ProviderListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
interface FormValues {
|
||||
capabilities: ModelCapability[];
|
||||
contextLength: null | number;
|
||||
externalId: string;
|
||||
maxOutputTokens: null | number;
|
||||
modelId: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
}
|
||||
@@ -70,8 +70,8 @@ export function ModelFormModal({
|
||||
form.setFieldsValue({
|
||||
capabilities: editingModel.capabilities,
|
||||
contextLength: editingModel.contextLength,
|
||||
externalId: editingModel.externalId,
|
||||
maxOutputTokens: editingModel.maxOutputTokens,
|
||||
modelId: editingModel.modelId,
|
||||
name: editingModel.name,
|
||||
providerId: editingModel.providerId,
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function ModelFormModal({
|
||||
if (editingModel) {
|
||||
const reqData: UpdateModelRequest = {};
|
||||
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;
|
||||
const capsChanged =
|
||||
values.capabilities.length !== editingModel.capabilities.length ||
|
||||
@@ -100,8 +100,8 @@ export function ModelFormModal({
|
||||
const reqData: CreateModelRequest = {
|
||||
capabilities: values.capabilities,
|
||||
contextLength: values.contextLength ?? undefined,
|
||||
externalId: values.externalId,
|
||||
maxOutputTokens: values.maxOutputTokens ?? undefined,
|
||||
modelId: values.modelId,
|
||||
name: values.name,
|
||||
providerId: values.providerId,
|
||||
};
|
||||
@@ -119,18 +119,18 @@ export function ModelFormModal({
|
||||
const handleTest = async () => {
|
||||
if (!testModelConnection) return;
|
||||
const providerId: unknown = form.getFieldValue("providerId");
|
||||
const modelId: unknown = form.getFieldValue("modelId");
|
||||
const externalId: unknown = form.getFieldValue("externalId");
|
||||
if (typeof providerId !== "string" || !providerId) {
|
||||
message.warning("请先选择供应商");
|
||||
return;
|
||||
}
|
||||
if (typeof modelId !== "string" || !modelId) {
|
||||
if (typeof externalId !== "string" || !externalId) {
|
||||
message.warning("请先输入模型 ID");
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testModelConnection({ modelId, providerId });
|
||||
const result = await testModelConnection({ externalId, providerId });
|
||||
if (result.ok) {
|
||||
message.success(result.message);
|
||||
} else {
|
||||
@@ -177,7 +177,7 @@ export function ModelFormModal({
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="模型 ID"
|
||||
name="modelId"
|
||||
name="externalId"
|
||||
rules={[{ message: "请输入模型 ID", required: true, whitespace: true }]}
|
||||
>
|
||||
<Input placeholder="gpt-4o, claude-3-opus-20240229, deepseek-chat 等" />
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import type { TableColumnsType } from "antd";
|
||||
import type { TableColumnsType, TableProps } from "antd";
|
||||
|
||||
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api";
|
||||
|
||||
import { formatDatetime } from "../../../shared/utils/format";
|
||||
|
||||
interface ModelTableProps {
|
||||
data: ModelListResponse | undefined;
|
||||
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;
|
||||
onPageChange: (page: number, pageSize: number) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
providers: ProviderOption[];
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
@@ -28,15 +35,33 @@ const CAPABILITY_LABELS: Record<string, string> = {
|
||||
"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 },
|
||||
export function ModelTable({
|
||||
data,
|
||||
loading,
|
||||
onChange,
|
||||
onDelete,
|
||||
onEdit,
|
||||
page,
|
||||
pageSize,
|
||||
providers,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: ModelTableProps) {
|
||||
const columns = useMemo<TableColumnsType<Model>>(
|
||||
() => [
|
||||
{
|
||||
dataIndex: "name",
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "名称",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: "providerId",
|
||||
ellipsis: true,
|
||||
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
|
||||
title: "供应商",
|
||||
width: 120,
|
||||
},
|
||||
@@ -52,47 +77,17 @@ const COLUMNS: TableColumnsType<Model> = [
|
||||
) : null,
|
||||
title: "能力",
|
||||
},
|
||||
];
|
||||
|
||||
export function ModelTable({
|
||||
data,
|
||||
loading,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onPageChange,
|
||||
page,
|
||||
pageSize,
|
||||
providers,
|
||||
}: ModelTableProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await onDelete(id);
|
||||
message.success("模型已删除");
|
||||
} catch (err: unknown) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
{
|
||||
align: "center",
|
||||
dataIndex: "createdAt",
|
||||
render: (_value: unknown, record: Model) => formatDatetime(record.createdAt),
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "创建时间",
|
||||
width: 180,
|
||||
},
|
||||
[onDelete, message],
|
||||
);
|
||||
|
||||
const columnsWithProvider = useMemo<TableColumnsType<Model>>(
|
||||
() =>
|
||||
COLUMNS.map((col) =>
|
||||
"dataIndex" in col && col.dataIndex === "providerId"
|
||||
? {
|
||||
...col,
|
||||
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
|
||||
}
|
||||
: col,
|
||||
),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const operationColumn = useMemo<TableColumnsType<Model>[number]>(
|
||||
() => ({
|
||||
{
|
||||
dataIndex: "op",
|
||||
render: (_value: unknown, record: Model) => (
|
||||
<Space size="small">
|
||||
@@ -101,7 +96,7 @@ export function ModelTable({
|
||||
</Button>
|
||||
<Popconfirm
|
||||
description="此操作不可恢复。"
|
||||
onConfirm={() => void handleDelete(record.id)}
|
||||
onConfirm={() => void onDelete(record.id)}
|
||||
title="确认删除此模型?"
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||
@@ -112,26 +107,39 @@ export function ModelTable({
|
||||
),
|
||||
title: "操作",
|
||||
width: 180,
|
||||
}),
|
||||
[onEdit, handleDelete],
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
locale={{ emptyText: "暂无模型数据" }}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: page,
|
||||
hideOnSinglePage: false,
|
||||
onChange: onPageChange,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
total: data?.total ?? 0,
|
||||
}}
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderName(providerId: string, providers: ProviderOption[]): string {
|
||||
return providers.find((p) => p.id === providerId)?.name ?? providerId;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,75 @@
|
||||
import type { TableColumnsType } from "antd";
|
||||
import type { TableColumnsType, TableProps } from "antd";
|
||||
|
||||
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, Button, Popconfirm, Space, Table } from "antd";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Popconfirm, Space, Table } from "antd";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Provider, ProviderListResponse } from "../../../../shared/api";
|
||||
|
||||
import { formatDatetime } from "../../../shared/utils/format";
|
||||
|
||||
interface ProviderTableProps {
|
||||
data: ProviderListResponse | undefined;
|
||||
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;
|
||||
onPageChange: (page: number, pageSize: number) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<Provider["type"], string> = {
|
||||
export function ProviderTable({
|
||||
data,
|
||||
loading,
|
||||
onChange,
|
||||
onDelete,
|
||||
onEdit,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: ProviderTableProps) {
|
||||
const columns = useMemo<TableColumnsType<Provider>>(
|
||||
() => [
|
||||
{
|
||||
dataIndex: "name",
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "名称",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: "type",
|
||||
render: (_value: unknown, record: Provider) => {
|
||||
const labels: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
"openai-compatible": "OpenAI 兼容",
|
||||
};
|
||||
|
||||
const COLUMNS: TableColumnsType<Provider> = [
|
||||
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
|
||||
{
|
||||
dataIndex: "type",
|
||||
render: (value: Provider["type"]) => TYPE_LABELS[value] ?? value,
|
||||
};
|
||||
return labels[record.type] ?? record.type;
|
||||
},
|
||||
title: "类型",
|
||||
width: 140,
|
||||
},
|
||||
{ dataIndex: "baseUrl", ellipsis: true, title: "Base URL" },
|
||||
];
|
||||
|
||||
export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await onDelete(id);
|
||||
message.success("供应商已删除");
|
||||
} catch (err: unknown) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
{
|
||||
align: "center",
|
||||
dataIndex: "createdAt",
|
||||
render: (_value: unknown, record: Provider) => formatDatetime(record.createdAt),
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "创建时间",
|
||||
width: 180,
|
||||
},
|
||||
[onDelete, message],
|
||||
);
|
||||
|
||||
const operationColumn = useMemo<TableColumnsType<Provider>[number]>(
|
||||
() => ({
|
||||
{
|
||||
dataIndex: "op",
|
||||
render: (_value: unknown, record: Provider) => (
|
||||
<Space size="small">
|
||||
@@ -57,8 +77,8 @@ export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, p
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
description="该供应商下存在模型时无法删除,请先删除或迁移相关模型。"
|
||||
onConfirm={() => void handleDelete(record.id)}
|
||||
description="删除后关联的模型将无法使用。"
|
||||
onConfirm={() => void onDelete(record.id)}
|
||||
title="确认删除此供应商?"
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||
@@ -69,23 +89,32 @@ export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, p
|
||||
),
|
||||
title: "操作",
|
||||
width: 180,
|
||||
}),
|
||||
[onEdit, handleDelete],
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
locale={{ emptyText: "暂无供应商数据" }}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: page,
|
||||
hideOnSinglePage: false,
|
||||
onChange: onPageChange,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
total: data?.total ?? 0,
|
||||
}}
|
||||
rowKey="id"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,62 @@
|
||||
import type { TableColumnsType } from "antd";
|
||||
import type { TableColumnsType, TableProps } from "antd";
|
||||
|
||||
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||
import { useMemo } from "react";
|
||||
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 {
|
||||
data: ProjectListResponse | undefined;
|
||||
loading: boolean;
|
||||
onArchive: (id: string) => Promise<unknown>;
|
||||
onDelete: (id: string) => Promise<unknown>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onChange: (
|
||||
pagination: { current?: number; pageSize?: number },
|
||||
sorter: { columnKey?: string; field?: string | string[]; order?: string },
|
||||
) => void;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onEdit: (project: Project) => void;
|
||||
onPageChange: (page: number, pageSize: number) => void;
|
||||
onRestore: (id: string) => Promise<unknown>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: ProjectStatus;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumnsType<Project> = [
|
||||
{ dataIndex: "name", ellipsis: true, title: "名称", width: 140 },
|
||||
export function ProjectTable({
|
||||
data,
|
||||
loading,
|
||||
onArchive,
|
||||
onChange,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onRestore,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: ProjectTableProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns = useMemo<TableColumnsType<Project>>(
|
||||
() => [
|
||||
{
|
||||
dataIndex: "name",
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "name" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "名称",
|
||||
width: 140,
|
||||
},
|
||||
{ dataIndex: "description", ellipsis: true, title: "描述" },
|
||||
{
|
||||
align: "center",
|
||||
dataIndex: "status",
|
||||
render: (_value, record: Project) => {
|
||||
render: (_value: unknown, record: Project) => {
|
||||
if (record.status === "archived") {
|
||||
return <Tag>已归档</Tag>;
|
||||
}
|
||||
@@ -38,74 +68,26 @@ const COLUMNS: TableColumnsType<Project> = [
|
||||
{
|
||||
align: "center",
|
||||
dataIndex: "createdAt",
|
||||
render: (_value, record: Project) => formatDatetime(record.createdAt),
|
||||
render: (_value: unknown, record: Project) => formatDatetime(record.createdAt),
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "createdAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "创建时间",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
dataIndex: "updatedAt",
|
||||
render: (_value, record: Project) => formatDatetime(record.updatedAt),
|
||||
render: (_value: unknown, record: Project) => formatDatetime(record.updatedAt),
|
||||
sorter: true,
|
||||
sortOrder:
|
||||
sortBy === "updatedAt" ? (sortOrder === "asc" ? "ascend" : sortOrder === "desc" ? "descend" : null) : null,
|
||||
title: "更新时间",
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
export function ProjectTable({
|
||||
data,
|
||||
loading,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onPageChange,
|
||||
onRestore,
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
}: ProjectTableProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await onArchive(id);
|
||||
message.success("项目已归档");
|
||||
} catch (err: unknown) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
},
|
||||
[onArchive, message],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await onRestore(id);
|
||||
message.success("项目已恢复");
|
||||
} catch (err: unknown) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
},
|
||||
[onRestore, message],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await onDelete(id);
|
||||
message.success("项目已永久删除");
|
||||
} catch (err: unknown) {
|
||||
message.error((err as Error).message);
|
||||
}
|
||||
},
|
||||
[onDelete, message],
|
||||
);
|
||||
|
||||
const operationColumn = useMemo<TableColumnsType<Project>[number]>(
|
||||
() => ({
|
||||
{
|
||||
dataIndex: "op",
|
||||
render: (_value, record: Project) => {
|
||||
render: (_value: unknown, record: Project) => {
|
||||
if (record.status === "active") {
|
||||
return (
|
||||
<Space size="small">
|
||||
@@ -122,7 +104,7 @@ export function ProjectTable({
|
||||
</Button>
|
||||
<Popconfirm
|
||||
description="归档后项目将变为只读。"
|
||||
onConfirm={() => void handleArchive(record.id)}
|
||||
onConfirm={() => void onArchive(record.id)}
|
||||
title="确认归档此项目?"
|
||||
>
|
||||
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link">
|
||||
@@ -134,14 +116,14 @@ export function ProjectTable({
|
||||
}
|
||||
return (
|
||||
<Space size="small">
|
||||
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
|
||||
<Popconfirm onConfirm={() => void onRestore(record.id)} title="确认恢复此项目?">
|
||||
<Button icon={<RedoOutlined />} size="small" type="link">
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
description="此操作不可恢复。"
|
||||
onConfirm={() => void handleDelete(record.id)}
|
||||
onConfirm={() => void onDelete(record.id)}
|
||||
title="确认永久删除此项目?"
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} size="small" type="link">
|
||||
@@ -152,33 +134,36 @@ export function ProjectTable({
|
||||
);
|
||||
},
|
||||
title: "操作",
|
||||
width: status === "active" ? 260 : 160,
|
||||
}),
|
||||
[navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
|
||||
width: 260,
|
||||
},
|
||||
],
|
||||
[navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder],
|
||||
);
|
||||
|
||||
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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
locale={{ emptyText: "暂无项目数据" }}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: page,
|
||||
hideOnSinglePage: false,
|
||||
onChange: onPageChange,
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
total: data?.total ?? 0,
|
||||
}}
|
||||
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())}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Space } from "antd";
|
||||
import { useState } from "react";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Space } from "antd";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { Project, ProjectStatus } from "../../../shared/api";
|
||||
|
||||
import { FilterToolbar } from "../../shared/components/FilterToolbar";
|
||||
import {
|
||||
useArchiveProject,
|
||||
useCreateProject,
|
||||
@@ -11,20 +13,40 @@ import {
|
||||
useRestoreProject,
|
||||
useUpdateProject,
|
||||
} from "../../shared/hooks/use-projects";
|
||||
import { useConfirmAction } from "../../shared/hooks/useConfirmAction";
|
||||
import { usePageSearchParams } from "../../shared/hooks/usePageSearchParams";
|
||||
import { ProjectFormModal } from "./components/ProjectFormModal";
|
||||
import { ProjectTable } from "./components/ProjectTable";
|
||||
import { ProjectToolbar } from "./components/ProjectToolbar";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "进行中", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
];
|
||||
|
||||
export function ProjectsPage() {
|
||||
const [tabValue, setTabValue] = useState<ProjectStatus>("active");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const { confirmAction } = useConfirmAction();
|
||||
const { params, resetAll, setParams } = usePageSearchParams({
|
||||
defaults: { page: "1", pageSize: "20" },
|
||||
});
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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 updateMutation = useUpdateProject();
|
||||
const archiveMutation = useArchiveProject();
|
||||
@@ -32,54 +54,111 @@ export function ProjectsPage() {
|
||||
const deleteMutation = useDeleteProject();
|
||||
|
||||
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 (
|
||||
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||
<ProjectToolbar
|
||||
activeTab={tabValue}
|
||||
keyword={keyword}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onSearchClear={() => {
|
||||
setKeyword("");
|
||||
setPage(1);
|
||||
}}
|
||||
onTabChange={(key) => {
|
||||
setTabValue(key as ProjectStatus);
|
||||
setPage(1);
|
||||
}}
|
||||
openCreateDialog={() => {
|
||||
<FilterToolbar
|
||||
actions={
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingProject(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
新建项目
|
||||
</Button>
|
||||
}
|
||||
filters={filters}
|
||||
search={{ keyword, onReset: handleReset, onSearch: handleSearch, placeholder: "搜索名称或描述" }}
|
||||
/>
|
||||
|
||||
<ProjectTable
|
||||
data={data}
|
||||
loading={isLoading || isRowActionPending}
|
||||
onArchive={(id) => archiveMutation.mutateAsync(id)}
|
||||
onDelete={(id) => deleteMutation.mutateAsync(id)}
|
||||
loading={isFetching}
|
||||
onArchive={handleArchive}
|
||||
onChange={handleTableChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(project) => {
|
||||
setEditingProject(project);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onPageChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
}}
|
||||
onRestore={(id) => restoreMutation.mutateAsync(id)}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
status={tabValue}
|
||||
onRestore={handleRestore}
|
||||
page={apiPage}
|
||||
pageSize={apiPageSize}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
|
||||
<ProjectFormModal
|
||||
editingProject={editingProject}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
onCreate={(data) => createMutation.mutateAsync(data)}
|
||||
onCreate={(formData) => createMutation.mutateAsync(formData)}
|
||||
onOpenChange={setDialogOpen}
|
||||
onUpdate={(args) => updateMutation.mutateAsync(args)}
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -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 type { MenuItemConfig } from "../../menu";
|
||||
@@ -6,5 +6,14 @@ import type { MenuItemConfig } from "../../menu";
|
||||
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
|
||||
{ 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;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: string;
|
||||
readonly children?: readonly MenuItemConfig[];
|
||||
readonly icon: ReactElement;
|
||||
readonly label: string;
|
||||
readonly path: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Route, Routes } from "react-router";
|
||||
import { ChatPage } from "./features/chat/ChatPage";
|
||||
import { DashboardPage } from "./features/dashboard";
|
||||
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 { ProjectsPage } from "./features/projects";
|
||||
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
|
||||
@@ -16,7 +17,8 @@ export function AppRoutes() {
|
||||
<Route element={<AdminConsoleLayout />} errorElement={<RouteError />}>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<ProjectsPage />} path="/projects" />
|
||||
<Route element={<ModelsPage />} path="/models" />
|
||||
<Route element={<ModelListPage />} path="/models" />
|
||||
<Route element={<ProviderListPage />} path="/models/providers" />
|
||||
</Route>
|
||||
<Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId">
|
||||
<Route element={<ChatPage />} path="" />
|
||||
|
||||
74
src/web/shared/components/FilterToolbar.tsx
Normal file
74
src/web/shared/components/FilterToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MenuProps } from "antd";
|
||||
|
||||
import { Menu } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import type { MenuItemConfig } from "../../../menu";
|
||||
@@ -14,23 +15,101 @@ interface SidebarProps {
|
||||
export function Sidebar({ menuItems }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = menuItems.find((item) => item.path === currentPath);
|
||||
const selectedKeys = currentItem ? [currentItem.value] : [];
|
||||
|
||||
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
|
||||
icon: item.icon,
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
const rootSubmenuKeys = useMemo(() => getRootSubmenuKeys(menuItems), [menuItems]);
|
||||
const antdMenuItems = useMemo(() => toAntdMenuItems(menuItems), [menuItems]);
|
||||
|
||||
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 item = menuItems.find((i) => i.value === key);
|
||||
const item = findByValue(menuItems, key);
|
||||
if (item) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
28
src/web/shared/components/SidebarGroup/index.tsx
Normal file
28
src/web/shared/components/SidebarGroup/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export async function fetchConversation(projectId: string, conversationId: strin
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,16 +37,22 @@ export async function fetchModel(id: string): Promise<Model> {
|
||||
}
|
||||
|
||||
export async function fetchModelList(params: {
|
||||
capabilities?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
providerId?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}): Promise<ModelListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||
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 url = `/api/models${qs ? `?${qs}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
@@ -85,7 +91,7 @@ export function useCreateModel() {
|
||||
return useMutation({
|
||||
mutationFn: createModel,
|
||||
onSuccess: (data) => {
|
||||
logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId });
|
||||
logger.info("模型创建成功", { externalId: data.externalId, providerId: data.providerId });
|
||||
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({
|
||||
queryFn: () => fetchModelList(params),
|
||||
queryKey: [...MODELS_KEY, "list", params],
|
||||
@@ -128,7 +142,7 @@ export function useUpdateModel() {
|
||||
return useMutation({
|
||||
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,12 +43,16 @@ export async function fetchProjectList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
status?: ProjectStatus;
|
||||
}): Promise<ProjectListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
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);
|
||||
const qs = searchParams.toString();
|
||||
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({
|
||||
queryFn: () => fetchProjectList(params),
|
||||
queryKey: [...PROJECTS_KEY, "list", params],
|
||||
|
||||
@@ -41,11 +41,17 @@ export async function fetchProviderList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
type?: string;
|
||||
}): Promise<ProviderListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
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 url = `/api/providers${qs ? `?${qs}` : ""}`;
|
||||
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({
|
||||
queryFn: () => fetchProviderList(params),
|
||||
queryKey: [...PROVIDERS_KEY, "list", params],
|
||||
|
||||
24
src/web/shared/hooks/useConfirmAction.ts
Normal file
24
src/web/shared/hooks/useConfirmAction.ts
Normal 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 };
|
||||
}
|
||||
74
src/web/shared/hooks/usePageSearchParams.ts
Normal file
74
src/web/shared/hooks/usePageSearchParams.ts
Normal 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 };
|
||||
}
|
||||
52
src/web/shared/utils/date-group.ts
Normal file
52
src/web/shared/utils/date-group.ts
Normal 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 }));
|
||||
}
|
||||
5
src/web/shared/utils/format.ts
Normal file
5
src/web/shared/utils/format.ts
Normal 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())}`;
|
||||
}
|
||||
@@ -3,6 +3,23 @@ export function formatCountdown(seconds: number): string {
|
||||
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 } {
|
||||
if (ms === null) return { suffix: "", value: 0 };
|
||||
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
|
||||
|
||||
@@ -52,7 +52,7 @@ body {
|
||||
|
||||
.app-content {
|
||||
overflow: auto;
|
||||
padding: var(--ant-padding-xl) var(--ant-padding-xl);
|
||||
padding: var(--ant-padding) var(--ant-padding-lg);
|
||||
}
|
||||
|
||||
.app-chat-page {
|
||||
@@ -81,25 +81,95 @@ body {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.app-chat-conversations {
|
||||
.app-sidebar-list {
|
||||
display: flex;
|
||||
width: 260px;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
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);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.app-chat-conversations-list {
|
||||
.app-sidebar-list-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -255,25 +325,6 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -290,28 +341,24 @@ body {
|
||||
|
||||
/* Inbox material list items */
|
||||
.material-list-item {
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
border: none;
|
||||
margin: var(--ant-margin-xxs) var(--ant-margin-xxs);
|
||||
padding: var(--ant-padding-xs) var(--ant-padding-sm);
|
||||
padding-left: var(--ant-padding-sm);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.material-list-item:last-child {
|
||||
border-bottom: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.material-list-item:hover {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
background: var(--ant-color-bg-text-hover);
|
||||
}
|
||||
|
||||
.material-list-item--selected {
|
||||
border-left-color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.material-list-item--selected:hover {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.material-item-right {
|
||||
@@ -346,56 +393,6 @@ body {
|
||||
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 {
|
||||
margin-left: 4px;
|
||||
font-size: var(--ant-font-size-sm);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Database from "bun:sqlite";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -105,7 +106,7 @@ export function createTestDatabase(prefix: string, migrations: MigrationRecord[]
|
||||
}
|
||||
|
||||
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 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -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 { createMigratedTestDatabase } from "../../helpers";
|
||||
|
||||
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" }),
|
||||
}));
|
||||
import "../mocks/ai";
|
||||
|
||||
async function withProviderServer(
|
||||
modelsResponse: Response,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -265,4 +265,27 @@ describe("bootstrap", () => {
|
||||
expect(flushed).toBe(true);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,17 +41,18 @@ describe("模型数据访问层", () => {
|
||||
db,
|
||||
{
|
||||
capabilities: ["text", "reasoning"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId,
|
||||
},
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in result).toBe(false);
|
||||
const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } })
|
||||
.model;
|
||||
const model = (
|
||||
result as { model: { capabilities: string[]; externalId: string; name: string; providerId: string } }
|
||||
).model;
|
||||
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.capabilities).toEqual(["text", "reasoning"]);
|
||||
});
|
||||
@@ -63,7 +64,7 @@ describe("模型数据访问层", () => {
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId: "test",
|
||||
externalId: "test",
|
||||
name: "Test",
|
||||
providerId: "nonexistent",
|
||||
},
|
||||
@@ -77,10 +78,10 @@ describe("模型数据访问层", () => {
|
||||
test("同一供应商下模型 ID 唯一", () => {
|
||||
withDb((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(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "Model2", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
@@ -94,12 +95,12 @@ describe("模型数据访问层", () => {
|
||||
const p2 = seedProvider(db, "P2");
|
||||
const r1 = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 },
|
||||
{ capabilities: ["text"], externalId: "same-id", name: "M1", providerId: p1 },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const r2 = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 },
|
||||
{ capabilities: ["text"], externalId: "same-id", name: "M2", providerId: p2 },
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in r1).toBe(false);
|
||||
@@ -112,7 +113,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const result = createModel(
|
||||
db,
|
||||
{ capabilities: [], modelId: "test", name: "Test", providerId },
|
||||
{ capabilities: [], externalId: "test", name: "Test", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
@@ -124,9 +125,9 @@ describe("模型数据访问层", () => {
|
||||
withDb((db) => {
|
||||
const p1 = seedProvider(db, "P1");
|
||||
const p2 = seedProvider(db, "P2");
|
||||
createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
|
||||
|
||||
const all = listModels(db, { page: 1, pageSize: 20 });
|
||||
expect(all.total).toBe(3);
|
||||
@@ -144,7 +145,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const created = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "GPT-4o", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const id = (created as { model: { id: string } }).model.id;
|
||||
@@ -168,7 +169,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const created = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "原名", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const id = (created as { model: { id: string } }).model.id;
|
||||
@@ -186,7 +187,7 @@ describe("模型数据访问层", () => {
|
||||
const providerId = seedProvider(db);
|
||||
const created = createModel(
|
||||
db,
|
||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId },
|
||||
{ capabilities: ["text"], externalId: "gpt-4o", name: "删除测试", providerId },
|
||||
createNoopLogger(),
|
||||
);
|
||||
const id = (created as { model: { id: string } }).model.id;
|
||||
@@ -203,9 +204,9 @@ describe("模型数据访问层", () => {
|
||||
withDb((db) => {
|
||||
const p1 = seedProvider(db, "P1");
|
||||
const p2 = seedProvider(db, "P2");
|
||||
createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
|
||||
createModel(db, { capabilities: ["text"], externalId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
|
||||
|
||||
expect(getModelsByProviderId(db, p1)).toBe(2);
|
||||
expect(getModelsByProviderId(db, p2)).toBe(1);
|
||||
@@ -220,8 +221,8 @@ describe("模型数据访问层", () => {
|
||||
{
|
||||
capabilities: ["text"],
|
||||
contextLength: 128000,
|
||||
externalId: "gpt-4o",
|
||||
maxOutputTokens: 4096,
|
||||
modelId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId,
|
||||
},
|
||||
|
||||
@@ -132,16 +132,13 @@ describe("项目数据访问层", () => {
|
||||
const result = archiveProject(db, id, createNoopLogger());
|
||||
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.archivedAt).not.toBeNull();
|
||||
|
||||
const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as {
|
||||
archived_at: null | string;
|
||||
const row = db.query("SELECT status FROM projects WHERE id = ?").get(id) as {
|
||||
status: string;
|
||||
};
|
||||
expect(row.status).toBe("archived");
|
||||
expect(row.archived_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +162,8 @@ describe("项目数据访问层", () => {
|
||||
const result = restoreProject(db, id, createNoopLogger());
|
||||
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.archivedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
154
tests/server/db/schema.test.ts
Normal file
154
tests/server/db/schema.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
241
tests/server/db/soft-delete.test.ts
Normal file
241
tests/server/db/soft-delete.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
134
tests/server/list-params.test.ts
Normal file
134
tests/server/list-params.test.ts
Normal 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
36
tests/server/mocks/ai.ts
Normal 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
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -9,45 +9,11 @@ import { createProject } from "../../../src/server/db/projects";
|
||||
import { createProvider } from "../../../src/server/db/providers";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||||
import "../mocks/ai";
|
||||
|
||||
const MODE: RuntimeMode = "test";
|
||||
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> {
|
||||
const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create");
|
||||
return h(req, db, MODE, LOG);
|
||||
@@ -78,12 +44,12 @@ async function patchConversationViaHandler(req: Request, db: Database): Promise<
|
||||
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(
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId,
|
||||
externalId,
|
||||
name: modelName,
|
||||
providerId,
|
||||
},
|
||||
@@ -145,7 +111,7 @@ describe("聊天 API 路由", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("无可用模型时返回 400", async () => {
|
||||
test("无可用模型时创建会话 modelId 为 null", async () => {
|
||||
const handle = createMigratedMemoryTestDatabase("chat-create-no-model");
|
||||
try {
|
||||
const db = handle.db;
|
||||
@@ -157,9 +123,9 @@ describe("聊天 API 路由", () => {
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createConversationViaHandler(req, db);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("模型");
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { conversation: Conversation };
|
||||
expect(body.conversation.modelId).toBeNull();
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { createNoopLogger } from "../../../src/server/logger";
|
||||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||||
import "../mocks/ai";
|
||||
|
||||
const MODE: RuntimeMode = "test";
|
||||
const LOG = createNoopLogger();
|
||||
@@ -21,7 +22,7 @@ function createTestModel(db: Database, pName: string, providerId?: string): Mode
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||
externalId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||
name: pName,
|
||||
providerId: pid,
|
||||
},
|
||||
@@ -49,13 +50,6 @@ async function listModelsViaHandler(req: Request, db: Database): Promise<Respons
|
||||
import { createModel } from "../../../src/server/db/models";
|
||||
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 {
|
||||
const result = createProvider(
|
||||
db,
|
||||
@@ -99,7 +93,7 @@ describe("models API routes", () => {
|
||||
const req = new Request("http://localhost/api/models", {
|
||||
body: JSON.stringify({
|
||||
capabilities: ["text", "reasoning"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId,
|
||||
}),
|
||||
@@ -110,7 +104,7 @@ describe("models API routes", () => {
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { model: Model };
|
||||
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 () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const p1 = seedProvider(db, "P1");
|
||||
@@ -192,7 +228,7 @@ describe("models API routes", () => {
|
||||
const req = new Request("http://localhost/api/models", {
|
||||
body: JSON.stringify({
|
||||
capabilities: ["invalid-cap"],
|
||||
modelId: "test",
|
||||
externalId: "test",
|
||||
name: "Test",
|
||||
providerId,
|
||||
}),
|
||||
@@ -212,7 +248,7 @@ describe("models API routes", () => {
|
||||
body: JSON.stringify({
|
||||
capabilities: ["text"],
|
||||
contextLength: 0,
|
||||
modelId: "test",
|
||||
externalId: "test",
|
||||
name: "Test",
|
||||
providerId,
|
||||
}),
|
||||
@@ -238,7 +274,7 @@ describe("models API routes", () => {
|
||||
const providerId = seedProvider(db);
|
||||
|
||||
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" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -253,7 +289,7 @@ describe("models API routes", () => {
|
||||
test("POST /api/models/test 缺少 providerId 返回 400", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
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" },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -265,7 +301,7 @@ describe("models API routes", () => {
|
||||
test("POST /api/models/test 不存在的供应商返回 404", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
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" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -8,16 +8,11 @@ import { createModel } from "../../../src/server/db/models";
|
||||
import { createProvider } from "../../../src/server/db/providers";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||||
import "../mocks/ai";
|
||||
|
||||
const MODE: RuntimeMode = "test";
|
||||
const LOG = createNoopLogger();
|
||||
|
||||
void mock.module("ai", () => ({
|
||||
createProviderRegistry: () => ({
|
||||
languageModel: () => ({}),
|
||||
}),
|
||||
}));
|
||||
|
||||
async function createProviderViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleCreateProvider: h } = await import("../../../src/server/routes/providers/create");
|
||||
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 () => {
|
||||
await withRouteDb(async (db) => {
|
||||
createTestProvider(db, "选项供应商");
|
||||
@@ -192,7 +236,7 @@ describe("供应商 API 路由", () => {
|
||||
db,
|
||||
{
|
||||
capabilities: ["text"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: provider.id,
|
||||
},
|
||||
|
||||
106
tests/web/FilterToolbar.test.tsx
Normal file
106
tests/web/FilterToolbar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
176
tests/web/components/ChatPage.test.tsx
Normal file
176
tests/web/components/ChatPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,9 @@ const TEXT_MODEL: Model = {
|
||||
capabilities: ["text"],
|
||||
contextLength: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
externalId: "gpt-4o",
|
||||
id: "model-1",
|
||||
maxOutputTokens: null,
|
||||
modelId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: "pv1",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
|
||||
95
tests/web/components/ConversationCard.test.tsx
Normal file
95
tests/web/components/ConversationCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
160
tests/web/components/ConversationList.test.tsx
Normal file
160
tests/web/components/ConversationList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/web/components/ConversationSidebar.test.tsx
Normal file
104
tests/web/components/ConversationSidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
176
tests/web/components/ResourceTable.test.tsx
Normal file
176
tests/web/components/ResourceTable.test.tsx
Normal 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));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-pro
|
||||
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_PROJECT: Project = {
|
||||
archivedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: "",
|
||||
id: "project-1",
|
||||
|
||||
@@ -18,7 +18,7 @@ const MOCK_MATERIAL: Material = {
|
||||
};
|
||||
|
||||
describe("MaterialCard", () => {
|
||||
test("渲染素材描述、时间和状态标签", () => {
|
||||
test("渲染素材描述和状态标签", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
@@ -28,7 +28,6 @@ describe("MaterialCard", () => {
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
||||
expect(screen.getByText("今天")).not.toBeNull();
|
||||
expect(screen.getByText("待审核")).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe("MaterialList", () => {
|
||||
expect(onAddClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("加载中显示 Spin", () => {
|
||||
test("加载中显示 Skeleton", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
loading: true,
|
||||
@@ -85,6 +85,6 @@ describe("MaterialList", () => {
|
||||
selectedId: null,
|
||||
}),
|
||||
);
|
||||
expect(document.querySelector(".ant-spin")).not.toBeNull();
|
||||
expect(document.querySelector(".ant-skeleton")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
25
tests/web/format.test.ts
Normal file
25
tests/web/format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,9 @@ const MODEL = {
|
||||
capabilities: ["text"] as Array<"text">,
|
||||
contextLength: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
externalId: "gpt-4o",
|
||||
id: "m1",
|
||||
maxOutputTokens: null,
|
||||
modelId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: "pv1",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
@@ -59,7 +59,7 @@ describe("use-models request helpers", () => {
|
||||
|
||||
await createModel({
|
||||
capabilities: ["text"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: "pv1",
|
||||
});
|
||||
@@ -75,7 +75,7 @@ describe("use-models request helpers", () => {
|
||||
]);
|
||||
expect(jsonBody(calls[0]?.body)).toEqual({
|
||||
capabilities: ["text"],
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: "pv1",
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe("use-models request helpers", () => {
|
||||
installFetchMock(() => jsonResponse({ error: "模型名称已存在" }, { status: 409 }));
|
||||
|
||||
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 () => {
|
||||
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.message).toBe("模型连接成功");
|
||||
expect(calls[0]?.method).toBe("POST");
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { installFetchMock, jsonResponse } from "../test-utils";
|
||||
|
||||
const PROJECT = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "描述",
|
||||
id: "p1",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { createElement } from "react";
|
||||
|
||||
import type { Model, Provider } from "../../../src/shared/api";
|
||||
|
||||
import { App } from "../../../src/web/app";
|
||||
import { ModelFormModal } from "../../../src/web/features/models/components/ModelFormModal";
|
||||
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||||
|
||||
const ENABLED_PROVIDER: Provider = {
|
||||
apiKey: "sk-test",
|
||||
@@ -32,9 +32,9 @@ const ENABLED_MODEL: Model = {
|
||||
capabilities: ["text", "reasoning"],
|
||||
contextLength: 128000,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
externalId: "gpt-4o",
|
||||
id: "m1",
|
||||
maxOutputTokens: 4096,
|
||||
modelId: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
providerId: "pv1",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
@@ -45,107 +45,6 @@ function clickLatestConfirmButton() {
|
||||
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", () => {
|
||||
test("编辑模型表单只提交变更字段", async () => {
|
||||
const updateCalls: unknown[] = [];
|
||||
@@ -291,7 +190,7 @@ describe("ModelFormModal", () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(testModelConnection).toHaveBeenCalledWith({
|
||||
modelId: "gpt-4o",
|
||||
externalId: "gpt-4o",
|
||||
providerId: "pv1",
|
||||
}),
|
||||
);
|
||||
@@ -317,3 +216,113 @@ describe("ModelFormModal", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ProjectTable } from "../../../src/web/features/projects/components/Proj
|
||||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||||
|
||||
const ACTIVE_PROJECT: Project = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "活跃描述",
|
||||
id: "p1",
|
||||
@@ -21,7 +20,6 @@ const ACTIVE_PROJECT: Project = {
|
||||
};
|
||||
|
||||
const ARCHIVED_PROJECT: Project = {
|
||||
archivedAt: "2024-01-02T00:00:00.000Z",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "归档描述",
|
||||
id: "p2",
|
||||
@@ -57,7 +55,6 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR
|
||||
if (url.pathname === "/api/projects" && call.method === "POST") {
|
||||
const data = jsonBody(call.body) as { description?: string; name: string };
|
||||
const created: Project = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-03T00:00:00.000Z",
|
||||
description: data.description ?? "",
|
||||
id: "p-created",
|
||||
@@ -83,13 +80,13 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR
|
||||
}
|
||||
|
||||
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));
|
||||
return jsonResponse({ project: archived });
|
||||
}
|
||||
|
||||
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));
|
||||
return jsonResponse({ project: restored });
|
||||
}
|
||||
@@ -114,7 +111,7 @@ function LocationProbe() {
|
||||
}
|
||||
|
||||
describe("ProjectsPage", () => {
|
||||
test("渲染项目管理入口并按状态请求项目列表", async () => {
|
||||
test("渲染项目管理入口并展示项目列表", async () => {
|
||||
const calls = createProjectFetchMock();
|
||||
|
||||
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.getByRole("button", { name: /新建项目/ })).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();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
|
||||
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));
|
||||
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());
|
||||
|
||||
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 () => {
|
||||
@@ -152,12 +156,19 @@ describe("ProjectsPage", () => {
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
|
||||
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));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -249,13 +260,12 @@ describe("ProjectsPage", () => {
|
||||
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
|
||||
loading: false,
|
||||
onArchive,
|
||||
onChange: () => undefined,
|
||||
onDelete,
|
||||
onEdit: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
onRestore,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
status: "active",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
223
tests/web/routes/providers.test.tsx
Normal file
223
tests/web/routes/providers.test.tsx
Normal 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
Reference in New Issue
Block a user