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:
+123
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+41
-2
@@ -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"
|
||||
|
||||
+29
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user