修复 AGC 模型选择入口、交互与默认模型回退 #299
+65
-12
@@ -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
|
||||
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}
|
||||
|
||||
@@ -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` 把关。
|
||||
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
|
||||
|
||||
## 验收
|
||||
|
||||
Reference in New Issue
Block a user
[P2] 保持模型保存期间的并发保护
这里移除 busy 限制后,用户在一次 select_game_creator_model 尚未返回时可以重新打开菜单并再选一次,选项本身也没有禁用。连续选择“快速”再选择“高质量”,挂起两条 invoke 并只完成第一条,就会触发 select() 中的 onReady(true) 和 setBusy(false);此时第二条仍未保存,项目发送按钮却已解锁,显示/配置仍对应前一个模型。已用当前组件和延迟 Promise 复现。可以允许查看菜单,但应在保存期间禁用新的选项操作,或串行处理选择并仅在最新选择落盘后恢复 ready。