diff --git a/CONTEXT.md b/CONTEXT.md index 1f3c268af..460a1abfc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -326,12 +326,16 @@ _Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串 _Avoid_: 把价格写死在代码、按档位名字硬编码权益 **模型权限**: -会员档位允许选择的模型范围;`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 -_Avoid_: 计价分辨率档、模型定价档位 +会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 +_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串 **并发上限**: -同一账号可同时运行的独立生成任务数上限;`Max` 不设上限。 -_Avoid_: 旧的排队展示字段、把「不设上限」写成空值 +同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。 +_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入 + +**独立生成任务**: +用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。 +_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发) ## Relationships diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 715d40d64..ceb6db413 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1174,6 +1174,7 @@ export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } export type AdminAgcAgentMode = 'codex' | 'cc'; +export type AdminAgcModelAccess = 'basic' | 'full'; export type AdminAgcModelProtocol = | 'openai_responses' | 'openai_chat' @@ -1187,6 +1188,8 @@ export interface AdminAgcModel { agentMode: AdminAgcAgentMode; /** 上游线协议,与执行器正交。 */ protocol?: AdminAgcModelProtocol; + /** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */ + access?: AdminAgcModelAccess; } export interface AdminAgcModelCatalog { diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index feb4ce9d3..3279a5342 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () => modelId: '', enabled: true, agentMode: 'cc', + access: 'basic', }); }); }); +test('权限档可按模型单独选择并随目录保存', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'gpt-6-astra', + enabled: true, + agentMode: 'codex' as const, + access: 'basic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + + const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement; + expect(access.value).toBe('basic'); + fireEvent.change(access, { target: { value: 'full' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ access: 'full' }); + }); +}); + test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { const catalog = { revision: 3, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index b009aa222..9e14400cb 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -118,6 +118,7 @@ export function AdminAgcModelsPage({ { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, { key: 'protocol', label: '协议' }, + { key: 'access', label: '权限档' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -159,6 +160,7 @@ export function AdminAgcModelsPage({ enabled: true, agentMode: 'codex', protocol: 'openai_responses', + access: 'basic', }, ], }, @@ -246,6 +248,21 @@ export function AdminAgcModelsPage({ + + + String { - "codex".to_string() -} - -fn default_model_protocol() -> String { - "openai_responses".to_string() -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelSummary { - id: String, - display_name: String, - #[serde(default = "default_codex_agent_mode")] - agent_mode: String, - /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。 - #[serde(default = "default_model_protocol")] - protocol: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelCatalog { - default_model_id: String, - models: Vec, - revision: u64, -} - #[derive(Debug, Deserialize)] struct ApiErrorPayload { code: Option, @@ -49,27 +21,12 @@ struct ApiErrorPayload { #[derive(Debug, Deserialize)] struct ApiEnvelope { ok: bool, - data: Option, + /// 直接复用 shared-contracts 的目录 DTO:`agentMode` / `protocol` / `unavailableModels` + /// 都带 `#[serde(default)]`,旧服务端缺字段时按 Codex + OpenAI Responses + 空桶兼容。 + data: Option, error: Option, } -fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .map(|model| LlmModelSummary { - id: model.id, - display_name: model.display_name, - agent_mode: model.agent_mode, - protocol: model.protocol, - }) - .collect(), - revision: catalog.revision, - } -} - fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; @@ -98,12 +55,10 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { } return envelope .data - .map(map_catalog) .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); } - serde_json::from_value::(value) - .map(map_catalog) + serde_json::from_value::(value) .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) } @@ -293,6 +248,48 @@ mod tests { .contains("x-genarrative-response-envelope: v1")); } + #[test] + fn catalog_maps_unknown_unavailable_reason_to_unknown() { + let body = serde_json::json!({ + "ok": true, + "data": { + "defaultModelId": "quality", + "models": [{ + "id": "quality", + "displayName": "高质量", + "agentMode": "codex", + "protocol": "openai_responses" + }], + "unavailableModels": [{ + "id": "pro", + "displayName": "专业版", + "agentMode": "cc", + "protocol": "anthropic", + "reason": "some_future_reason" + }], + "revision": 8 + }, + "error": null + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("catalog should parse"); + assert_eq!(catalog.models.len(), 1); + assert_eq!(catalog.unavailable_models.len(), 1); + assert_eq!( + catalog.unavailable_models[0].reason, + shared_contracts::llm::LlmModelUnavailableReason::Unknown + ); + assert_eq!( + catalog.unavailable_models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Cc + ); + assert_eq!( + catalog.unavailable_models[0].protocol, + shared_contracts::llm::AgcModelProtocol::Anthropic + ); + } + #[tokio::test] async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx new file mode 100644 index 000000000..ab1accc17 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx @@ -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(); + 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(); + // 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。 + 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(); + + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe( + '所选模型已下线,已切换为默认模型', + ), + ); + expect(mocks.invoke).toHaveBeenCalledWith( + 'select_game_creator_model', + expect.objectContaining({ modelId: 'basic-model', isDefault: true }), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..951f2274e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -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( initialCatalog?.models ?? [], ); + const [unavailableModels, setUnavailableModels] = useState< + ClientLlmUnavailableModel[] + >(initialCatalog?.unavailableModels ?? []); const [selected, setSelected] = useState(''); const [defaultModelId, setDefaultModelId] = useState( initialCatalog?.defaultModelId ?? '', @@ -136,6 +144,9 @@ export function ConversationModelSelect({ showBusy: boolean, epochAtRequest: number, ) => { + // 目录契约的 `unavailableModels` 是加法字段:旧缓存 / 局部数据可能缺失。 + // 统一归一化一次,避免下面 setState 与按 reason 取文案时解引用 undefined。 + const catalogUnavailableModels = catalog.unavailableModels ?? []; if ( mountedRef.current && (appliedRevisionRef.current !== catalog.revision || @@ -143,6 +154,7 @@ export function ConversationModelSelect({ ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); + setUnavailableModels(catalogUnavailableModels); setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); @@ -178,7 +190,13 @@ export function ConversationModelSelect({ if (!next && defaultEnabled) { next = catalog.defaultModelId; nextIsDefault = true; - if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; + if (saved) { + // 命中不可用桶时按 reason 给具体原因;两桶都没有(后台直接删除)走泛化文案。 + nextNotice = unavailableModelFallbackNotice( + catalogUnavailableModels.find((model) => model.id === saved) + ?.reason, + ); + } } if (next && (next !== saved || nextIsDefault !== followsDefault)) { let persisted: GameCreatorAppConfigView; @@ -253,6 +271,7 @@ export function ConversationModelSelect({ appliedRevisionRef.current = null; selectedRef.current = ''; setModels([]); + setUnavailableModels([]); setSelected(''); setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); @@ -315,6 +334,7 @@ export function ConversationModelSelect({ selectedRef.current = ''; setSelected(''); setModels([]); + setUnavailableModels([]); void syncCatalog(true); } window.addEventListener('focus', handleWindowFocus); @@ -455,6 +475,28 @@ export function ConversationModelSelect({ ) : null} ))} + {/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */} + {unavailableModels.map((model) => { + const reasonLabel = unavailableModelReasonLabel(model.reason); + return ( +
+ + {model.displayName} + +
+ ); + })}