feat(AGC): 模型选择器展示不可用模型与降级原因

- clientApi/llmModelCatalog 改用 @genarrative/shared 生成类型,自定义目录补 unavailableModels 空桶与 protocol
- 新增 modelAvailabilityCopy.ts:锁定项原因文案与按 reason 的降级提示
- ConversationModelSelect 渲染锁定项(不可点、可 focus、data-tooltip),降级提示按 reason 取值
- styles.css 补锁定项与 tooltip 样式(仅 hover / focus-visible)
- 新增 vitest 覆盖文案映射与锁定项渲染,vitest.config 纳入 features/project-workspace 用例
This commit is contained in:
2026-10-03 18:04:11 +08:00
parent 9437b71aee
commit 93fc9f98ba
8 changed files with 311 additions and 35 deletions
@@ -0,0 +1,123 @@
/** @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
catalog: {
defaultModelId: 'basic-model',
models: [
{
id: 'basic-model',
displayName: '基础模型',
agentMode: 'codex' as const,
protocol: 'openai_responses' as const,
},
],
unavailableModels: [
{
id: 'pro-model',
displayName: '专业模型',
agentMode: 'codex' as const,
protocol: 'openai_responses' as const,
reason: 'plan_required' as const,
},
{
id: 'retired-model',
displayName: '下线模型',
agentMode: 'cc' as const,
protocol: 'anthropic' as const,
reason: 'disabled' as const,
},
],
revision: 1,
},
}));
vi.mock('../../app/tauri', () => ({
resolveTauriInvoke: () => mocks.invoke,
}));
vi.mock('../../services/llmModelCatalog', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/llmModelCatalog')>();
return {
...actual,
cachedLlmModelCatalog: () => mocks.catalog,
refreshLlmModelCatalog: () => Promise.resolve(mocks.catalog),
};
});
import { ConversationModelSelect } from './ConversationModelSelect';
function mockConfig(selectedModelId: string, isDefault: boolean) {
mocks.invoke.mockImplementation(async (command: string) => {
if (command === 'read_game_creator_app_config') {
return {
config: {
selectedModelId,
selectedModelIsDefault: isDefault,
},
};
}
if (command === 'select_game_creator_model') {
return {
config: {
selectedModelId: 'basic-model',
selectedModelIsDefault: true,
},
};
}
return {};
});
}
afterEach(() => {
cleanup();
mocks.invoke.mockReset();
});
describe('ConversationModelSelect 锁定模型', () => {
it('不可用模型渲染为不可点、可聚焦的原因项', async () => {
mockConfig('basic-model', true);
render(<ConversationModelSelect disabled={false} />);
// 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith('read_game_creator_app_config'),
);
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
const planLocked = screen.getByText('专业模型').closest('[role="option"]');
expect(planLocked?.tagName).toBe('DIV');
expect(planLocked?.getAttribute('aria-disabled')).toBe('true');
expect(planLocked?.getAttribute('tabindex')).toBe('0');
expect(planLocked?.getAttribute('data-reason')).toBe('plan_required');
expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持');
const disabledLocked = screen
.getByText('下线模型')
.closest('[role="option"]');
expect(disabledLocked?.getAttribute('data-tooltip')).toBe('该模型已下线');
});
it('已选模型落进不可用桶时按 reason 回退默认并提示', async () => {
mockConfig('retired-model', false);
render(<ConversationModelSelect disabled={false} />);
await waitFor(() =>
expect(screen.getByRole('status').textContent).toBe(
'所选模型已下线,已切换为默认模型',
),
);
expect(mocks.invoke).toHaveBeenCalledWith(
'select_game_creator_model',
expect.objectContaining({ modelId: 'basic-model', isDefault: true }),
);
});
});
@@ -1,4 +1,4 @@
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react';
import type { Ref } from 'react';
import {
useCallback,
@@ -14,10 +14,15 @@ import {
cachedLlmModelCatalog,
type ClientLlmModel,
type ClientLlmModelCatalog,
type ClientLlmUnavailableModel,
LLM_CONFIG_CHANGED_EVENT,
LlmModelCatalogConfigError,
refreshLlmModelCatalog,
} from '../../services/llmModelCatalog';
import {
unavailableModelFallbackNotice,
unavailableModelReasonLabel,
} from './modelAvailabilityCopy';
/**
* 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。
@@ -76,6 +81,9 @@ export function ConversationModelSelect({
const [models, setModels] = useState<ClientLlmModel[]>(
initialCatalog?.models ?? [],
);
const [unavailableModels, setUnavailableModels] = useState<
ClientLlmUnavailableModel[]
>(initialCatalog?.unavailableModels ?? []);
const [selected, setSelected] = useState('');
const [defaultModelId, setDefaultModelId] = useState(
initialCatalog?.defaultModelId ?? '',
@@ -143,6 +151,7 @@ export function ConversationModelSelect({
) {
appliedRevisionRef.current = catalog.revision;
setModels(catalog.models);
setUnavailableModels(catalog.unavailableModels);
setDefaultModelId(catalog.defaultModelId);
}
const invoke = resolveTauriInvoke();
@@ -178,7 +187,13 @@ export function ConversationModelSelect({
if (!next && defaultEnabled) {
next = catalog.defaultModelId;
nextIsDefault = true;
if (saved) nextNotice = '所选模型已停用,已切换为默认模型';
if (saved) {
// 命中不可用桶时按 reason 给具体原因;两桶都没有(后台直接删除)走泛化文案。
nextNotice = unavailableModelFallbackNotice(
catalog.unavailableModels.find((model) => model.id === saved)
?.reason,
);
}
}
if (next && (next !== saved || nextIsDefault !== followsDefault)) {
let persisted: GameCreatorAppConfigView;
@@ -253,6 +268,7 @@ export function ConversationModelSelect({
appliedRevisionRef.current = null;
selectedRef.current = '';
setModels([]);
setUnavailableModels([]);
setSelected('');
setDefaultModelId('');
setError(modelCatalogErrorMessage(error));
@@ -315,6 +331,7 @@ export function ConversationModelSelect({
selectedRef.current = '';
setSelected('');
setModels([]);
setUnavailableModels([]);
void syncCatalog(true);
}
window.addEventListener('focus', handleWindowFocus);
@@ -455,6 +472,28 @@ export function ConversationModelSelect({
) : null}
</button>
))}
{/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */}
{unavailableModels.map((model) => {
const reasonLabel = unavailableModelReasonLabel(model.reason);
return (
<div
key={model.id}
role="option"
aria-selected={false}
aria-disabled="true"
aria-label={reasonLabel}
tabIndex={0}
className="conversation-model-menu-option conversation-model-menu-option-locked"
data-reason={model.reason}
data-tooltip={reasonLabel}
>
<span className="conversation-model-menu-option-main">
<span>{model.displayName}</span>
</span>
<Lock size={13} aria-hidden="true" />
</div>
);
})}
<div className="conversation-model-menu-divider" />
<button
type="button"
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import {
unavailableModelFallbackNotice,
unavailableModelReasonLabel,
} from './modelAvailabilityCopy';
describe('modelAvailabilityCopy', () => {
it('锁定项文案按 reason 取值,unknown 兜底', () => {
expect(unavailableModelReasonLabel('plan_required')).toBe('订阅计划不支持');
expect(unavailableModelReasonLabel('disabled')).toBe('该模型已下线');
expect(unavailableModelReasonLabel('unknown')).toBe('暂不可用');
});
it('降级文案按 reason 取值,两桶都没有时泛化', () => {
expect(unavailableModelFallbackNotice('plan_required')).toBe(
'所选模型需要更高订阅计划,已切换为默认模型',
);
expect(unavailableModelFallbackNotice('disabled')).toBe(
'所选模型已下线,已切换为默认模型',
);
expect(unavailableModelFallbackNotice('unknown')).toBe(
'所选模型已不可用,已切回默认模型',
);
expect(unavailableModelFallbackNotice(undefined)).toBe(
'所选模型已不可用,已切回默认模型',
);
});
});
@@ -0,0 +1,38 @@
import type { ClientLlmModelUnavailableReason } from '../../services/llmModelCatalog';
/**
* 锁定项 hover / 键盘 focus 的原因文案。
*
* `unknown` 是契约的前向兜底,后端将来新增 `reason` 时旧客户端仍给出可读文案。
*/
export function unavailableModelReasonLabel(
reason: ClientLlmModelUnavailableReason,
): string {
switch (reason) {
case 'plan_required':
return '订阅计划不支持';
case 'disabled':
return '该模型已下线';
default:
return '暂不可用';
}
}
/**
* 已选模型不可用时的降级提示。
*
* `unavailableReason` 命中目录不可用桶时按原因给具体文案;两桶都没有(后台直接删除)
* 传 `undefined`,用泛化文案。
*/
export function unavailableModelFallbackNotice(
unavailableReason: ClientLlmModelUnavailableReason | undefined,
): string {
switch (unavailableReason) {
case 'plan_required':
return '所选模型需要更高订阅计划,已切换为默认模型';
case 'disabled':
return '所选模型已下线,已切换为默认模型';
default:
return '所选模型已不可用,已切回默认模型';
}
}
@@ -1,39 +1,28 @@
import type {
AgcAgentMode,
AgcModelProtocol,
LlmModelsResponse,
LlmModelSummary,
LlmModelUnavailableReason,
LlmUnavailableModel,
} from '@genarrative/shared';
import { resolveTauriInvoke } from '../app/tauri';
export type ClientLlmModel = {
displayName: string;
id: string;
/** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */
agentMode: 'codex' | 'cc';
/** 上游线协议;服务端未下发时按执行器的默认协议解释。 */
protocol?: 'openai_responses' | 'openai_chat' | 'anthropic';
};
export type ClientLlmModelCatalog = {
defaultModelId: string;
models: ClientLlmModel[];
revision: number;
};
type ClientLlmModelWire = Omit<ClientLlmModel, 'agentMode'> & {
agentMode?: ClientLlmModel['agentMode'] | null;
};
type ClientLlmModelCatalogWire = Omit<ClientLlmModelCatalog, 'models'> & {
models: ClientLlmModelWire[];
};
/**
* 模型目录类型直接复用 Rust `shared-contracts::llm_catalog` 的 ts-rs 生成物:
* 不再手写第二份 `ClientLlmModel`,`agentMode` / `protocol` / `reason` 的取值以契约为准。
*/
export type ClientLlmModel = LlmModelSummary;
export type ClientLlmUnavailableModel = LlmUnavailableModel;
export type ClientLlmModelUnavailableReason = LlmModelUnavailableReason;
export type ClientLlmAgentMode = AgcAgentMode;
export type ClientLlmModelProtocol = AgcModelProtocol;
export type ClientLlmModelCatalog = LlmModelsResponse;
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
const invoke = resolveTauriInvoke();
if (!invoke) throw new Error('本地模型服务不可用');
const catalog = await invoke<ClientLlmModelCatalogWire>(
'load_game_creator_llm_models',
);
return {
...catalog,
models: catalog.models.map((model) => ({
...model,
agentMode: model.agentMode ?? 'codex',
})),
};
// Rust typed command 已经把响应解析成 `LlmModelsResponse`(含 unavailableModels)。
return invoke<ClientLlmModelCatalog>('load_game_creator_llm_models');
}
@@ -2,7 +2,14 @@ import { resolveTauriInvoke } from '../app/tauri';
import type { GameCreatorAppConfigView } from '../app/types';
import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi';
export type { ClientLlmModel, ClientLlmModelCatalog } from './clientApi';
export type {
ClientLlmAgentMode,
ClientLlmModel,
ClientLlmModelCatalog,
ClientLlmModelProtocol,
ClientLlmModelUnavailableReason,
ClientLlmUnavailableModel,
} from './clientApi';
let cached: ClientLlmModelCatalog | null = null;
let inFlight: Promise<ClientLlmModelCatalog> | null = null;
@@ -52,7 +59,9 @@ async function loadEffectiveCatalog(epoch: number) {
id,
displayName: id,
agentMode: 'codex' as const,
protocol: 'openai_responses' as const,
})),
unavailableModels: [],
revision: --localRevision,
};
}
+46
View File
@@ -11066,6 +11066,52 @@ button.design-workspace-tree__entry:hover,
font-style: normal;
line-height: 1.7;
}
.conversation-model-menu-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
min-height: 32px;
padding: 0 9px;
border-radius: 6px;
color: var(--platform-text-soft, #6b7280);
font: inherit;
font-size: 12px;
text-align: left;
}
.conversation-model-menu-option-locked {
position: relative;
cursor: not-allowed;
}
.conversation-model-menu-option-locked:hover,
.conversation-model-menu-option-locked:focus-visible {
background: var(--platform-button-ghost-fill, #f3f4f6);
outline: none;
}
/* 锁定项的原因提示:仅 hover / 键盘 focus 出现(AGC 桌面端,无移动端适配)。 */
.conversation-model-menu-option-locked[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
right: 0;
bottom: calc(100% + 4px);
z-index: 30;
display: none;
width: max-content;
max-width: 220px;
padding: 3px 7px;
border-radius: 6px;
background: rgb(17 24 39 / 92%);
color: #fff;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
pointer-events: none;
}
.conversation-model-menu-option-locked[data-tooltip]:hover::after,
.conversation-model-menu-option-locked[data-tooltip]:focus-visible::after {
display: block;
}
.conversation-model-menu-loading {
display: flex;
align-items: center;
+3
View File
@@ -165,6 +165,9 @@ export default defineConfig({
// DirectProject 会话滚动的判据与 hook 与实现同目录:改滚动契约时用例就在旁边。
'apps/ai-game-creator-shell/src/view/project-development/chat/**/*.test.ts',
'apps/ai-game-creator-shell/src/view/project-development/chat/**/*.test.tsx',
// 对话模型选择器的锁定项 / 降级文案与实现同目录。
'apps/ai-game-creator-shell/src/features/project-workspace/*.test.ts',
'apps/ai-game-creator-shell/src/features/project-workspace/*.test.tsx',
'miniprogram/**/*.test.js',
'scripts/**/*.test.ts',
'packages/shared/src/contracts/*.test.ts',