模型选择始终回退默认模型,不再出现请选择模型
Project CI / Frontend tests (pull_request) Successful in 1h17m16s
Project CI / Repository checks (pull_request) Successful in 1h17m52s
Project CI / Backend tests (pull_request) Successful in 21m51s
Project CI / Native shell tests (pull_request) Successful in 30m8s

ConversationModelSelect 在已保存选择失效时回退到后台默认模型,只要默认模型可用就不会出现「选择模型」空态

首页模型选择器改为挂载即加载目录,触发按钮直接落在默认模型上(移除懒加载)

首页不再需要 lazy 路径,清理组件中已无调用方的懒加载逻辑

同步更新模型选择相关回归测试与首页/运行时配置用例
This commit is contained in:
2026-09-07 14:50:34 +08:00
parent 70fdd186cf
commit 2703acda84
5 changed files with 64 additions and 31 deletions
@@ -11,21 +11,18 @@ import {
export function ConversationModelSelect({
className,
disabled,
lazy = false,
onReady,
}: {
className?: string;
disabled: boolean;
lazy?: boolean;
onReady?: (ready: boolean) => void;
}) {
const [models, setModels] = useState<ClientLlmModel[]>([]);
const [selected, setSelected] = useState('');
const [defaultModelId, setDefaultModelId] = useState('');
const [busy, setBusy] = useState(!lazy);
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [open, setOpen] = useState(false);
const initializedRef = useRef(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const refresh = useCallback(async () => {
setBusy(true);
@@ -40,16 +37,25 @@ export function ConversationModelSelect({
]);
setModels(catalog.models);
setDefaultModelId(catalog.defaultModelId);
const id = config.config.selectedModelId || 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 && !config.config.selectedModelId) {
if (available && savedSelection !== id) {
const saved = await invoke<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: id },
);
if (saved.config.selectedModelId !== id)
throw new Error('Default selection was not saved');
throw new Error('Model selection was not saved');
}
onReady?.(available);
if (!available) setError('请选择可用模型');
@@ -62,9 +68,8 @@ export function ConversationModelSelect({
}, [onReady]);
useEffect(() => {
if (lazy) return;
void refresh();
}, [refresh, lazy]);
}, [refresh]);
useEffect(() => {
if (!open) return;
@@ -126,13 +131,7 @@ export function ConversationModelSelect({
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => {
if (lazy && !initializedRef.current) {
initializedRef.current = true;
void refresh();
}
setOpen((current) => !current);
}}
onClick={() => setOpen((current) => !current)}
>
<span
className="conversation-model-trigger-status"
@@ -248,7 +248,6 @@ export default function HomeView({
<ConversationModelSelect
className="home-input-model-select"
disabled={homeCreationBusy}
lazy
/>
<button
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
@@ -62,7 +62,7 @@ function ApprovedGddStartHarness() {
}
export function registerClientHomeTests() {
it('adds a model selector to the home composer and lazily loads the catalog on open', async () => {
it('adds a model selector to the home composer and shows the default model', async () => {
const invoke = vi.fn(async (command: string, args?: unknown) => {
if (command === 'read_game_creator_app_config') {
return { config: { selectedModelId: 'quality' } };
@@ -77,14 +77,14 @@ export function registerClientHomeTests() {
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
// Lazy: the home composer must not read config or load models on mount.
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_app_config',
expect.anything(),
// 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'),
);
const modelTrigger = await screen.findByRole('button', {
name: '对话模型',
});
await waitFor(() => expect(modelTrigger.textContent).toContain('高质量'));
const createButton = screen.getByRole('button', { name: '开启创作' });
expect(createButton).toHaveProperty('disabled', false);
@@ -1260,7 +1260,17 @@ export function registerHomeProjectCreationTests() {
});
it('keeps only open and create project actions without exposing a Linux fallback', () => {
const invoke = vi.fn();
const invoke = vi.fn(async (command: string, args?: unknown) => {
if (command === 'read_game_creator_app_config') {
return { config: { selectedModelId: 'quality' } };
}
if (command === 'select_game_creator_model') {
return {
config: { selectedModelId: (args as { modelId: string }).modelId },
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
@@ -1282,7 +1292,12 @@ export function registerHomeProjectCreationTests() {
screen.queryByRole('button', { name: '在文件管理器中显示' }),
).toBeNull();
expect(screen.queryByRole('button', { name: /Godot 项目/ })).toBeNull();
expect(invoke).not.toHaveBeenCalled();
// 首页模型选择器会在挂载时读取模型目录(read_game_creator_app_config),
// 这里只校验没有打开工作区窗口或其它项目操作被触发。
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_workspace_window',
expect.anything(),
);
});
it('opens the directory selected by the native picker', async () => {
@@ -692,9 +692,16 @@ export function registerRuntimeSettingsTests() {
}) => void)
| undefined;
let readCount = 0;
let modelReadResolved = false;
const invoke = vi.fn((command: string) => {
if (command === 'read_game_creator_app_config') {
readCount += 1;
// 首页模型选择器会在挂载时读取一次配置;让首次读取立即完成,
// 以免一直处于 pending 干扰运行时配置对话框的读取计数。
if (!modelReadResolved) {
modelReadResolved = true;
return Promise.resolve({ config: { selectedModelId: 'quality' } });
}
return new Promise((resolve) => {
resolveRead = resolve as typeof resolveRead;
});
@@ -723,10 +730,13 @@ export function registerRuntimeSettingsTests() {
true,
);
// 首页模型选择器挂载时会读取一次配置(上面已让首次读取立即完成),
// 这里只校验:对话框处于「正在读取」时点击读取/保存不会新增读取请求。
const readsWhileReading = readCount;
fireEvent.click(screen.getByRole('button', { name: '读取' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(readCount).toBe(1);
expect(readCount).toBe(readsWhileReading);
await act(async () => {
resolveRead?.({
path: '/home/test/AppData/game-creator.config.json',
@@ -54,15 +54,25 @@ test('only displays aliases and persists selection through the native command',
).toContain('快速');
});
test('does not mark a removed selection ready or expose the old identifier', async () => {
invoke.mockResolvedValue({
config: { selectedModelId: 'private-old-model' },
});
test('falls back to the default model when the saved selection was removed', async () => {
invoke.mockImplementation(async (command, input) => ({
config: {
selectedModelId:
command === 'select_game_creator_model'
? (input as { modelId: string }).modelId
: 'private-old-model',
},
}));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('请选择可用模型');
expect(onReady).toHaveBeenLastCalledWith(false);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('private-old-model')).toBeNull();
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'quality',
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
});
test('failed catalog can be refreshed without enabling submission', async () => {