修复 AGC 首页与项目对话的模型选择入口和交互

首页聊天框架右下角新增模型选择入口,复用项目右侧对话的模型选择器,目录按需加载且不阻塞开启创作

修复首次进入项目时右侧模型选择器点不动的问题:选择器不再因目录加载或对话进行中被禁用,切换模型只影响后续轮次

为模型选择器菜单增加加载占位与统一弹层定位,首页组件使用相对定位容器

同步更新 AGC 后台模型别名与对话选择技术方案文档

补充首页模型选择与首次进入项目可交互的回归测试
This commit is contained in:
2026-09-06 22:01:42 +08:00
parent 60fbee2353
commit 68e3457083
7 changed files with 147 additions and 16 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,26 @@ import {
} from '../../services/clientApi';
export function ConversationModelSelect({
className,
disabled,
lazy = false,
onReady,
}: {
className?: string;
disabled: boolean;
onReady: (ready: boolean) => void;
lazy?: boolean;
onReady?: (ready: boolean) => void;
}) {
const [models, setModels] = useState<ClientLlmModel[]>([]);
const [selected, setSelected] = useState('');
const [busy, setBusy] = useState(true);
const [busy, setBusy] = useState(!lazy);
const [error, setError] = useState('');
const [open, setOpen] = useState(false);
const initializedRef = useRef(false);
const refresh = useCallback(async () => {
setBusy(true);
setError('');
onReady(false);
onReady?.(false);
try {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('Native host unavailable');
@@ -43,7 +48,7 @@ export function ConversationModelSelect({
if (saved.config.selectedModelId !== id)
throw new Error('Default selection was not saved');
}
onReady(available);
onReady?.(available);
if (!available) setError('请选择可用模型');
} catch {
setModels([]);
@@ -54,11 +59,12 @@ export function ConversationModelSelect({
}, [onReady]);
useEffect(() => {
if (lazy) return;
void refresh();
}, [refresh]);
}, [refresh, lazy]);
async function select(id: string) {
onReady(false);
onReady?.(false);
setBusy(true);
setError('');
try {
@@ -71,7 +77,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 +86,13 @@ export function ConversationModelSelect({
}
return (
<div className="conversation-model-select">
<div
className={
className
? `conversation-model-select ${className}`
: 'conversation-model-select'
}
>
{error ? <span role="alert">{error}</span> : null}
<button
type="button"
@@ -88,8 +100,14 @@ export function ConversationModelSelect({
aria-label="对话模型"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || busy}
onClick={() => setOpen((current) => !current)}
disabled={disabled}
onClick={() => {
if (lazy && !initializedRef.current) {
initializedRef.current = true;
void refresh();
}
setOpen((current) => !current);
}}
>
<span
className="conversation-model-trigger-status"
@@ -107,6 +125,11 @@ 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}
@@ -339,7 +339,9 @@ export function ProjectSupervisorView({
/>
{directCodex ? (
<ConversationModelSelect
disabled={runtimePanelProps.controlBusy || needsUserInput}
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
// 当前回合不受影响;发送按钮仍由 controlBusy / modelReady 把关。
disabled={needsUserInput}
onReady={setModelReady}
/>
) : null}
+15 -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,14 @@ iframe.preview-frame {
color: var(--platform-text-strong, #111827);
outline: none;
}
.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,7 +241,14 @@ export default function HomeView({
}}
>
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<UploadButton />
<div className="flex min-w-0 items-center gap-1.5">
<UploadButton />
<ConversationModelSelect
className="home-input-model-select"
disabled={homeCreationBusy}
lazy
/>
</div>
<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"
@@ -62,6 +62,46 @@ function ApprovedGddStartHarness() {
}
export function registerClientHomeTests() {
it('adds a model selector to the home composer and lazily loads the catalog on open', 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');
// Lazy: the home composer must not read config or load models on mount.
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_app_config',
expect.anything(),
);
const modelTrigger = await screen.findByRole('button', {
name: '对话模型',
});
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');
@@ -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({
@@ -8,6 +8,8 @@
- `GET /api/llm/models` 返回启用项的 `id/displayName` 和 `defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
## 验收