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}
+
+
+
+ );
+ })}
|