接入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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"llm": {
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-5.6-sol",
|
||||
"model": "gpt-6-astra",
|
||||
"apiKind": "openai_responses",
|
||||
"reasoningEffort": "max",
|
||||
"stream": true,
|
||||
|
||||
@@ -540,6 +540,7 @@ async function ensureBackend({
|
||||
backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
backendSpacetimeDataDir,
|
||||
'--preserve-database',
|
||||
'--no-interactive',
|
||||
],
|
||||
{ cwd: appRoot },
|
||||
@@ -680,11 +681,13 @@ function isDirectModuleExecution() {
|
||||
export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
readLinuxProcessGroupAlive,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
import {
|
||||
isAiGameCreatorServer,
|
||||
preflightExistingVite,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
@@ -20,7 +23,9 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
|
||||
|
||||
function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
|
||||
const args = [...argv];
|
||||
const configOverride = JSON.stringify({ build: { devUrl } });
|
||||
const configOverride = JSON.stringify({
|
||||
build: { devUrl, beforeDevCommand: '' },
|
||||
});
|
||||
const separatorIndex = args.indexOf('--');
|
||||
if (separatorIndex < 0) {
|
||||
return ['dev', ...args, '--config', configOverride];
|
||||
@@ -51,6 +56,7 @@ async function runTauriDev(
|
||||
{
|
||||
resolveDevEndpoint = resolveAgcDevEndpoint,
|
||||
preflight = preflightExistingVite,
|
||||
prepareFrontend = prepareFrontendDev,
|
||||
spawnCli = spawnTauriCli,
|
||||
waitForCli = waitForChildTermination,
|
||||
terminateTree = terminateChildTree,
|
||||
@@ -59,10 +65,9 @@ async function runTauriDev(
|
||||
const endpoint = await resolveDevEndpoint();
|
||||
await preflight({ endpoint });
|
||||
|
||||
const tauriArguments = buildTauriArguments(argv, endpoint.url);
|
||||
const child = spawnCli(tauriArguments, {
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
});
|
||||
let child = null;
|
||||
let frontendChild = null;
|
||||
const preparationAbort = new AbortController();
|
||||
let resolveShutdown;
|
||||
let shutdownSignal = '';
|
||||
let repeatedSignal = false;
|
||||
@@ -76,21 +81,47 @@ async function runTauriDev(
|
||||
if (!shutdownSignal) {
|
||||
shutdownSignal = signal;
|
||||
stopChild(child, 'SIGTERM');
|
||||
stopChild(frontendChild, 'SIGTERM');
|
||||
preparationAbort.abort();
|
||||
resolveShutdown(signal);
|
||||
return;
|
||||
}
|
||||
repeatedSignal = true;
|
||||
stopChild(child, 'SIGKILL');
|
||||
stopChild(frontendChild, 'SIGKILL');
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
try {
|
||||
const preparation = prepareFrontend(endpoint, {
|
||||
signal: preparationAbort.signal,
|
||||
onChild(frontend) {
|
||||
frontendChild = frontend;
|
||||
},
|
||||
});
|
||||
const prepared = await Promise.race([
|
||||
preparation.then(() => true),
|
||||
shutdownRequested.then(() => false),
|
||||
]);
|
||||
if (!prepared || shutdownSignal) return 1;
|
||||
const tauriArguments = buildTauriArguments(argv, endpoint.url);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
const outcome = await Promise.race([
|
||||
childResult.then((failure) => ({ type: 'exit', failure })),
|
||||
shutdownRequested.then((signal) => ({ type: 'signal', signal })),
|
||||
...(frontendChild
|
||||
? [
|
||||
waitForChildTermination(frontendChild).then((failure) => ({
|
||||
type: 'frontend-exit',
|
||||
failure,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const cleanup = await terminateTree(child, {
|
||||
gracefulTimeoutMs: repeatedSignal ? 0 : 2500,
|
||||
@@ -105,15 +136,51 @@ async function runTauriDev(
|
||||
if (outcome.type === 'signal') {
|
||||
return 1;
|
||||
}
|
||||
if (outcome.type === 'frontend-exit') return 1;
|
||||
const { failure } = outcome;
|
||||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||||
} finally {
|
||||
for (const [signal, handler] of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
preparationAbort.abort();
|
||||
if (frontendChild) {
|
||||
const cleanup = await terminateTree(frontendChild);
|
||||
if (!cleanup.stopped) {
|
||||
console.error('[ai-game-creator-shell] 配套开发服务未能完全停止。');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareFrontendDev(endpoint, { onChild, signal }) {
|
||||
const frontend = spawnChild(
|
||||
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||
['run', 'agc:serve'],
|
||||
{ cwd: repoRoot, env: withAgcDevEndpointEnv(endpoint) },
|
||||
);
|
||||
onChild(frontend);
|
||||
console.log(
|
||||
'[ai-game-creator-shell] 正在准备前端与配套后端,完成后启动 Tauri',
|
||||
);
|
||||
const deadline = Date.now() + 660_000;
|
||||
while (Date.now() < deadline) {
|
||||
signal.throwIfAborted();
|
||||
const failure = readChildFailure(frontend);
|
||||
if (failure) {
|
||||
throw new Error(
|
||||
`配套开发服务退出,前端未就绪:${failure.error?.message ?? failure.signal ?? failure.code}`,
|
||||
);
|
||||
}
|
||||
if (isAiGameCreatorServer(await readExistingViteServer(endpoint))) return;
|
||||
await Promise.race([
|
||||
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
|
||||
waitForChildTermination(frontend),
|
||||
]);
|
||||
}
|
||||
throw new Error(`等待前端与配套后端就绪超时:${endpoint.url}`);
|
||||
}
|
||||
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
|
||||
@@ -1776,8 +1776,8 @@ impl CodexAppServerConnection {
|
||||
effective_llm.base_url =
|
||||
format!("{}/api/llm", session.api_base_url.trim_end_matches('/'));
|
||||
effective_llm.api_key.clear();
|
||||
// The selected model profile is resolved and validated at config
|
||||
// load time; the official route still owns the provider/base URL.
|
||||
// Only the platform catalog identifier reaches Codex. api-server
|
||||
// validates it and resolves the actual upstream model.
|
||||
effective_llm.model = llm.model.clone();
|
||||
CodexAppServerCredential::PlatformSession {
|
||||
fingerprint: format!(
|
||||
|
||||
@@ -1997,8 +1997,40 @@ pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView,
|
||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||
}
|
||||
|
||||
static GAME_CREATOR_CONFIG_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn write_game_creator_app_config(
|
||||
mut config: GameCreatorAppConfig,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
config.selected_model_id = load_game_creator_app_config()?.selected_model_id;
|
||||
persist_game_creator_app_config(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn select_game_creator_model(
|
||||
model_id: String,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
if model_id.is_empty()
|
||||
|| model_id.len() > 64
|
||||
|| !model_id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|
||||
{
|
||||
return Err("模型标识无效".into());
|
||||
}
|
||||
let mut config = load_game_creator_app_config()?;
|
||||
config.selected_model_id = model_id;
|
||||
persist_game_creator_app_config(config)
|
||||
}
|
||||
|
||||
fn persist_game_creator_app_config(
|
||||
config: GameCreatorAppConfig,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let config = normalize_game_creator_app_config(config)?;
|
||||
|
||||
@@ -93,7 +93,7 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1";
|
||||
pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-5.6-sol";
|
||||
pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra";
|
||||
|
||||
pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [
|
||||
(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"),
|
||||
@@ -3482,23 +3482,11 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr
|
||||
config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string();
|
||||
config.llm.api_key.clear();
|
||||
config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string();
|
||||
if let Some(profile) = config
|
||||
.model_profiles
|
||||
.iter()
|
||||
.find(|profile| profile.enabled && profile.id == config.selected_model_profile_id)
|
||||
{
|
||||
if !profile.model_id.trim().is_empty() {
|
||||
config.llm.model = profile.model_id.trim().to_string();
|
||||
}
|
||||
if let Some(reasoning_effort) = profile.reasoning_effort.as_deref() {
|
||||
if !reasoning_effort.trim().is_empty() {
|
||||
config.llm.reasoning_effort = reasoning_effort.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.llm.model.trim().is_empty() {
|
||||
config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string();
|
||||
}
|
||||
config.llm.model = if config.selected_model_id.is_empty() {
|
||||
"platform-default".to_string()
|
||||
} else {
|
||||
config.selected_model_id.clone()
|
||||
};
|
||||
config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string();
|
||||
config.agent_llm.clear();
|
||||
config.editor_api.api_key.clear();
|
||||
@@ -3632,18 +3620,8 @@ pub(crate) fn merge_game_creator_config_file(
|
||||
config.planning.capability_enabled = capability_enabled;
|
||||
}
|
||||
}
|
||||
if let Some(model_profiles) = file_config.model_profiles {
|
||||
config.model_profiles = model_profiles
|
||||
.into_iter()
|
||||
.filter(|profile| {
|
||||
!profile.id.trim().is_empty()
|
||||
&& !profile.name.trim().is_empty()
|
||||
&& !profile.model_id.trim().is_empty()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(selected_model_profile_id) = file_config.selected_model_profile_id {
|
||||
config.selected_model_profile_id = selected_model_profile_id;
|
||||
if let Some(selected_model_id) = file_config.selected_model_id {
|
||||
config.selected_model_id = selected_model_id;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1021,25 +1021,7 @@ struct GameCreatorAppConfigFile {
|
||||
editor_api: Option<GameCreatorEditorApiConfigFile>,
|
||||
planning: Option<GameCreatorPlanningConfigFile>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model_profiles: Option<Vec<GameCreatorModelProfileFile>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorModelProfileFile {
|
||||
id: String,
|
||||
name: String,
|
||||
model_id: String,
|
||||
#[serde(default = "default_model_profile_enabled")]
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
fn default_model_profile_enabled() -> bool {
|
||||
true
|
||||
selected_model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -1101,13 +1083,7 @@ struct GameCreatorAppConfig {
|
||||
#[serde(default)]
|
||||
planning: GameCreatorPlanningConfig,
|
||||
#[serde(default)]
|
||||
model_profiles: Vec<GameCreatorModelProfileFile>,
|
||||
#[serde(default = "default_selected_model_profile_id")]
|
||||
selected_model_profile_id: String,
|
||||
}
|
||||
|
||||
fn default_selected_model_profile_id() -> String {
|
||||
"default".to_string()
|
||||
selected_model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -1530,7 +1506,7 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
|
||||
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
|
||||
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
|
||||
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
|
||||
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol";
|
||||
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra";
|
||||
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
|
||||
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max";
|
||||
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
|
||||
@@ -1634,14 +1610,7 @@ impl Default for GameCreatorAppConfig {
|
||||
agent_llm: BTreeMap::new(),
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
model_profiles: vec![GameCreatorModelProfileFile {
|
||||
id: "default".to_string(),
|
||||
name: "陶泥儿智能创作".to_string(),
|
||||
model_id: OFFICIAL_LLM_ROUTER_MODEL.to_string(),
|
||||
enabled: true,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
}],
|
||||
selected_model_profile_id: default_selected_model_profile_id(),
|
||||
selected_model_id: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2570,6 +2539,7 @@ fn main() {
|
||||
clear_platform_account_session,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
select_game_creator_model,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
|
||||
@@ -718,21 +718,12 @@ export interface GameCreatorAppConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
modelProfiles: GameCreatorModelProfile[];
|
||||
selectedModelProfileId: string;
|
||||
selectedModelId?: string;
|
||||
planning?: {
|
||||
capabilityEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GameCreatorModelProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
reasoningEffort: GameCreatorLlmReasoningEffort;
|
||||
}
|
||||
|
||||
export interface GameCreatorAppConfigView {
|
||||
path: string;
|
||||
config: GameCreatorAppConfig;
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { GameCreatorAppConfigView } from '../../app/types';
|
||||
import {
|
||||
type ClientLlmModel,
|
||||
loadClientLlmModels,
|
||||
} from '../../services/clientApi';
|
||||
|
||||
export function ConversationModelSelect({
|
||||
disabled,
|
||||
onReady,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onReady: (ready: boolean) => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<ClientLlmModel[]>([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
onReady(false);
|
||||
try {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('Native host unavailable');
|
||||
const [catalog, config] = await Promise.all([
|
||||
loadClientLlmModels(),
|
||||
invoke<GameCreatorAppConfigView>('read_game_creator_app_config'),
|
||||
]);
|
||||
setModels(catalog.models);
|
||||
const id = config.config.selectedModelId || catalog.defaultModelId;
|
||||
setSelected(id);
|
||||
const available = catalog.models.some((model) => model.id === id);
|
||||
if (available && !config.config.selectedModelId) {
|
||||
const saved = await invoke<GameCreatorAppConfigView>(
|
||||
'select_game_creator_model',
|
||||
{ modelId: id },
|
||||
);
|
||||
if (saved.config.selectedModelId !== id)
|
||||
throw new Error('Default selection was not saved');
|
||||
}
|
||||
onReady(available);
|
||||
if (!available) setError('请选择可用模型');
|
||||
} catch {
|
||||
setModels([]);
|
||||
setError('模型列表加载失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function select(id: string) {
|
||||
onReady(false);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('Native host unavailable');
|
||||
const result = await invoke<GameCreatorAppConfigView>(
|
||||
'select_game_creator_model',
|
||||
{ modelId: id },
|
||||
);
|
||||
if (result.config.selectedModelId !== id)
|
||||
throw new Error('Selection was not saved');
|
||||
setSelected(id);
|
||||
onReady(true);
|
||||
} catch {
|
||||
setError('模型选择保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="conversation-model-select">
|
||||
{error ? <span role="alert">{error}</span> : null}
|
||||
<select
|
||||
aria-label="对话模型"
|
||||
disabled={disabled || busy || models.length === 0}
|
||||
value={models.some((model) => model.id === selected) ? selected : ''}
|
||||
onChange={(event) => void select(event.currentTarget.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{busy ? '正在读取模型' : '选择模型'}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新模型列表"
|
||||
title="刷新模型列表"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+23
-2
@@ -27,6 +27,7 @@ import {
|
||||
} from '../agent-runtime';
|
||||
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
|
||||
import { taskStatusLabels } from '../project-summary/projectSummary';
|
||||
import { ConversationModelSelect } from './ConversationModelSelect';
|
||||
import { PlanGddSurface } from './GddApprovalCard';
|
||||
import {
|
||||
pendingCommandDetail,
|
||||
@@ -143,6 +144,7 @@ export function ProjectSupervisorView({
|
||||
? '思考中'
|
||||
: '发送';
|
||||
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
|
||||
const [modelReady, setModelReady] = useState(false);
|
||||
return (
|
||||
<section
|
||||
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
|
||||
@@ -303,7 +305,16 @@ export function ProjectSupervisorView({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<form className="project-supervisor-composer" onSubmit={onSubmit}>
|
||||
<form
|
||||
className="project-supervisor-composer"
|
||||
onSubmit={(event) => {
|
||||
if (directCodex && !modelReady) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSubmit(event);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
aria-label={directCodex ? '陶泥儿对话内容' : '项目需求'}
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
@@ -326,11 +337,21 @@ export function ProjectSupervisorView({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{directCodex ? (
|
||||
<ConversationModelSelect
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
onReady={setModelReady}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={submitLabel}
|
||||
title={submitLabel}
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
disabled={
|
||||
runtimePanelProps.controlBusy ||
|
||||
needsUserInput ||
|
||||
(directCodex && !modelReady)
|
||||
}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,16 +180,16 @@ export type ClientEditorAssetLibrary = {
|
||||
};
|
||||
|
||||
export type ClientLlmModel = {
|
||||
displayName: string;
|
||||
id: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
export function loadClientLlmModels() {
|
||||
return requestClientApi<{ models: ClientLlmModel[] }>(
|
||||
return requestClientApi<{ models: ClientLlmModel[]; defaultModelId: string }>(
|
||||
'/api/llm/models',
|
||||
{ method: 'GET' },
|
||||
'读取可用模型失败',
|
||||
).then((response) => response.models ?? []);
|
||||
);
|
||||
}
|
||||
|
||||
export function loadEditorAssetLibrary() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PlanGddStateViewV1 } from '../../src/app/types';
|
||||
import * as clientApi from '../../src/services/clientApi';
|
||||
import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore';
|
||||
|
||||
const nativeClipboardMock = vi.hoisted(() => ({
|
||||
@@ -475,6 +476,7 @@ function createProjectSupervisorRuntimeHarness({
|
||||
'未命名游戏原型',
|
||||
);
|
||||
let messageSequence = 0;
|
||||
let selectedModelId = 'quality';
|
||||
let steerSequence = 0;
|
||||
let sessionExists = initialSessionExists;
|
||||
let currentProjectRevision = initialProjectRevision;
|
||||
@@ -587,6 +589,38 @@ function createProjectSupervisorRuntimeHarness({
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (
|
||||
command === 'read_game_creator_app_config' ||
|
||||
command === 'select_game_creator_model'
|
||||
) {
|
||||
if (command === 'select_game_creator_model')
|
||||
selectedModelId = String(args?.modelId);
|
||||
return {
|
||||
path: '/tmp/test-game-creator-config.json',
|
||||
config: {
|
||||
schemaVersion: 'game-creator-config.v2',
|
||||
agentMode: 'codex_app_server',
|
||||
selectedModelId,
|
||||
llm: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: 'quality',
|
||||
apiKind: 'openai_responses',
|
||||
reasoningEffort: 'max',
|
||||
stream: true,
|
||||
webSearchEnabled: true,
|
||||
contextWindowTokens: 128000,
|
||||
autoCompactTokenLimit: 64000,
|
||||
toolOutputTokenLimit: 12000,
|
||||
requestTimeoutMs: 180000,
|
||||
maxRetries: 2,
|
||||
retryBackoffMs: 500,
|
||||
},
|
||||
agentLlm: {},
|
||||
editorApi: { baseUrl: 'https://dev.genarrative.world', apiKey: '' },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
@@ -964,6 +998,13 @@ async function openMainProject(projectPath: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'fast', displayName: '快速' },
|
||||
],
|
||||
});
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
value: TestPointerEvent,
|
||||
|
||||
@@ -255,69 +255,6 @@ export function registerAgentStatusDerivationTests() {
|
||||
}
|
||||
|
||||
export function registerRuntimeSettingsTests() {
|
||||
it('shows a checking animation while account status is being verified', async () => {
|
||||
let resolveAccountCheck!: (status: unknown) => void;
|
||||
const accountCheck = new Promise<unknown>((resolve) => {
|
||||
resolveAccountCheck = resolve;
|
||||
});
|
||||
const invoke = vi.fn((command: string) => {
|
||||
if (command === 'read_game_creator_app_config') {
|
||||
return Promise.resolve({
|
||||
path: '/home/test/AppData/game-creator.config.json',
|
||||
config: {
|
||||
agentMode: 'codex_app_server',
|
||||
llm: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
apiKind: 'openai_responses',
|
||||
reasoningEffort: 'high',
|
||||
stream: true,
|
||||
webSearchEnabled: true,
|
||||
contextWindowTokens: 128000,
|
||||
autoCompactTokenLimit: 64000,
|
||||
toolOutputTokenLimit: 12000,
|
||||
requestTimeoutMs: 180000,
|
||||
maxRetries: 2,
|
||||
retryBackoffMs: 500,
|
||||
},
|
||||
agentLlm: {},
|
||||
editorApi: { baseUrl: '', apiKey: '' },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (command === 'check_game_creator_llm_config') {
|
||||
return accountCheck;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
const accountState = await screen.findByTestId(
|
||||
'runtime-settings-account-credential-state',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(accountState.getAttribute('data-checking')).toBe('true');
|
||||
});
|
||||
expect(accountState.querySelector('.is-spinning')).not.toBeNull();
|
||||
expect(screen.getByText('正在检测')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveAccountCheck({
|
||||
accountCredentialState: 'ready',
|
||||
configured: true,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(accountState.getAttribute('data-checking')).toBe('false');
|
||||
expect(accountState.querySelector('.is-spinning')).toBeNull();
|
||||
});
|
||||
expect(screen.getByText('账号权限已就绪')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes loading the client extension list from an empty list', async () => {
|
||||
let resolveExtensions!: (items: unknown[]) => void;
|
||||
const extensionsRead = new Promise<unknown[]>((resolve) => {
|
||||
@@ -467,11 +404,9 @@ export function registerRuntimeSettingsTests() {
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
expect(await screen.findByText('智能创作')).not.toBeNull();
|
||||
expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull();
|
||||
expect(screen.queryByLabelText('Agent 模式')).toBeNull();
|
||||
expect(screen.getByText('账号状态')).not.toBeNull();
|
||||
expect(screen.queryByText('官方账号服务(固定)')).toBeNull();
|
||||
expect(screen.queryByText('普通用户无需填写任何智能服务凭据。')).toBeNull();
|
||||
expect(screen.getByText('官方账号服务(固定)')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
|
||||
).toBeNull();
|
||||
@@ -542,7 +477,7 @@ export function registerRuntimeSettingsTests() {
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
expect(await screen.findByText('智能创作')).not.toBeNull();
|
||||
expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull();
|
||||
expect(screen.queryByLabelText('Agent 模式')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
@@ -596,7 +531,7 @@ export function registerRuntimeSettingsTests() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '运行时配置' });
|
||||
expect(screen.getByText('账号状态')).not.toBeNull();
|
||||
expect(screen.getByText('官方账号服务(固定)')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
|
||||
).toBeNull();
|
||||
@@ -725,17 +660,9 @@ export function registerRuntimeSettingsTests() {
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '运行时配置' }),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置模型方案' }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '模型方案管理' }),
|
||||
).not.toBeNull();
|
||||
fireEvent.keyDown(screen.getByLabelText('陶泥儿智能创作 显示名称'), {
|
||||
fireEvent.keyDown(screen.getByLabelText('推理档'), {
|
||||
key: 'Escape',
|
||||
});
|
||||
expect(screen.getByRole('dialog', { name: '模型方案管理' })).not.toBeNull();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: '模型方案管理' })).toBeNull();
|
||||
expect(screen.getByRole('dialog', { name: '运行时配置' })).not.toBeNull();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
@@ -877,16 +804,6 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
baseUrl: 'http://127.0.0.1:8082',
|
||||
apiKey: 'editor-loaded-secret',
|
||||
},
|
||||
modelProfiles: [
|
||||
{
|
||||
id: 'default',
|
||||
name: '陶泥儿智能创作',
|
||||
modelId: 'gpt-test',
|
||||
enabled: true,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
selectedModelProfileId: 'default',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -905,7 +822,7 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
|
||||
expect(await screen.findByText('账号状态')).not.toBeNull();
|
||||
expect(await screen.findByText('官方账号服务(固定)')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
|
||||
).toBeNull();
|
||||
@@ -930,8 +847,7 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
expect(screen.queryByLabelText('External Editor Base URL')).toBeNull();
|
||||
expect(screen.queryByLabelText('External Editor API Key')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
|
||||
expect(screen.getByText('已启用')).not.toBeNull();
|
||||
expect(screen.queryByText('所有角色使用统一账号服务')).toBeNull();
|
||||
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
|
||||
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', false);
|
||||
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
|
||||
@@ -948,27 +864,15 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
'12000',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
|
||||
expect(screen.getByText('已启用')).not.toBeNull();
|
||||
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置模型方案' }));
|
||||
await screen.findByRole('dialog', {
|
||||
name: '模型方案管理',
|
||||
});
|
||||
const reasoningSelect = screen.getByLabelText('陶泥儿智能创作 推理档位');
|
||||
expect(reasoningSelect.textContent).toContain('medium');
|
||||
fireEvent.click(reasoningSelect);
|
||||
expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'medium');
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole('listbox', {
|
||||
name: '陶泥儿智能创作 推理档位',
|
||||
}),
|
||||
).getByRole('option', {
|
||||
within(screen.getByLabelText('推理档')).getByRole('option', {
|
||||
name: 'max',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
expect(screen.queryByRole('dialog', { name: '模型方案管理' })).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('流式输出'));
|
||||
fireEvent.click(screen.getByLabelText('联网检索'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
|
||||
@@ -991,7 +895,7 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
target: { value: '800' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
|
||||
expect(screen.getByText('已启用')).not.toBeNull();
|
||||
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /连接与工具/ }));
|
||||
expect(screen.queryByLabelText('External Editor Base URL')).toBeNull();
|
||||
expect(screen.queryByLabelText('External Editor API Key')).toBeNull();
|
||||
@@ -1027,16 +931,6 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
baseUrl: 'https://dev.genarrative.world',
|
||||
apiKey: '',
|
||||
},
|
||||
modelProfiles: [
|
||||
{
|
||||
id: 'default',
|
||||
name: '陶泥儿智能创作',
|
||||
modelId: 'gpt-test',
|
||||
enabled: true,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
selectedModelProfileId: 'default',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(persistedConfig)).not.toContain(
|
||||
@@ -1049,7 +943,7 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
|
||||
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
|
||||
expect(screen.getByText('已启用')).not.toBeNull();
|
||||
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
|
||||
expect(screen.getByLabelText('聊天').textContent).not.toContain(
|
||||
'unit-new-secret-value',
|
||||
);
|
||||
@@ -1089,27 +983,19 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
|
||||
expect(screen.getByText('已恢复默认配置,保存后生效')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /常用设置/ }));
|
||||
expect(screen.getByText('智能创作')).not.toBeNull();
|
||||
expect(screen.getByText('陶泥儿智能创作(固定)')).not.toBeNull();
|
||||
expect(screen.queryByLabelText('Agent 模式')).toBeNull();
|
||||
expect(screen.getByText('账号状态')).not.toBeNull();
|
||||
expect(screen.getByText('官方账号服务(固定)')).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/),
|
||||
).toBeNull();
|
||||
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
|
||||
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
|
||||
expect(screen.queryByLabelText('LLM 模型')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置模型方案' }));
|
||||
await screen.findByRole('dialog', {
|
||||
name: '模型方案管理',
|
||||
});
|
||||
expect(
|
||||
screen.getByLabelText('陶泥儿智能创作 推理档位').textContent,
|
||||
).toContain('max');
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
expect(screen.queryByRole('dialog', { name: '模型方案管理' })).toBeNull();
|
||||
expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'max');
|
||||
expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ }));
|
||||
expect(screen.getByText('已启用')).not.toBeNull();
|
||||
expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /高级参数/ }));
|
||||
expect(screen.getByLabelText('上下文窗口 tokens')).toHaveProperty(
|
||||
'value',
|
||||
@@ -1200,7 +1086,7 @@ export function registerPublishedRuntimeSettingsTests() {
|
||||
renderAppAt('/');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '配置' }));
|
||||
expect(await screen.findByText('账号状态')).not.toBeNull();
|
||||
expect(await screen.findByText('官方账号服务(固定)')).not.toBeNull();
|
||||
expect(screen.queryByLabelText('LLM Provider')).toBeNull();
|
||||
expect(screen.queryByLabelText('LLM API Key')).toBeNull();
|
||||
expect(screen.queryByLabelText('LLM Base URL')).toBeNull();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { resolveTauriInvoke } from '../src/app/tauri';
|
||||
import { ConversationModelSelect } from '../src/features/project-workspace/ConversationModelSelect';
|
||||
import { loadClientLlmModels } from '../src/services/clientApi';
|
||||
|
||||
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
|
||||
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() }));
|
||||
const invoke = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
|
||||
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', displayName: '高质量' },
|
||||
{ id: 'fast', displayName: '快速' },
|
||||
],
|
||||
});
|
||||
invoke.mockImplementation(async (command, input) => ({
|
||||
config: {
|
||||
selectedModelId:
|
||||
command === 'select_game_creator_model' ? input.modelId : 'quality',
|
||||
},
|
||||
}));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('only displays aliases and persists selection through the native command', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByRole('option', { name: '高质量' });
|
||||
expect(screen.queryByText('gpt-6-astra')).toBeNull();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '对话模型' }), {
|
||||
target: { value: 'fast' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'fast',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
|
||||
'fast',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not mark a removed selection ready or expose the old identifier', async () => {
|
||||
invoke.mockResolvedValue({
|
||||
config: { selectedModelId: 'private-old-model' },
|
||||
});
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByText('请选择可用模型');
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
expect(screen.queryByText('private-old-model')).toBeNull();
|
||||
});
|
||||
|
||||
test('failed catalog can be refreshed without enabling submission', async () => {
|
||||
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline'));
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByText('模型列表加载失败');
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
});
|
||||
|
||||
test('a failed save keeps submission unavailable', async () => {
|
||||
invoke.mockImplementation(async (command) => {
|
||||
if (command === 'select_game_creator_model') throw new Error('disk full');
|
||||
return { config: { selectedModelId: 'quality' } };
|
||||
});
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByRole('option', { name: '快速' });
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'fast' } });
|
||||
await screen.findByText('模型选择保存失败');
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../scripts/start-dev-stack.mjs';
|
||||
import {
|
||||
buildTauriArguments,
|
||||
runTauriDev,
|
||||
runTauriDev as runTauriDevImpl,
|
||||
} from '../scripts/start-tauri-dev.mjs';
|
||||
|
||||
const testEndpoint = {
|
||||
@@ -23,6 +23,10 @@ const testEndpoint = {
|
||||
portRange: { start: 10000, end: 10099, label: '10000-10099' },
|
||||
};
|
||||
const resolveTestEndpoint = async () => testEndpoint;
|
||||
const runTauriDev = (
|
||||
argv: string[],
|
||||
options: Parameters<typeof runTauriDevImpl>[1],
|
||||
) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options });
|
||||
|
||||
async function waitForFile(path: string, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -41,7 +45,7 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
||||
'dev',
|
||||
'--no-watch',
|
||||
'--config',
|
||||
'{"build":{"devUrl":"http://127.0.0.1:10005/"}}',
|
||||
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -56,7 +60,7 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
||||
'--config',
|
||||
'custom.json',
|
||||
'--config',
|
||||
'{"build":{"devUrl":"http://127.0.0.1:10005/"}}',
|
||||
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
|
||||
'--',
|
||||
'--',
|
||||
'--example-app-arg',
|
||||
@@ -72,7 +76,7 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
||||
).toEqual([
|
||||
'dev',
|
||||
'--config',
|
||||
'{"build":{"devUrl":"http://127.0.0.1:10005/"}}',
|
||||
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
|
||||
'--',
|
||||
'--',
|
||||
'--config-dir',
|
||||
@@ -82,6 +86,26 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
||||
});
|
||||
|
||||
describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
||||
test('前端就绪前不启动 Tauri,准备失败时清理自有服务', async () => {
|
||||
const spawnCli = vi.fn();
|
||||
const frontend = { pid: 1234 };
|
||||
const terminateTree = vi.fn(async () => ({ stopped: true }));
|
||||
await expect(
|
||||
runTauriDev([], {
|
||||
resolveDevEndpoint: resolveTestEndpoint,
|
||||
preflight: async () => {},
|
||||
prepareFrontend: async (_, { onChild }) => {
|
||||
onChild(frontend);
|
||||
expect(spawnCli).not.toHaveBeenCalled();
|
||||
throw new Error('backend failed');
|
||||
},
|
||||
spawnCli,
|
||||
terminateTree,
|
||||
}),
|
||||
).rejects.toThrow('backend failed');
|
||||
expect(spawnCli).not.toHaveBeenCalled();
|
||||
expect(terminateTree).toHaveBeenCalledWith(frontend);
|
||||
});
|
||||
test('动态端口预检失败时不启动 Tauri CLI', async () => {
|
||||
const spawnCli = vi.fn();
|
||||
|
||||
@@ -111,6 +135,9 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
||||
preflight: async () => {
|
||||
order.push('preflight');
|
||||
},
|
||||
prepareFrontend: async () => {
|
||||
order.push('frontend-ready');
|
||||
},
|
||||
spawnCli: () => {
|
||||
order.push('spawn');
|
||||
return child;
|
||||
@@ -127,7 +154,13 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(order).toEqual(['preflight', 'spawn', 'exit', 'cleanup']);
|
||||
expect(order).toEqual([
|
||||
'preflight',
|
||||
'frontend-ready',
|
||||
'spawn',
|
||||
'exit',
|
||||
'cleanup',
|
||||
]);
|
||||
});
|
||||
|
||||
const posixTest = process.platform === 'win32' ? test.skip : test;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user