接入AGC后台模型目录与对话模型选择
新增后台 AGC 模型目录、别名、启停和默认项管理 客户端设置页恢复原状,对话框右下角按别名选择模型 服务端按稳定模型标识映射并校验实际模型白名单 修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查 补充迁移、文档、启动与模型选择测试
This commit is contained in:
@@ -1072,3 +1072,19 @@ function buildAdminApiError(
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
export function getAgcModelCatalog(token: string) {
|
||||
return request<import('./adminApiTypes').AdminAgcModelCatalog>(
|
||||
'/admin/api/agc-models',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function saveAgcModelCatalog(
|
||||
token: string,
|
||||
body: import('./adminApiTypes').AdminAgcModelCatalog,
|
||||
) {
|
||||
return request<import('./adminApiTypes').AdminAgcModelCatalog>(
|
||||
'/admin/api/agc-models',
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -998,3 +998,15 @@ export interface AdminRechargeRefundActionResponse {
|
||||
export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
alias: string;
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
revision: number;
|
||||
defaultModelId: string;
|
||||
models: AdminAgcModel[];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -289,6 +290,9 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'agc-models' ? (
|
||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{activeRouteId === 'editor-showcase' ? (
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token={token}
|
||||
|
||||
@@ -50,6 +50,7 @@ const routeIcons = {
|
||||
'editor-showcase': Star,
|
||||
'editor-assets': Images,
|
||||
accounts: Users,
|
||||
'agc-models': ListChecks,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -16,9 +16,13 @@ export type AdminRouteId =
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'editor-assets'
|
||||
| 'agc-models'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
|
||||
export type AdminTabPermission = Exclude<
|
||||
AdminRouteId,
|
||||
'accounts' | 'agc-models'
|
||||
>;
|
||||
|
||||
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
|
||||
export interface AdminRouteDefinition {
|
||||
@@ -47,6 +51,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
label: '模型定价',
|
||||
hash: '#editor-generation-pricing',
|
||||
},
|
||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
|
||||
import { AdminAgcModelsPage } from './AdminAgcModelsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
getAgcModelCatalog: vi.fn(),
|
||||
saveAgcModelCatalog: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
formatAdminApiError: vi.fn(() => '保存失败'),
|
||||
}));
|
||||
vi.mock('../components/useAdminWriteConfirm', () => ({
|
||||
useAdminWriteConfirm: () => ({
|
||||
confirmWrite: async () => true,
|
||||
confirmDialog: null,
|
||||
}),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
|
||||
test('edits alias and upstream model without changing the stable identifier or revision', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true },
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('gpt-6-astra');
|
||||
fireEvent.change(screen.getByLabelText('模型 1 别名'), {
|
||||
target: { value: '精细创作' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
await waitFor(() =>
|
||||
expect(saveAgcModelCatalog).toHaveBeenCalledWith('test', {
|
||||
...catalog,
|
||||
models: [{ ...catalog.models[0], alias: '精细创作' }],
|
||||
}),
|
||||
);
|
||||
await screen.findByText('已保存');
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
|
||||
import type { AdminAgcModel, AdminAgcModelCatalog } from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
export function AdminAgcModelsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<AdminAgcModelCatalog | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||||
|
||||
async function refresh() {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSaved(false);
|
||||
try {
|
||||
setCatalog(await getAgcModelCatalog(token));
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
function update(id: string, patch: Partial<AdminAgcModel>) {
|
||||
setSaved(false);
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
models: current.models.map((model) =>
|
||||
model.id === id ? { ...model, ...patch } : model,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!catalog || busy) return;
|
||||
if (
|
||||
!(await confirmWrite({
|
||||
action: '保存 AGC 模型目录',
|
||||
target: `${catalog.models.length} 个模型`,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSaved(false);
|
||||
try {
|
||||
setCatalog(await saveAgcModelCatalog(token, catalog));
|
||||
setSaved(true);
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-agc-models">
|
||||
<header>
|
||||
<h2>AGC 模型</h2>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
title="重新读取"
|
||||
aria-label="重新读取模型"
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="添加模型"
|
||||
aria-label="添加模型"
|
||||
disabled={busy || !catalog || catalog.models.length >= 32}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
models: [
|
||||
...current.models,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
alias: '',
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !catalog}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<Save size={16} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{saved ? <p role="status">已保存</p> : null}
|
||||
{busy ? <p role="status">正在处理</p> : null}
|
||||
<div className="admin-agc-models-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>别名</th>
|
||||
<th>实际模型名</th>
|
||||
<th>启用</th>
|
||||
<th>默认</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalog?.models.map((model, index) => (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 别名`}
|
||||
maxLength={40}
|
||||
required
|
||||
value={model.alias}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { alias: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 实际模型名`}
|
||||
maxLength={200}
|
||||
required
|
||||
value={model.modelId}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { modelId: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
type="checkbox"
|
||||
checked={model.enabled}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onChange={(e) =>
|
||||
update(model.id, { enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 默认`}
|
||||
name="agc-default-model"
|
||||
type="radio"
|
||||
checked={model.id === catalog.defaultModelId}
|
||||
disabled={busy || !model.enabled}
|
||||
onChange={() => {
|
||||
setSaved(false);
|
||||
setCatalog({ ...catalog, defaultModelId: model.id });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
title="删除模型"
|
||||
aria-label={`删除模型 ${index + 1}`}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
models: catalog.models.filter(
|
||||
(candidate) => candidate.id !== model.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3093,5 +3093,54 @@ button:disabled {
|
||||
background: var(--admin-surface, #fff);
|
||||
}
|
||||
.admin-detail-modal__panel header,
|
||||
.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; }
|
||||
.admin-detail-modal__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-detail-modal__panel pre {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.admin-agc-models {
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-agc-models > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-agc-models > header > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-agc-models button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
}
|
||||
.admin-agc-models-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.admin-agc-models table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.admin-agc-models th,
|
||||
.admin-agc-models td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.admin-agc-models td input:not([type]) {
|
||||
min-width: 150px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user