feat: 拆分模型/供应商为独立路由页面,侧边栏支持 SubMenu 分组
This commit is contained in:
@@ -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 显示品牌名、版本号和布局标题。
|
||||
@@ -25,14 +25,14 @@ 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` 展示失败但不阻止保存。 |
|
||||
| 聊天室 | `/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` |
|
||||
| 页面 | 路径 | 入口 |
|
||||
| -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 总览 | `/` | `features/dashboard/index.tsx` |
|
||||
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
|
||||
| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx` + `ProviderListPage.tsx`。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
|
||||
| 聊天室 | `/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` |
|
||||
|
||||
### 聊天页面
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ bun run dev config.yaml
|
||||
|
||||
## 功能介绍
|
||||
|
||||
| 功能 | 路径 | 说明 |
|
||||
| -------- | ----------------------- | ---------------------------------------- |
|
||||
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
|
||||
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
||||
| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 |
|
||||
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
|
||||
| 功能 | 路径 | 说明 |
|
||||
| -------- | ----------------------- | -------------------------------------- |
|
||||
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
|
||||
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
||||
| 模型 | `/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 供应商和模型。新建会话时系统会自动选择第一个可用模型。
|
||||
|
||||
97
src/web/features/models/ModelListPage.tsx
Normal file
97
src/web/features/models/ModelListPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Space } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Model, TestModelRequest } from "../../../shared/api";
|
||||
|
||||
import {
|
||||
useCreateModel,
|
||||
useDeleteModel,
|
||||
useModelList,
|
||||
useTestModelConnection,
|
||||
useUpdateModel,
|
||||
} from "../../shared/hooks/use-models";
|
||||
import { useProviderOptions } from "../../shared/hooks/use-providers";
|
||||
import { ModelFormModal } from "./components/ModelFormModal";
|
||||
import { ModelTable } from "./components/ModelTable";
|
||||
import { ModelToolbar } from "./components/ModelToolbar";
|
||||
|
||||
export function ModelListPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null);
|
||||
|
||||
const { data: modelData, isLoading: modelLoading } = useModelList({
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
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 isActionPending = deleteModelMutation.isPending;
|
||||
const modelProviders = providerOptionsData?.items ?? [];
|
||||
|
||||
return (
|
||||
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||
<ModelToolbar
|
||||
keyword={keyword}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onSearchClear={() => {
|
||||
setKeyword("");
|
||||
setPage(1);
|
||||
}}
|
||||
openCreateDialog={() => {
|
||||
setEditingModel(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ModelTable
|
||||
data={modelData}
|
||||
loading={modelLoading || providerOptionsLoading || isActionPending}
|
||||
onDelete={(id) => deleteModelMutation.mutateAsync(id)}
|
||||
onEdit={(model) => {
|
||||
setEditingModel(model);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onPageChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
}}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
providers={modelProviders}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
84
src/web/features/models/ProviderListPage.tsx
Normal file
84
src/web/features/models/ProviderListPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Space } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Provider } from "../../../shared/api";
|
||||
|
||||
import {
|
||||
useCreateProvider,
|
||||
useDeleteProvider,
|
||||
useProviderList,
|
||||
useTestProviderConfig,
|
||||
useUpdateProvider,
|
||||
} from "../../shared/hooks/use-providers";
|
||||
import { ProviderFormModal } from "./components/ProviderFormModal";
|
||||
import { ProviderTable } from "./components/ProviderTable";
|
||||
import { ProviderToolbar } from "./components/ProviderToolbar";
|
||||
|
||||
export function ProviderListPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<null | Provider>(null);
|
||||
|
||||
const { data: providerData, isLoading: providerLoading } = useProviderList({
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const createProviderMutation = useCreateProvider();
|
||||
const updateProviderMutation = useUpdateProvider();
|
||||
const deleteProviderMutation = useDeleteProvider();
|
||||
const testProviderConfigMutation = useTestProviderConfig();
|
||||
|
||||
const isSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
|
||||
const isActionPending = deleteProviderMutation.isPending;
|
||||
|
||||
return (
|
||||
<Space className="app-page-flex" orientation="vertical" size="large">
|
||||
<ProviderToolbar
|
||||
keyword={keyword}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onSearchClear={() => {
|
||||
setKeyword("");
|
||||
setPage(1);
|
||||
}}
|
||||
openCreateDialog={() => {
|
||||
setEditingProvider(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProviderTable
|
||||
data={providerData}
|
||||
loading={providerLoading || isActionPending}
|
||||
onDelete={(id) => deleteProviderMutation.mutateAsync(id)}
|
||||
onEdit={(provider) => {
|
||||
setEditingProvider(provider);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onPageChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
}}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,20 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Input, Space, Tabs } from "antd";
|
||||
import { Button, Flex, Input, Space } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ModelsToolbarProps {
|
||||
activeTab: string;
|
||||
interface ModelToolbarProps {
|
||||
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) {
|
||||
export function ModelToolbar({ keyword, onSearch, onSearchClear, openCreateDialog }: ModelToolbarProps) {
|
||||
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} />
|
||||
<div />
|
||||
<Space size="small">
|
||||
<Input.Search
|
||||
allowClear
|
||||
@@ -41,11 +25,11 @@ export function ModelsToolbar({
|
||||
onSearchClear();
|
||||
}}
|
||||
onSearch={(value) => onSearch(value)}
|
||||
placeholder={placeholder}
|
||||
placeholder="搜索模型名称或 ID"
|
||||
value={draftKeyword}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
||||
{createLabel}
|
||||
新建模型
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
37
src/web/features/models/components/ProviderToolbar.tsx
Normal file
37
src/web/features/models/components/ProviderToolbar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Input, Space } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProviderToolbarProps {
|
||||
keyword: string;
|
||||
onSearch: (value: string) => void;
|
||||
onSearchClear: () => void;
|
||||
openCreateDialog: () => void;
|
||||
}
|
||||
|
||||
export function ProviderToolbar({ keyword, onSearch, onSearchClear, openCreateDialog }: ProviderToolbarProps) {
|
||||
const [draftKeyword, setDraftKeyword] = useState(keyword);
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
|
||||
<div />
|
||||
<Space size="small">
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton="搜索"
|
||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
||||
onClear={() => {
|
||||
setDraftKeyword("");
|
||||
onSearchClear();
|
||||
}}
|
||||
onSearch={(value) => onSearch(value)}
|
||||
placeholder="搜索供应商名称"
|
||||
value={draftKeyword}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
||||
新建供应商
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -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,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="" />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -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[] = [];
|
||||
@@ -317,3 +216,112 @@ 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",
|
||||
id: "m1",
|
||||
maxOutputTokens: 4096,
|
||||
modelId: "gpt-4o",
|
||||
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.modelId}`.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());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
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);
|
||||
});
|
||||
|
||||
222
tests/web/routes/providers.test.tsx
Normal file
222
tests/web/routes/providers.test.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user