Compare commits

...

2 Commits

16 changed files with 607 additions and 130 deletions

View File

@@ -1,129 +0,0 @@
# add-model-management 当前进度
## 变更信息
- **Change**: `openspec/changes/add-model-management/`
- **Workflow**: fast-drive (design.md + tasks.md)
- **状态**: apply-review 修复进行中,有一个阻塞问题
## 已完成工作
### 核心功能54/54 tasks 全部标记 [x]
所有代码已实现并通过验证:
- DB Schema: providers + models 表(含 FK、唯一索引
- 数据访问层: `src/server/db/providers.ts`, `src/server/db/models.ts`
- 后端路由: 15 个路由providers 8 + models 7注册在 `src/server/server.ts`
- AI 服务层: `src/server/ai/registry.ts`(含 `testProviderConnection` + `buildProviderRegistry`, `src/server/ai/types.ts`
- 共享类型: `src/shared/api.ts`(含 `MODEL_CAPABILITIES` 常量)
- 前端 Hooks: `src/web/hooks/use-providers.ts`, `src/web/hooks/use-models.ts`
- 前端页面: `src/web/pages/models/`6 个组件 + index
- 前端路由+菜单: `src/web/routes.tsx`, `src/web/consoles/admin/menu.tsx`
- 测试: 10 个测试文件66 个测试用例)
- 文档: backend.md, frontend.md, architecture.md, README.md 已更新
### apply-review 修复(进行中)
已完成的修复:
-**registry.ts 补充 `buildProviderRegistry`**: 新增从 DB 查询启用供应商构建 AI SDK Provider Registry 的函数
-**Q1 统一错误响应**: `providers/get.ts``models/get.ts` 改用 `createApiError()`
-**Q2 提取共享常量**: `MODEL_CAPABILITIES``shared/api.ts` 导出,`models/create.ts``models/update.ts` 不再重复定义
-**Q3 清理重复测试**: `tests/web/hooks/use-models.test.ts` 移除了残留的 provider 相关测试
-**文档修正**: `docs/development/backend.md``buildProviderRegistry` 签名已更新为 `(db)` 而非 `(config)`
-**design.md + tasks.md 更新**: tasks 6.1 和 design 执行计划第 12 项已反映 registry 完整范围
## 阻塞问题
### `bun test` 无法解析 `createProviderRegistry`
**现象**: 运行 `bun test tests/server/ai/registry.test.ts` 时报错:
```
SyntaxError: Export named 'createProviderRegistry' not found in module '...\node_modules\ai\dist\index.mjs'
```
**已确认**:
- `createProviderRegistry``node_modules/ai/dist/index.mjs` 中存在
- `bun -e "import { createProviderRegistry } from 'ai'; ..."` 正常工作
- `bun run typecheck` 通过(类型存在)
- 问题仅出现在 `bun test` 环境
- 此前 registry.ts 只导入 `generateText` 时测试正常;添加 `createProviderRegistry` 后全部 registry 测试失败
**可能原因**: Bun 1.3.14 的 `bun test` ESM 模块解析缓存问题,或 `mock.module("ai", ...)` 与静态导入 `createProviderRegistry` 的交互问题
**建议尝试方向**:
1. 清除 Bun 缓存: `rm -rf node_modules/.cache`
2. 升级 Bun 版本
3. 改用动态导入 `const { createProviderRegistry } = await import("ai")``buildProviderRegistry` 函数内部
4.`buildProviderRegistry` 的测试改为不 mock `ai` 模块(因为 `createProviderRegistry` 不需要 mock只有 `generateText` 需要)
5. 将 registry 拆为两个文件:`connection-test.ts`mock generateText`registry.ts`(不 mock
## 质量状态
- `bun run typecheck`: ✅ 0 errors
- `bun run lint`: ✅ 0 errors修复后
- 模型管理相关测试: ❌ 62/66 pass4 个 registry 测试因上述问题失败,其他 62 个全部通过)
- 已有 projects.test.tsx 有一个预存超时问题(与本次变更无关)
## 文件清单
### 新增文件untracked
```
drizzle/0001_wooden_rocket_raccoon.sql
drizzle/meta/0001_snapshot.json
src/server/ai/registry.ts
src/server/ai/types.ts
src/server/db/models.ts
src/server/db/providers.ts
src/server/routes/models/{create,delete,disable,enable,get,list,update}.ts
src/server/routes/providers/{create,delete,disable,enable,get,list,test,update}.ts
src/web/hooks/use-models.ts
src/web/hooks/use-providers.ts
src/web/pages/models/index.tsx
src/web/pages/models/components/{ModelFormModal,ModelTable,ModelToolbar,ProviderFormModal,ProviderTable,ProviderToolbar}.tsx
tests/server/ai/registry.test.ts
tests/server/db/models.test.ts
tests/server/db/providers.test.ts
tests/server/routes/models.test.ts
tests/server/routes/providers.test.ts
tests/web/components/ModelTable.test.tsx
tests/web/components/ProviderTable.test.tsx
tests/web/hooks/use-models.test.ts
tests/web/hooks/use-providers.test.ts
tests/web/routes/models.test.tsx
```
### 修改文件modified
```
bun.lock
docs/development/README.md
docs/development/architecture.md
docs/development/backend.md
docs/development/frontend.md
drizzle/meta/_journal.json
package.json
src/server/db/schema.ts
src/server/server.ts
src/shared/api.ts
src/web/consoles/admin/menu.tsx
src/web/routes.tsx
```
### OpenSpec 变更文档
```
openspec/changes/add-model-management/design.md
openspec/changes/add-model-management/tasks.md
```
## 后续步骤
1. 解决 `bun test``createProviderRegistry` 的兼容问题
2. 确保所有 66+ 测试通过
3. 归档变更(`/opsx-archive`

View File

@@ -0,0 +1,67 @@
import { App as AntApp, DatePicker, Form, Input, Modal } from "antd";
import dayjs from "dayjs";
import { useEffect } from "react";
import type { Material } from "../types";
interface AddMaterialModalProps {
onAdd: (material: Material) => void;
onOpenChange: (open: boolean) => void;
open: boolean;
}
interface FormValues {
associatedDate: dayjs.Dayjs;
description: string;
}
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
const { message } = AntApp.useApp();
const [form] = Form.useForm<FormValues>();
useEffect(() => {
if (!open) return;
form.resetFields();
}, [form, open]);
const handleFinish = (values: FormValues) => {
const material: Material = {
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
createdAt: new Date().toISOString(),
description: values.description,
id: crypto.randomUUID(),
};
onAdd(material);
message.success("素材已添加");
onOpenChange(false);
};
return (
<Modal
destroyOnHidden
okText="确定"
onCancel={() => onOpenChange(false)}
onOk={() => void form.submit()}
open={open}
title="新增素材"
>
<Form
form={form}
initialValues={{ associatedDate: dayjs() }}
layout="vertical"
onFinish={(values) => void handleFinish(values)}
>
<Form.Item
label="描述"
name="description"
rules={[{ message: "请输入描述", required: true, whitespace: true }]}
>
<Input.TextArea maxLength={500} placeholder="请输入素材描述" />
</Form.Item>
<Form.Item label="关联时间" name="associatedDate" rules={[{ message: "请选择关联时间", required: true }]}>
<DatePicker className="app-inbox-datepicker" />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,37 @@
import { DeleteOutlined } from "@ant-design/icons";
import { Button, Card, Flex, Typography } from "antd";
import type { Material } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
interface MaterialCardProps {
material: Material;
onDelete: () => void;
onSelect: () => void;
selected: boolean;
}
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
return (
<Card className={selected ? "app-inbox-card-selected" : undefined} hoverable onClick={onSelect} size="small">
<Typography.Paragraph ellipsis={{ rows: 3 }}>{material.description}</Typography.Paragraph>
<Flex align="center" justify="space-between">
<Typography.Text type="secondary">
{material.associatedDate} · {formatRelativeTime(material.createdAt)}
</Typography.Text>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
size="small"
type="text"
/>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,32 @@
import { Card, Descriptions, Empty, Typography } from "antd";
import type { Material } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time";
interface MaterialContentProps {
material: Material | null;
}
export function MaterialContent({ material }: MaterialContentProps) {
if (!material) {
return (
<div className="app-inbox-content">
<Empty description="请在左侧选择素材" />
</div>
);
}
return (
<div className="app-inbox-content">
<Typography.Title level={4}></Typography.Title>
<Card>
<Typography.Paragraph>{material.description}</Typography.Paragraph>
<Descriptions column={1} size="small">
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
</Descriptions>
</Card>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty } from "antd";
import type { Material } from "../types";
import { MaterialCard } from "./MaterialCard";
interface MaterialListProps {
materials: readonly Material[];
onAddClick: () => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
selectedId: null | string;
}
export function MaterialList({ materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
return (
<div className="app-inbox-sidebar">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<div className="app-inbox-list">
{materials.length === 0 ? (
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
materials.map((material) => (
<MaterialCard
key={material.id}
material={material}
onDelete={() => onDelete(material.id)}
onSelect={() => onSelect(material.id)}
selected={material.id === selectedId}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import type { Material } from "./types";
import { AddMaterialModal } from "./components/AddMaterialModal";
import { MaterialContent } from "./components/MaterialContent";
import { MaterialList } from "./components/MaterialList";
export function InboxPage() {
const [materials, setMaterials] = useState<Material[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<null | string>(null);
const selectedMaterial = materials.find((m) => m.id === selectedId) ?? null;
const handleAdd = (material: Material) => {
setMaterials((prev) => [...prev, material].sort((a, b) => b.associatedDate.localeCompare(a.associatedDate)));
setSelectedId(material.id);
};
const handleDelete = (id: string) => {
setMaterials((prev) => prev.filter((m) => m.id !== id));
if (selectedId === id) setSelectedId(null);
};
return (
<div className="app-inbox-page">
<MaterialList
materials={materials}
onAddClick={() => setModalOpen(true)}
onDelete={handleDelete}
onSelect={setSelectedId}
selectedId={selectedId}
/>
<MaterialContent material={selectedMaterial} />
<AddMaterialModal onAdd={handleAdd} onOpenChange={setModalOpen} open={modalOpen} />
</div>
);
}

View File

@@ -0,0 +1,6 @@
export interface Material {
associatedDate: string;
createdAt: string;
description: string;
id: string;
}

View File

@@ -1,10 +1,11 @@
import { MessageOutlined } from "@ant-design/icons";
import { InboxOutlined, MessageOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
{ icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" },
] as const;
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {

View File

@@ -2,6 +2,7 @@ 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 { NotFoundPage } from "./features/not-found";
import { ProjectsPage } from "./features/projects";
@@ -19,6 +20,7 @@ export function AppRoutes() {
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId">
<Route element={<ChatPage />} path="" />
<Route element={<ChatPage />} path="chat" />
<Route element={<InboxPage />} path="inbox" />
</Route>
<Route element={<NotFoundPage />} path="*" />
</Routes>

View File

@@ -264,3 +264,46 @@ body {
.x-markdown-dark .x-md-table-wrap {
overflow-x: auto;
}
.app-inbox-page {
display: flex;
height: 100%;
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);
background: var(--ant-color-bg-container);
}
.app-inbox-list {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--ant-margin-xs);
min-height: 0;
overflow-y: auto;
}
.app-inbox-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
padding: var(--ant-padding-xl);
overflow-y: auto;
}
.app-inbox-card-selected {
box-shadow: 0 0 0 1px var(--ant-color-primary);
}
.app-inbox-datepicker {
width: 100%;
}

View File

@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import { AddMaterialModal } from "../../../../src/web/features/inbox/components/AddMaterialModal";
import { renderWithProviders } from "../../test-utils";
describe("AddMaterialModal", () => {
test("打开时渲染表单字段", () => {
renderWithProviders(
createElement(AddMaterialModal, {
onAdd: vi.fn(),
onOpenChange: vi.fn(),
open: true,
}),
);
expect(screen.getByText("新增素材")).not.toBeNull();
expect(screen.getByText("描述")).not.toBeNull();
expect(screen.getByText("关联时间")).not.toBeNull();
});
test("关闭时不渲染", () => {
renderWithProviders(
createElement(AddMaterialModal, {
onAdd: vi.fn(),
onOpenChange: vi.fn(),
open: false,
}),
);
expect(screen.queryByText("新增素材")).toBeNull();
});
test("提交空表单显示必填校验", async () => {
renderWithProviders(
createElement(AddMaterialModal, {
onAdd: vi.fn(),
onOpenChange: vi.fn(),
open: true,
}),
);
fireEvent.click(screen.getByText("确 定"));
await waitFor(() => {
expect(screen.getByText("请输入描述")).not.toBeNull();
});
});
test("点击确定触发表单提交", async () => {
const onAdd = vi.fn<(material: Material) => void>();
renderWithProviders(
createElement(AddMaterialModal, {
onAdd,
onOpenChange: vi.fn(),
open: true,
}),
);
const textarea = screen.getByPlaceholderText("请输入素材描述");
fireEvent.change(textarea, { target: { value: "测试描述" } });
fireEvent.click(screen.getByText("确 定"));
await waitFor(() => {
expect(onAdd).toHaveBeenCalledTimes(1);
});
const callArgs = onAdd.mock.calls[0];
expect(callArgs).toBeDefined();
const calledMaterial = callArgs![0];
expect(calledMaterial.description).toBe("测试描述");
expect(calledMaterial.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(calledMaterial.id).toBeTruthy();
});
});

View File

@@ -0,0 +1,63 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { InboxPage } from "../../../../src/web/features/inbox";
import { renderWithProviders } from "../../test-utils";
describe("InboxPage", () => {
test("初始状态显示空状态提示", () => {
renderWithProviders(createElement(InboxPage));
expect(screen.getByText("暂无素材")).not.toBeNull();
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
});
test("新增素材后列表追加且自动选中", async () => {
renderWithProviders(createElement(InboxPage));
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
await waitFor(() => {
expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull();
});
const textarea = screen.getByPlaceholderText("请输入素材描述");
fireEvent.change(textarea, { target: { value: "新增的素材" } });
fireEvent.click(screen.getByText("确 定"));
await waitFor(() => {
expect(screen.getByText("新增的素材")).not.toBeNull();
});
expect(screen.getByText("素材详情")).not.toBeNull();
expect(screen.queryByText("暂无素材")).toBeNull();
expect(screen.queryByText("请在左侧选择素材")).toBeNull();
});
test("删除素材后列表更新", async () => {
renderWithProviders(createElement(InboxPage));
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
await waitFor(() => {
expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull();
});
const textarea = screen.getByPlaceholderText("请输入素材描述");
fireEvent.change(textarea, { target: { value: "待删除的素材" } });
fireEvent.click(screen.getByText("确 定"));
await waitFor(() => {
expect(screen.getByText("待删除的素材")).not.toBeNull();
});
fireEvent.click(screen.getByLabelText("删除"));
await waitFor(() => {
expect(screen.getByText("暂无素材")).not.toBeNull();
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,74 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import { MaterialCard } from "../../../../src/web/features/inbox/components/MaterialCard";
import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIAL: Material = {
associatedDate: "2026-06-03",
createdAt: new Date().toISOString(),
description: "测试素材描述",
id: "test-id",
};
describe("MaterialCard", () => {
test("渲染素材描述和日期信息", () => {
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
onDelete: vi.fn(),
onSelect: vi.fn(),
selected: false,
}),
);
expect(screen.getByText("测试素材描述")).not.toBeNull();
expect(screen.getByText(/2026-06-03/)).not.toBeNull();
});
test("点击卡片触发 onSelect", () => {
const onSelect = vi.fn();
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
onDelete: vi.fn(),
onSelect,
selected: false,
}),
);
const card = screen.getByText("测试素材描述").closest(".ant-card")!;
fireEvent.click(card);
expect(onSelect).toHaveBeenCalledTimes(1);
});
test("点击删除按钮触发 onDelete 且不触发 onSelect", () => {
const onDelete = vi.fn();
const onSelect = vi.fn();
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
onDelete,
onSelect,
selected: false,
}),
);
fireEvent.click(screen.getByLabelText("删除"));
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onSelect).not.toHaveBeenCalled();
});
test("选中状态添加 app-inbox-card-selected 类", () => {
renderWithProviders(
createElement(MaterialCard, {
material: MOCK_MATERIAL,
onDelete: vi.fn(),
onSelect: vi.fn(),
selected: true,
}),
);
const card = screen.getByText("测试素材描述").closest(".app-inbox-card-selected");
expect(card).not.toBeNull();
});
});

View File

@@ -0,0 +1,29 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent";
import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIAL: Material = {
associatedDate: "2026-06-03",
createdAt: new Date().toISOString(),
description: "详细描述内容",
id: "test-id",
};
describe("MaterialContent", () => {
test("未选中时显示空状态提示", () => {
renderWithProviders(createElement(MaterialContent, { material: null }));
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
});
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();
});
});

View File

@@ -0,0 +1,67 @@
import { screen } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { createElement } from "react";
import type { Material } from "../../../../src/web/features/inbox/types";
import { MaterialList } from "../../../../src/web/features/inbox/components/MaterialList";
import { renderWithProviders } from "../../test-utils";
const MOCK_MATERIALS: Material[] = [
{
associatedDate: "2026-06-03",
createdAt: new Date().toISOString(),
description: "素材一",
id: "id-1",
},
{
associatedDate: "2026-06-02",
createdAt: new Date().toISOString(),
description: "素材二",
id: "id-2",
},
];
describe("MaterialList", () => {
test("列表为空时显示暂无素材", () => {
renderWithProviders(
createElement(MaterialList, {
materials: [],
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
expect(screen.getByText("暂无素材")).not.toBeNull();
});
test("渲染素材卡片列表", () => {
renderWithProviders(
createElement(MaterialList, {
materials: MOCK_MATERIALS,
onAddClick: vi.fn(),
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
expect(screen.getByText("素材一")).not.toBeNull();
expect(screen.getByText("素材二")).not.toBeNull();
});
test("点击新增素材按钮触发 onAddClick", () => {
const onAddClick = vi.fn();
renderWithProviders(
createElement(MaterialList, {
materials: [],
onAddClick,
onDelete: vi.fn(),
onSelect: vi.fn(),
selectedId: null,
}),
);
screen.getByText("新增素材").click();
expect(onAddClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -120,4 +120,34 @@ describe("Workbench 路由", () => {
{ timeout: 10000 },
);
});
test("Workbench 收集箱路由可达", async () => {
createMockHandler();
renderWithProviders(createElement(App), {
initialRoute: `/workbench/${MOCK_PROJECT.id}/inbox`,
});
await waitFor(
() => {
expect(screen.getByText("新增素材")).not.toBeNull();
},
{ timeout: 10000 },
);
});
test("Workbench 显示收集箱菜单", async () => {
createMockHandler();
renderWithProviders(createElement(App), {
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
});
await waitFor(
() => {
expect(screen.getByText("收集箱")).not.toBeNull();
},
{ timeout: 10000 },
);
});
});