优化AGC客户端同步LLM配置 #306

Merged
suzmii merged 5 commits from feat/agc-client-sync-llm-config into master 2026-09-09 13:19:44 +08:00
18 changed files with 695 additions and 83 deletions
@@ -2007,13 +2007,16 @@ pub(crate) fn write_game_creator_app_config(
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
config.selected_model_id = load_game_creator_app_config()?.selected_model_id;
let stored = load_game_creator_app_config()?;
config.selected_model_id = stored.selected_model_id;
config.selected_model_is_default = stored.selected_model_is_default;
persist_game_creator_app_config(config)
}
#[tauri::command]
pub(crate) fn select_game_creator_model(
model_id: String,
is_default: bool,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
@@ -2028,6 +2031,7 @@ pub(crate) fn select_game_creator_model(
}
let mut config = load_game_creator_app_config()?;
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
persist_game_creator_app_config(config)
}
@@ -3623,6 +3623,9 @@ pub(crate) fn merge_game_creator_config_file(
if let Some(selected_model_id) = file_config.selected_model_id {
config.selected_model_id = selected_model_id;
}
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
config.selected_model_is_default = selected_model_is_default;
}
Ok(())
}
@@ -1022,6 +1022,8 @@ struct GameCreatorAppConfigFile {
planning: Option<GameCreatorPlanningConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_is_default: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -1084,6 +1086,8 @@ struct GameCreatorAppConfig {
planning: GameCreatorPlanningConfig,
#[serde(default)]
selected_model_id: String,
#[serde(default)]
selected_model_is_default: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -1609,6 +1613,7 @@ impl Default for GameCreatorAppConfig {
editor_api: GameCreatorEditorApiConfig::default(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: String::new(),
selected_model_is_default: false,
}
}
}
@@ -738,6 +738,7 @@ fn app_config_commands_write_runtime_config_file() {
agent_llm,
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
})
.expect("write runtime config");
@@ -806,6 +807,28 @@ fn app_config_commands_write_runtime_config_file() {
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn model_selection_default_flag_round_trips() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let saved =
select_game_creator_model("quality".to_string(), true).expect("select default model");
assert_eq!(saved.config.selected_model_id, "quality");
assert!(saved.config.selected_model_is_default);
let saved =
select_game_creator_model("fast".to_string(), false).expect("select explicit model");
assert_eq!(saved.config.selected_model_id, "fast");
assert!(!saved.config.selected_model_is_default);
let reloaded = read_game_creator_app_config().expect("read runtime config");
assert_eq!(reloaded.config.selected_model_id, "fast");
assert!(!reloaded.config.selected_model_is_default);
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn app_config_write_rejects_invalid_api_kind() {
let root = unique_project_path();
@@ -824,6 +847,7 @@ fn app_config_write_rejects_invalid_api_kind() {
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
});
assert!(result
@@ -850,6 +874,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
});
assert!(result
@@ -876,6 +901,7 @@ fn app_config_write_rejects_too_small_request_timeout() {
agent_llm: BTreeMap::new(),
planning: GameCreatorPlanningConfig::default(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
});
assert!(result
@@ -719,6 +719,7 @@ export interface GameCreatorAppConfig {
apiKey: string;
};
selectedModelId?: string;
selectedModelIsDefault?: boolean;
planning?: {
capabilityEnabled: boolean;
};
@@ -1,75 +1,251 @@
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Ref } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { GameCreatorAppConfigView } from '../../app/types';
import {
type ClientLlmModel,
loadClientLlmModels,
import type {
ClientLlmModel,
ClientLlmModelCatalog,
} from '../../services/clientApi';
import {
cachedLlmModelCatalog,
refreshLlmModelCatalog,
} from '../../services/llmModelCatalog';
export type ConversationModelSelectHandle = {
/** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */
ensureUsable: () => Promise<boolean>;
};
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
class ModelSelectionConfigError extends Error {}
export function ConversationModelSelect({
className,
disabled,
onReady,
projectPath,
ref,
}: {
className?: string;
disabled: boolean;
onReady?: (ready: boolean) => void;
projectPath?: string;
ref?: Ref<ConversationModelSelectHandle>;
}) {
const [models, setModels] = useState<ClientLlmModel[]>([]);
const initialCatalog = cachedLlmModelCatalog();
const [models, setModels] = useState<ClientLlmModel[]>(
initialCatalog?.models ?? [],
);
const [selected, setSelected] = useState('');
const [defaultModelId, setDefaultModelId] = useState('');
const [busy, setBusy] = useState(true);
const [defaultModelId, setDefaultModelId] = useState(
initialCatalog?.defaultModelId ?? '',
);
const [busy, setBusy] = useState(!initialCatalog);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
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);
setDefaultModelId(catalog.defaultModelId);
const savedSelection = config.config.selectedModelId;
// 优先沿用用户已选且仍可用的模型;已选模型被停用/移除时回退到默认模型,
// 避免下拉出现「请选择模型」的空态。默认模型由后台强制配置,缺失时才走错误提示。
const savedAvailable = savedSelection
? catalog.models.some((model) => model.id === savedSelection)
: false;
const id =
savedAvailable && savedSelection
? savedSelection
: catalog.defaultModelId;
setSelected(id);
const available = catalog.models.some((model) => model.id === id);
if (available && savedSelection !== id) {
const saved = await invoke<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: id },
);
if (saved.config.selectedModelId !== id)
throw new Error('Model selection was not saved');
}
onReady?.(available);
if (!available) setError('请选择可用模型');
} catch {
setModels([]);
setError('模型列表加载失败');
} finally {
setBusy(false);
}
const appliedRevisionRef = useRef<number | null>(
initialCatalog?.revision ?? null,
);
const selectedRef = useRef('');
const selectionEpochRef = useRef(0);
const busyTokenRef = useRef(0);
const configWriteChainRef = useRef<Promise<unknown>>(Promise.resolve());
const onReadyRef = useRef(onReady);
const mountedRef = useRef(true);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
void refresh();
}, [refresh]);
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const markReady = useCallback((ready: boolean) => {
onReadyRef.current?.(ready);
}, []);
// 配置写回串行化:目录同步的写回与用户选择按入队顺序落盘,用户选择最后写入。
const queueConfigWrite = useCallback(
(write: () => Promise<GameCreatorAppConfigView>) => {
const run = configWriteChainRef.current.then(write, write);
configWriteChainRef.current = run.then(
() => undefined,
() => undefined,
);
return run;
},
[],
);
const waitForConfigWrite = useCallback(
() => configWriteChainRef.current.then(() => undefined),
[],
);
const applyCatalog = useCallback(
async (
catalog: ClientLlmModelCatalog,
showBusy: boolean,
epochAtRequest: number,
) => {
if (
mountedRef.current &&
(appliedRevisionRef.current !== catalog.revision ||
!Number.isFinite(catalog.revision))
) {
appliedRevisionRef.current = catalog.revision;
setModels(catalog.models);
setDefaultModelId(catalog.defaultModelId);
}
const invoke = resolveTauriInvoke();
if (!invoke) throw new ModelSelectionConfigError('读取客户端配置失败');
// 有在途写回时先等它结束,避免读到旧配置、也避免用旧快照覆盖新选择。
await waitForConfigWrite();
if (selectionEpochRef.current !== epochAtRequest)
return Boolean(selectedRef.current);
const config = await invoke<GameCreatorAppConfigView>(
'read_game_creator_app_config',
).catch(() => null);
if (!config) throw new ModelSelectionConfigError('读取客户端配置失败');
if (selectionEpochRef.current !== epochAtRequest)
return Boolean(selectedRef.current);
const saved = config.config.selectedModelId;
const followsDefault = config.config.selectedModelIsDefault === true;
const enabled = (id: string) =>
catalog.models.some((model) => model.id === id);
const defaultEnabled = enabled(catalog.defaultModelId);
let next = '';
let nextIsDefault = false;
let nextNotice = '';
// 跟随默认项的选择会随后台默认模型变化;用户手动选择后不再被覆盖。
if (followsDefault && defaultEnabled) {
next = catalog.defaultModelId;
nextIsDefault = true;
if (saved && saved !== next)
nextNotice = '默认模型已更新,已切换为新的默认模型';
} else if (!followsDefault && saved && enabled(saved)) {
next = saved;
}
// 已选模型被停用/移除时回退默认模型,避免下拉出现「请选择模型」的空态。
if (!next && defaultEnabled) {
next = catalog.defaultModelId;
nextIsDefault = true;
if (saved) nextNotice = '所选模型已停用,已切换为默认模型';
}
if (next && (next !== saved || nextIsDefault !== followsDefault)) {
let persisted: GameCreatorAppConfigView;
try {
persisted = await queueConfigWrite(() =>
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: next,
isDefault: nextIsDefault,
}),
);
} catch {
throw new ModelSelectionConfigError('模型选择保存失败');
}
if (
persisted.config.selectedModelId !== next ||
persisted.config.selectedModelIsDefault !== nextIsDefault
)
throw new ModelSelectionConfigError('模型选择保存失败');
}
// 写回期间用户又做了新选择:保留新选择,不用本次快照覆盖界面。
if (selectionEpochRef.current !== epochAtRequest)
return Boolean(selectedRef.current);
const ready = Boolean(next);
if (!mountedRef.current) return ready;
selectedRef.current = next;
setSelected(next);
setNotice(nextNotice);
setError(ready ? '' : '请选择可用模型');
markReady(ready);
return ready;
},
[markReady, queueConfigWrite, waitForConfigWrite],
);
const syncCatalog = useCallback(
async (showBusy: boolean) => {
const busyToken = showBusy
? ++busyTokenRef.current
: busyTokenRef.current;
if (showBusy) {
if (mountedRef.current) {
setBusy(true);
setError('');
}
markReady(false);
}
const epochAtRequest = selectionEpochRef.current;
try {
let catalog: ClientLlmModelCatalog;
let usingCachedCatalog = false;
try {
catalog = await refreshLlmModelCatalog();
} catch {
const cached = cachedLlmModelCatalog();
if (!cached) {
if (mountedRef.current) setError('模型列表加载失败');
markReady(false);
return false;
}
catalog = cached;
usingCachedCatalog = true;
}
const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
if (usingCachedCatalog && mountedRef.current)
setError('模型列表加载失败');
return ready;
} catch (error) {
if (mountedRef.current) {
setError(
error instanceof ModelSelectionConfigError
? error.message
: '模型列表加载失败',
);
}
markReady(false);
return false;
} finally {
// 只有最新的 showBusy 同步负责收起加载态,失败路径也必须恢复可交互。
if (
showBusy &&
mountedRef.current &&
busyTokenRef.current === busyToken
) {
setBusy(false);
}
}
},
[applyCatalog, markReady],
);
useEffect(() => {
void syncCatalog(true);
}, [projectPath, syncCatalog]);
useEffect(() => {
function handleWindowFocus() {
void syncCatalog(false);
}
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
}, [syncCatalog]);
useEffect(() => {
if (!open) return;
@@ -92,25 +268,37 @@ export function ConversationModelSelect({
};
}, [open]);
const ensureUsable = useCallback(() => syncCatalog(true), [syncCatalog]);
useImperativeHandle(ref, () => ({ ensureUsable }), [ensureUsable]);
async function select(id: string) {
onReady?.(false);
selectionEpochRef.current += 1;
markReady(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 },
const result = await queueConfigWrite(() =>
invoke<GameCreatorAppConfigView>('select_game_creator_model', {
modelId: id,
isDefault: false,
}),
);
if (result.config.selectedModelId !== id)
throw new Error('Selection was not saved');
setSelected(id);
onReady?.(true);
if (mountedRef.current) {
selectedRef.current = id;
setSelected(id);
setNotice('');
}
markReady(true);
} catch {
setError('模型选择保存失败');
if (mountedRef.current) setError('模型选择保存失败');
markReady(false);
} finally {
setBusy(false);
if (mountedRef.current) setBusy(false);
}
}
@@ -124,6 +312,7 @@ export function ConversationModelSelect({
}
>
{error ? <span role="alert">{error}</span> : null}
{notice ? <span role="status">{notice}</span> : null}
<button
type="button"
className="conversation-model-trigger"
@@ -131,7 +320,11 @@ export function ConversationModelSelect({
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onClick={() => {
const nextOpen = !open;
setOpen(nextOpen);
if (nextOpen) void syncCatalog(false);
}}
>
<span
className="conversation-model-trigger-status"
@@ -183,7 +376,7 @@ export function ConversationModelSelect({
className="conversation-model-menu-refresh"
aria-label="刷新模型列表"
disabled={disabled || busy}
onClick={() => void refresh()}
onClick={() => void syncCatalog(true)}
>
<RefreshCcw size={13} aria-hidden="true" />
<span></span>
@@ -7,7 +7,7 @@ import type {
SetStateAction,
UIEventHandler,
} from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type {
AgentStatusCard,
@@ -28,7 +28,10 @@ import {
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
import { ConversationModelSelect } from './ConversationModelSelect';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from './ConversationModelSelect';
import { PlanGddSurface } from './GddApprovalCard';
import {
pendingCommandDetail,
@@ -146,6 +149,9 @@ export function ProjectSupervisorView({
: '发送';
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
return (
<section
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
@@ -322,16 +328,33 @@ export function ProjectSupervisorView({
<form
className="project-supervisor-composer"
onSubmit={(event) => {
if (directCodex && !modelReady) {
event.preventDefault();
if (!directCodex) {
onSubmit(event);
return;
}
onSubmit(event);
event.preventDefault();
if (modelValidateInFlightRef.current) return;
modelValidateInFlightRef.current = true;
setModelValidating(true);
const validateModel = async () => {
try {
const ready = modelSelectRef.current
? await modelSelectRef.current.ensureUsable()
: modelReady;
if (ready) onSubmit(event);
} finally {
modelValidateInFlightRef.current = false;
setModelValidating(false);
}
};
void validateModel();
}}
>
<textarea
aria-label={directCodex ? '陶泥儿对话内容' : '项目需求'}
disabled={runtimePanelProps.controlBusy || needsUserInput}
disabled={
runtimePanelProps.controlBusy || needsUserInput || modelValidating
}
rows={3}
value={chatInput}
placeholder={
@@ -353,10 +376,12 @@ export function ProjectSupervisorView({
/>
{directCodex ? (
<ConversationModelSelect
ref={modelSelectRef}
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
// 当前回合不受影响;发送按钮仍由 controlBusy / modelReady 把关。
disabled={needsUserInput}
onReady={setModelReady}
projectPath={projectPath}
/>
) : null}
<button
@@ -366,7 +391,7 @@ export function ProjectSupervisorView({
disabled={
runtimePanelProps.controlBusy ||
needsUserInput ||
(directCodex && !modelReady)
(directCodex && (!modelReady || modelValidating))
}
>
{submitting ? (
@@ -184,8 +184,14 @@ export type ClientLlmModel = {
id: string;
};
export type ClientLlmModelCatalog = {
defaultModelId: string;
models: ClientLlmModel[];
revision: number;
};
export function loadClientLlmModels() {
return requestClientApi<{ models: ClientLlmModel[]; defaultModelId: string }>(
return requestClientApi<ClientLlmModelCatalog>(
'/api/llm/models',
{ method: 'GET' },
'读取可用模型失败',
@@ -0,0 +1,32 @@
import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi';
let cached: ClientLlmModelCatalog | null = null;
let inFlight: Promise<ClientLlmModelCatalog> | null = null;
/** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */
export function cachedLlmModelCatalog() {
return cached;
}
/**
* 读取模型目录;同一时刻只保留一个在途请求,重复触发复用同一结果。
* 读取失败时保留上一次成功目录,不覆盖调用方已生效的选择。
*/
export function refreshLlmModelCatalog() {
if (inFlight) return inFlight;
const request = loadClientLlmModels()
.then((catalog) => {
cached = catalog;
return catalog;
})
.finally(() => {
if (inFlight === request) inFlight = null;
});
inFlight = request;
return request;
}
export function resetLlmModelCatalogCacheForTest() {
cached = null;
inFlight = null;
}
@@ -8632,3 +8632,12 @@ iframe.preview-frame {
background: #fff;
font-size: 12px;
}
.conversation-model-select [role='status'] {
position: absolute;
bottom: 36px;
right: 0;
max-width: 220px;
color: #6b7280;
background: #fff;
font-size: 12px;
}
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlanGddStateViewV1 } from '../../src/app/types';
import * as clientApi from '../../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog';
import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore';
const nativeClipboardMock = vi.hoisted(() => ({
@@ -998,12 +999,14 @@ async function openMainProject(projectPath: string) {
}
beforeEach(() => {
resetLlmModelCatalogCacheForTest();
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
@@ -96,6 +96,7 @@ export function registerClientHomeTests() {
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
}),
);
expect(modelTrigger.textContent).toContain('快速');
@@ -711,6 +711,8 @@ export function registerRuntimeSettingsTests() {
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
// 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。
await waitFor(() => expect(readCount).toBe(1));
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
@@ -7,18 +8,26 @@ import {
waitFor,
within,
} from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { resolveTauriInvoke } from '../src/app/tauri';
import { ConversationModelSelect } from '../src/features/project-workspace/ConversationModelSelect';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from '../src/features/project-workspace/ConversationModelSelect';
import { loadClientLlmModels } from '../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() }));
const invoke = vi.fn();
let savedModelId = 'quality';
let savedModelIsDefault = true;
beforeEach(() => {
vi.clearAllMocks();
resetLlmModelCatalogCacheForTest();
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
@@ -26,13 +35,22 @@ beforeEach(() => {
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
savedModelId = 'quality';
savedModelIsDefault = true;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
invoke.mockImplementation(async (command, input) => ({
config: {
selectedModelId:
command === 'select_game_creator_model' ? input.modelId : 'quality',
},
}));
});
afterEach(cleanup);
@@ -46,6 +64,7 @@ test('only displays aliases and persists selection through the native command',
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
}),
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
@@ -61,14 +80,20 @@ test('falls back to the default model when the saved selection was removed', asy
command === 'select_game_creator_model'
? (input as { modelId: string }).modelId
: 'private-old-model',
selectedModelIsDefault:
command === 'select_game_creator_model'
? Boolean((input as { isDefault: boolean }).isDefault)
: false,
},
}));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('所选模型已停用,已切换为默认模型');
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('private-old-model')).toBeNull();
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'quality',
isDefault: true,
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
@@ -89,7 +114,9 @@ test('failed catalog can be refreshed without enabling submission', async () =>
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' } };
return {
config: { selectedModelId: 'quality', selectedModelIsDefault: true },
};
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
@@ -140,13 +167,15 @@ test('marks the default model in the menu', async () => {
test('keeps model options disabled while a selection save is in flight', async () => {
let resolveSave: ((value: unknown) => void) | undefined;
invoke.mockImplementation(async (command, input) => {
invoke.mockImplementation(async (command) => {
if (command === 'select_game_creator_model') {
return new Promise((resolve) => {
resolveSave = resolve;
});
}
return { config: { selectedModelId: 'quality' } };
return {
config: { selectedModelId: 'quality', selectedModelIsDefault: true },
};
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
@@ -169,6 +198,8 @@ test('keeps model options disabled while a selection save is in flight', async (
);
expect(onReady).toHaveBeenLastCalledWith(false);
// 配置写回按队列落盘,保存请求在下一个微任务才发出。
await waitFor(() => expect(resolveSave).toBeDefined());
resolveSave?.({ config: { selectedModelId: 'fast' } });
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
@@ -176,3 +207,266 @@ test('keeps model options disabled while a selection save is in flight', async (
false,
);
});
test('keeps the last good catalog when a background refresh fails', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline'));
fireEvent(window, new Event('focus'));
await screen.findByText('模型列表加载失败');
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: /高质量/ })).not.toBeNull();
expect(onReady).toHaveBeenLastCalledWith(true);
});
test('applies a new catalog revision on focus', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
{ id: 'vision', displayName: '视觉' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull();
});
test('pre-send validation falls back when the selected model is disabled', async () => {
let savedModelId = 'quality';
let savedModelIsDefault = false;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const ref = createRef<ConversationModelSelectHandle>();
const onReady = vi.fn();
render(
<ConversationModelSelect ref={ref} disabled={false} onReady={onReady} />,
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() => expect(savedModelId).toBe('fast'));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量' }],
revision: 2,
});
await act(async () => {
await expect(ref.current?.ensureUsable()).resolves.toBe(true);
});
expect(screen.getByText('所选模型已停用,已切换为默认模型')).not.toBeNull();
expect(savedModelId).toBe('quality');
});
test('follows the new server default when the saved selection was the default', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'fast',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(savedModelId).toBe('fast'));
expect(savedModelIsDefault).toBe(true);
expect(
screen.getByText('默认模型已更新,已切换为新的默认模型'),
).not.toBeNull();
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
test('keeps an explicit selection when the server default changes', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() => expect(savedModelId).toBe('fast'));
expect(savedModelIsDefault).toBe(false);
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() =>
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速'),
);
expect(savedModelId).toBe('fast');
expect(savedModelIsDefault).toBe(false);
});
test('recovers the selector when reading the native config fails', async () => {
invoke.mockRejectedValueOnce(new Error('config unreadable'));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('读取客户端配置失败');
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false));
// 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(
screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('读取客户端配置失败')).toBeNull();
});
test('pre-send validation waits for an in-flight selection save', async () => {
let resolveSave: (() => void) | undefined;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
await new Promise<void>((resolve) => {
resolveSave = () => {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
resolve();
};
});
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const ref = createRef<ConversationModelSelectHandle>();
const onReady = vi.fn();
render(
<ConversationModelSelect ref={ref} disabled={false} onReady={onReady} />,
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
let settled = false;
let pending!: Promise<boolean>;
await act(async () => {
pending = ref.current!.ensureUsable().finally(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(settled).toBe(false);
resolveSave?.();
await act(async () => {
await expect(pending).resolves.toBe(true);
});
expect(savedModelId).toBe('fast');
expect(savedModelIsDefault).toBe(false);
});
test('reuses a single in-flight catalog request', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(loadClientLlmModels).toHaveBeenCalledTimes(1);
fireEvent(window, new Event('focus'));
fireEvent(window, new Event('focus'));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
expect(loadClientLlmModels).toHaveBeenCalledTimes(2);
});
test('keeps refreshing when the server omits the catalog revision', async () => {
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: undefined as unknown as number,
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// 旧服务端不返回 revision 时,两次响应的 revision 都是 undefined
// 不能因此判定「未变化」而停止刷新。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'vision', displayName: '视觉' },
],
revision: undefined as unknown as number,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull();
});
test('applies a catalog when the revision goes backwards', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// 服务端目录重建后 revision 可能回退,仍要按「已变化」处理。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'vision', displayName: '视觉' },
],
revision: 0,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull();
});
test('keeps the applied catalog when the revision is unchanged', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// revision 未变化时不更新界面,沿用已应用的目录。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'vision', displayName: '视觉' },
],
revision: 1,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.queryByRole('option', { name: '视觉' })).toBeNull();
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
});
@@ -5,10 +5,12 @@
- 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId``enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId``selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口与项目对话共用同一份目录缓存、挂载即加载(失败时沿用上一次成功目录),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
@@ -16,4 +18,5 @@
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
- AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。
@@ -661,7 +661,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 私有单例表,主键 `id=0`,保存 `catalog_json``revision``updated_at`;不存凭据。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。
- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识别名。
- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识别名和目录 `revision`(供条件刷新,不暴露实际模型名)
- Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`
### `error_report`
@@ -39,6 +39,7 @@ mod model_catalog_tests {
#[test]
fn public_catalog_only_exposes_alias_and_stable_id() {
let mut catalog = module_runtime::AgcModelCatalog::default();
catalog.revision = 7;
catalog.models[1].enabled = false;
let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap();
assert_eq!(
@@ -46,6 +47,7 @@ mod model_catalog_tests {
json!([{"id": "quality", "displayName": "高质量"}])
);
assert_eq!(payload["defaultModelId"], "quality");
assert_eq!(payload["revision"], json!(7));
assert!(!payload.to_string().contains("gpt-"));
}
}
@@ -193,6 +195,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
display_name: model.alias,
})
.collect(),
revision: catalog.revision,
}
}
@@ -72,4 +72,6 @@ pub struct LlmModelSummary {
pub struct LlmModelsResponse {
pub default_model_id: String,
pub models: Vec<LlmModelSummary>,
/// 模型目录版本,客户端据此做条件刷新。
pub revision: u64,
}