修复 AGC 模型选择入口、交互与默认模型回退 #299

Merged
kdletters merged 8 commits from fix/agc-model-selector into master 2026-09-08 22:05:15 +08:00
9 changed files with 337 additions and 44 deletions
@@ -1,5 +1,5 @@
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { GameCreatorAppConfigView } from '../../app/types';
@@ -9,21 +9,25 @@ import {
} from '../../services/clientApi';
export function ConversationModelSelect({
className,
disabled,
onReady,
}: {
className?: string;
disabled: boolean;
onReady: (ready: boolean) => void;
onReady?: (ready: boolean) => void;
}) {
const [models, setModels] = useState<ClientLlmModel[]>([]);
const [selected, setSelected] = useState('');
const [defaultModelId, setDefaultModelId] = useState('');
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const refresh = useCallback(async () => {
setBusy(true);
setError('');
onReady(false);
onReady?.(false);
try {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('Native host unavailable');
@@ -32,18 +36,28 @@ export function ConversationModelSelect({
invoke<GameCreatorAppConfigView>('read_game_creator_app_config'),
]);
setModels(catalog.models);
const id = config.config.selectedModelId || catalog.defaultModelId;
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 && !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);
onReady?.(available);
if (!available) setError('请选择可用模型');
} catch {
setModels([]);
@@ -57,8 +71,29 @@ export function ConversationModelSelect({
void refresh();
}, [refresh]);
useEffect(() => {
if (!open) return;
function handleOutsidePointerDown(event: MouseEvent) {
const target = event.target as Node | null;
if (containerRef.current && !containerRef.current.contains(target)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsidePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsidePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
async function select(id: string) {
onReady(false);
onReady?.(false);
setBusy(true);
setError('');
try {
@@ -71,7 +106,7 @@ export function ConversationModelSelect({
if (result.config.selectedModelId !== id)
throw new Error('Selection was not saved');
setSelected(id);
onReady(true);
onReady?.(true);
} catch {
setError('模型选择保存失败');
} finally {
@@ -80,7 +115,14 @@ export function ConversationModelSelect({
}
return (
<div className="conversation-model-select">
<div
ref={containerRef}
className={
className
? `conversation-model-select ${className}`
: 'conversation-model-select'
}
>
{error ? <span role="alert">{error}</span> : null}
<button
type="button"
@@ -88,7 +130,7 @@ export function ConversationModelSelect({
aria-label="对话模型"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || busy}
disabled={disabled}
suzmii marked this conversation as resolved
Review

[P2] 保持模型保存期间的并发保护

这里移除 busy 限制后,用户在一次 select_game_creator_model 尚未返回时可以重新打开菜单并再选一次,选项本身也没有禁用。连续选择“快速”再选择“高质量”,挂起两条 invoke 并只完成第一条,就会触发 select() 中的 onReady(true) 和 setBusy(false);此时第二条仍未保存,项目发送按钮却已解锁,显示/配置仍对应前一个模型。已用当前组件和延迟 Promise 复现。可以允许查看菜单,但应在保存期间禁用新的选项操作,或串行处理选择并仅在最新选择落盘后恢复 ready。

[P2] 保持模型保存期间的并发保护 这里移除 busy 限制后,用户在一次 select_game_creator_model 尚未返回时可以重新打开菜单并再选一次,选项本身也没有禁用。连续选择“快速”再选择“高质量”,挂起两条 invoke 并只完成第一条,就会触发 select() 中的 onReady(true) 和 setBusy(false);此时第二条仍未保存,项目发送按钮却已解锁,显示/配置仍对应前一个模型。已用当前组件和延迟 Promise 复现。可以允许查看菜单,但应在保存期间禁用新的选项操作,或串行处理选择并仅在最新选择落盘后恢复 ready。
onClick={() => setOpen((current) => !current)}
>
<span
@@ -107,18 +149,29 @@ export function ConversationModelSelect({
role="listbox"
aria-label="对话模型"
>
{busy && models.length === 0 ? (
<span className="conversation-model-menu-loading">
</span>
) : null}
{models.map((model) => (
<button
key={model.id}
type="button"
role="option"
aria-selected={model.id === selected}
disabled={disabled || busy}
onClick={() => {
setOpen(false);
void select(model.id);
}}
>
<span>{model.displayName}</span>
<span className="conversation-model-menu-option-main">
<span>{model.displayName}</span>
{model.id === defaultModelId ? (
<em className="conversation-model-menu-default"></em>
) : null}
</span>
{model.id === selected ? (
<Check size={13} aria-hidden="true" />
) : null}
@@ -353,7 +353,9 @@ export function ProjectSupervisorView({
/>
{directCodex ? (
<ConversationModelSelect
disabled={runtimePanelProps.controlBusy || needsUserInput}
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
// 当前回合不受影响;发送按钮仍由 controlBusy / modelReady 把关。
disabled={needsUserInput}
onReady={setModelReady}
/>
) : null}
+33 -3
View File
@@ -8397,7 +8397,11 @@ iframe.preview-frame {
max-width: calc(100% - 64px);
pointer-events: auto;
}
.project-supervisor-surface.is-direct-codex .conversation-model-trigger {
.home-input-model-select {
position: relative;
min-width: 0;
}
.conversation-model-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -8439,8 +8443,8 @@ iframe.preview-frame {
}
.conversation-model-menu {
position: absolute;
right: 46px;
bottom: 43px;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
display: grid;
min-width: 150px;
@@ -8478,6 +8482,32 @@ iframe.preview-frame {
color: var(--platform-text-strong, #111827);
outline: none;
}
.conversation-model-menu-option-main {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.conversation-model-menu-default {
flex: 0 0 auto;
padding: 0 5px;
border: 1px solid var(--platform-line-soft, #e2cbb8);
border-radius: 5px;
background: var(--platform-button-secondary-fill, rgba(255, 253, 250, 0.78));
color: var(--platform-text-soft, #988476);
font-size: 10px;
font-style: normal;
line-height: 1.7;
}
.conversation-model-menu-loading {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 9px;
color: var(--platform-text-soft, #6b7280);
font-size: 12px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
@@ -14,6 +14,7 @@ import { useRef, useState } from 'react';
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
import type { ProjectStartMode } from '../../app/types';
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
import RichInputArea, { UploadButton } from './components/RichInputArea';
import {
richTextToAttachments,
@@ -240,25 +241,33 @@ export default function HomeView({
}}
>
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<UploadButton />
<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"
type="submit"
aria-label={
startMode === 'planning' ? '进入立项策划' : '开启创作'
}
disabled={homeCreationBusy}
>
{homeCreationBusy ? (
<Loader2
size={16}
aria-hidden="true"
className="animate-spin"
/>
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
<div className="flex min-w-0 items-center gap-1.5">
<UploadButton />
</div>
<div className="flex shrink-0 items-center gap-1.5">
<ConversationModelSelect
className="home-input-model-select"
disabled={homeCreationBusy}
/>
<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"
type="submit"
aria-label={
startMode === 'planning' ? '进入立项策划' : '开启创作'
}
disabled={homeCreationBusy}
>
{homeCreationBusy ? (
<Loader2
size={16}
aria-hidden="true"
className="animate-spin"
/>
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
</div>
</div>
</RichInputArea>
</form>
@@ -62,6 +62,46 @@ function ApprovedGddStartHarness() {
}
export function registerClientHomeTests() {
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' } };
}
if (command === 'select_game_creator_model') {
return {
config: { selectedModelId: (args as { modelId: string }).modelId },
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
// 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。
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);
fireEvent.click(modelTrigger);
await waitFor(() =>
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(),
);
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
}),
);
expect(modelTrigger.textContent).toContain('快速');
expect(createButton).toHaveProperty('disabled', false);
});
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
renderLauncherAt('/?launcher');
@@ -1220,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');
@@ -1242,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 () => {
@@ -4695,6 +4695,50 @@ export function registerUserSurfaceBoundaryTests() {
}
export function registerProjectSupervisorSurfaceTests() {
it('allows selecting the model on the first direct-project entry', async () => {
const projectPath = '/tmp/first-entry-model-select';
const manifest = createGameCreationAppManifest(
'first-entry-model-select',
'首次进入模型选择',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
const surface = await screen.findByLabelText('陶泥儿项目对话');
const trigger = within(surface).getByRole('button', { name: '对话模型' });
await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false));
fireEvent.click(trigger);
await waitFor(() =>
expect(
within(surface).getByRole('option', { name: '快速' }),
).not.toBeNull(),
);
fireEvent.click(within(surface).getByRole('option', { name: '快速' }));
await waitFor(() => expect(trigger.textContent).toContain('快速'));
});
it('runs a top workbench play request without a second confirmation', async () => {
const projectPath = '/tmp/top-play-request';
const supervisorHarness = createProjectSupervisorRuntimeHarness({
@@ -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',
@@ -5,6 +5,7 @@ import {
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
@@ -53,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 () => {
@@ -88,3 +99,80 @@ test('a failed save keeps submission unavailable', async () => {
await screen.findByText('模型选择保存失败');
expect(onReady).toHaveBeenLastCalledWith(false);
});
test('closes the menu when clicking outside', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
fireEvent.mouseDown(document.body);
await waitFor(() =>
expect(screen.queryByRole('option', { name: '快速' })).toBeNull(),
);
});
test('closes the menu on Escape', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('option', { name: '快速' })).toBeNull(),
);
});
test('marks the default model in the menu', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
const qualityOption = screen.getByRole('option', { name: /高质量/ });
expect(within(qualityOption).getByText('默认')).not.toBeNull();
const fastOption = screen.getByRole('option', { name: '快速' });
expect(within(fastOption).queryByText('默认')).toBeNull();
});
test('keeps model options disabled while a selection save is in flight', async () => {
let resolveSave: ((value: unknown) => void) | undefined;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
return new Promise((resolve) => {
resolveSave = resolve;
});
}
return { config: { selectedModelId: 'quality' } };
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await screen.findByRole('option', { name: '快速' });
fireEvent.click(screen.getByRole('option', { name: '快速' }));
// 保存期间重新打开菜单:可以查看,但选项应禁用,避免并发选择。
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('option', { name: /高质量/ })).toHaveProperty(
'disabled',
true,
);
expect(onReady).toHaveBeenLastCalledWith(false);
resolveSave?.({ config: { selectedModelId: 'fast' } });
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
'disabled',
false,
);
});
@@ -8,6 +8,8 @@
- `GET /api/llm/models` 返回启用项的 `id/displayName``defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
## 验收