a0e6a83691
### 背景 AGC 后台已支持维护 LLM 模型目录(`agc_model_catalog`),客户端此前只在项目右侧对话浮出模型选择,且存在几个体验问题: 1. 首页聊天框架缺少模型选择入口; 2. 首次进入项目时,右侧模型选择器「点不动」(被 disabled); 3. 模型选择下拉失去焦点不自动收起; 4. 默认模型缺少可视标识; 5. 已保存的选择被后台停用/移除后,客户端显示「请选择模型」,未自动回退默认模型;首页因懒加载在打开前也一直显示「选择模型」。 ### 改动内容(完整变更集) - **首页聊天框架新增模型选择入口**,复用项目右侧对话的 `ConversationModelSelect`;上传按钮在左,模型选择器与发送按钮在右(同一组,模型选择器紧挨发送按钮)。 - **修复首次进入项目时右侧模型选择器点不动**:选择器不再因目录加载(`busy`)或对话进行中(`controlBusy`)被禁用;切换模型只写回客户端配置并作用于后续轮次,发送仍由 `controlBusy` / `modelReady` 把关。 - **模型下拉支持点击外部 / Esc 自动收起**:给组件容器挂 ref,监听 `document` 的 `mousedown` 与 `keydown`,点击容器外或按 Esc 即收起(仅在展开时挂监听)。 - **默认模型增加「默认」标识**:组件记录 `defaultModelId`,在下拉选项中给默认模型显示小标签;亮暗主题自适应。 - **模型选择始终回退默认模型**:已保存选择仍可用时沿用;被停用/移除或从未选择时回退到后台默认模型并写回配置;只有默认模型本身不可用才走错误分流。首页由「懒加载」改为「挂载即加载」,触发按钮直接落在默认模型上,不再出现「选择模型」空态。 - **清理**:移除组件中已无调用方的 `lazy` 加载路径。 - **文档**:同步更新 `【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`(首页入口、对话进行中可切换模型、失焦收起等行为)。 ### 关键交互/行为说明 - 触发按钮永远显示模型名(默认或当前选择),不再出现「选择模型」占位,除非默认模型不可用。 - 模型下拉的加载中、刷新、选中态沿用原有交互;默认模型有「默认」标签,便于识别。 - 首页模型选择器不改动「开启创作」门控:目录不可用时仍可创建项目并走后台默认项。 ### 验证 - `npm run test -- apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx` -> 7/7 通过 - `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts` -> 386/386 通过 - `npm run ai-game-creator-shell:typecheck` 通过 - `npm run check:encoding`(4309 文件)通过 - `git diff --check` 通过 ### 影响范围与注意事项 - 变更集中在 AGC 客户端(`apps/ai-game-creator-shell`),不涉及 SpacetimeDB schema / 后端接口 / 契约。 - `GET /api/llm/models` 语义不变(仍只返回启用项 + 默认项),本次不改后端。 - 模型下拉在窄屏的换行/遮挡,以及「默认」标签对比度,建议在真机确认一下观感(单测只覆盖逻辑)。 ### 关联 - 无关联 Issue;本 PR 独立提交。 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: #299 Co-authored-by: 董羽秦 <suzmii@qq.com> Co-committed-by: 董羽秦 <suzmii@qq.com>
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} 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('button', { name: '对话模型' });
|
|
expect(screen.queryByText('gpt-6-astra')).toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
|
fireEvent.click(screen.getByRole('option', { name: '快速' }));
|
|
await waitFor(() =>
|
|
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
|
modelId: 'fast',
|
|
}),
|
|
);
|
|
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
|
expect(
|
|
screen.getByRole('button', { name: '对话模型' }).textContent,
|
|
).toContain('快速');
|
|
});
|
|
|
|
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 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 () => {
|
|
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: '对话模型' }));
|
|
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('button', { name: '对话模型' });
|
|
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
|
fireEvent.click(screen.getByRole('option', { name: '快速' }));
|
|
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,
|
|
);
|
|
});
|