diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 2aa7377..7675881 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -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` | ### 聊天页面 diff --git a/docs/user/usage.md b/docs/user/usage.md index 986ecb9..458c5f7 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -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 供应商和模型。新建会话时系统会自动选择第一个可用模型。 diff --git a/src/web/features/models/ModelListPage.tsx b/src/web/features/models/ModelListPage.tsx new file mode 100644 index 0000000..576e320 --- /dev/null +++ b/src/web/features/models/ModelListPage.tsx @@ -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(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 ( + + { + setKeyword(value); + setPage(1); + }} + onSearchClear={() => { + setKeyword(""); + setPage(1); + }} + openCreateDialog={() => { + setEditingModel(null); + setDialogOpen(true); + }} + /> + + deleteModelMutation.mutateAsync(id)} + onEdit={(model) => { + setEditingModel(model); + setDialogOpen(true); + }} + onPageChange={(p, ps) => { + setPage(p); + setPageSize(ps); + }} + page={page} + pageSize={pageSize} + providers={modelProviders} + /> + + 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)} + /> + + ); +} diff --git a/src/web/features/models/ProviderListPage.tsx b/src/web/features/models/ProviderListPage.tsx new file mode 100644 index 0000000..752bd32 --- /dev/null +++ b/src/web/features/models/ProviderListPage.tsx @@ -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); + + 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 ( + + { + setKeyword(value); + setPage(1); + }} + onSearchClear={() => { + setKeyword(""); + setPage(1); + }} + openCreateDialog={() => { + setEditingProvider(null); + setDialogOpen(true); + }} + /> + + deleteProviderMutation.mutateAsync(id)} + onEdit={(provider) => { + setEditingProvider(provider); + setDialogOpen(true); + }} + onPageChange={(p, ps) => { + setPage(p); + setPageSize(ps); + }} + page={page} + pageSize={pageSize} + /> + + setDialogOpen(false)} + onCreate={(data) => createProviderMutation.mutateAsync(data)} + onOpenChange={setDialogOpen} + onTest={(data) => testProviderConfigMutation.mutateAsync(data)} + onUpdate={(args) => updateProviderMutation.mutateAsync(args)} + open={dialogOpen} + submitting={isSubmitting} + /> + + ); +} diff --git a/src/web/features/models/components/ModelsToolbar.tsx b/src/web/features/models/components/ModelToolbar.tsx similarity index 53% rename from src/web/features/models/components/ModelsToolbar.tsx rename to src/web/features/models/components/ModelToolbar.tsx index 64bef78..3359004 100644 --- a/src/web/features/models/components/ModelsToolbar.tsx +++ b/src/web/features/models/components/ModelToolbar.tsx @@ -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 ( - +
onSearch(value)} - placeholder={placeholder} + placeholder="搜索模型名称或 ID" value={draftKeyword} /> diff --git a/src/web/features/models/components/ProviderToolbar.tsx b/src/web/features/models/components/ProviderToolbar.tsx new file mode 100644 index 0000000..b8ad809 --- /dev/null +++ b/src/web/features/models/components/ProviderToolbar.tsx @@ -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 ( + +
+ + setDraftKeyword(event.target.value)} + onClear={() => { + setDraftKeyword(""); + onSearchClear(); + }} + onSearch={(value) => onSearch(value)} + placeholder="搜索供应商名称" + value={draftKeyword} + /> + + + + ); +} diff --git a/src/web/features/models/index.tsx b/src/web/features/models/index.tsx deleted file mode 100644 index aa68269..0000000 --- a/src/web/features/models/index.tsx +++ /dev/null @@ -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("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); - - const [modelPage, setModelPage] = useState(1); - const [modelPageSize, setModelPageSize] = useState(20); - const [modelKeyword, setModelKeyword] = useState(""); - const [modelDialogOpen, setModelDialogOpen] = useState(false); - const [editingModel, setEditingModel] = useState(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 ( - - setActiveTab(key)} - openCreateDialog={handleOpenCreate} - /> - - {activeTab === "providers" && ( - <> - deleteProviderMutation.mutateAsync(id)} - onEdit={(provider) => { - setEditingProvider(provider); - setProviderDialogOpen(true); - }} - onPageChange={(p, ps) => { - setProviderPage(p); - setProviderPageSize(ps); - }} - page={providerPage} - pageSize={providerPageSize} - /> - 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" && ( - <> - deleteModelMutation.mutateAsync(id)} - onEdit={(model) => { - setEditingModel(model); - setModelDialogOpen(true); - }} - onPageChange={(p, ps) => { - setModelPage(p); - setModelPageSize(ps); - }} - page={modelPage} - pageSize={modelPageSize} - providers={modelProviders} - /> - 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)} - /> - - )} - - ); -} diff --git a/src/web/layouts/admin-layout/menu.tsx b/src/web/layouts/admin-layout/menu.tsx index fb8cfe1..a873844 100644 --- a/src/web/layouts/admin-layout/menu.tsx +++ b/src/web/layouts/admin-layout/menu.tsx @@ -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; diff --git a/src/web/menu.tsx b/src/web/menu.tsx index 3143812..d2a8f7e 100644 --- a/src/web/menu.tsx +++ b/src/web/menu.tsx @@ -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; } diff --git a/src/web/routes.tsx b/src/web/routes.tsx index aaffd14..b8d8f39 100644 --- a/src/web/routes.tsx +++ b/src/web/routes.tsx @@ -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() { } errorElement={}> } path="/" /> } path="/projects" /> - } path="/models" /> + } path="/models" /> + } path="/models/providers" /> } errorElement={} path="/workbench/:projectId"> } path="" /> diff --git a/src/web/shared/components/Sidebar/index.tsx b/src/web/shared/components/Sidebar/index.tsx index a2a4613..0aab430 100644 --- a/src/web/shared/components/Sidebar/index.tsx +++ b/src/web/shared/components/Sidebar/index.tsx @@ -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(() => { + 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 ; + return ( + + ); +} + +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, + }; + }); } diff --git a/tests/web/routes/models.test.tsx b/tests/web/routes/models.test.tsx index a23b0ad..6724652 100644 --- a/tests/web/routes/models.test.tsx +++ b/tests/web/routes/models.test.tsx @@ -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; + 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; + const existing = models.find((m) => m.id === id) ?? TEST_MODEL; + const updated = { ...existing, ...(data as Partial) }; + 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); +}); diff --git a/tests/web/routes/providers.test.tsx b/tests/web/routes/providers.test.tsx new file mode 100644 index 0000000..7850b8c --- /dev/null +++ b/tests/web/routes/providers.test.tsx @@ -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; + 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; + const existing = providers.find((p) => p.id === id) ?? TEST_PROVIDER; + const updated = { ...existing, ...(data as Partial) }; + 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); +});