diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index f88cfe0d4..d58d1f878 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1776,7 +1776,9 @@ impl CodexAppServerConnection { effective_llm.base_url = format!("{}/api/llm", session.api_base_url.trim_end_matches('/')); effective_llm.api_key.clear(); - effective_llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string(); + // The selected model profile is resolved and validated at config + // load time; the official route still owns the provider/base URL. + effective_llm.model = llm.model.clone(); CodexAppServerCredential::PlatformSession { fingerprint: format!( "platform-session:{}:{}:{}", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index 6024bcbe4..b23b50749 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -137,6 +137,10 @@ async fn proxy_codex_provider_request( headers.append(name.clone(), value.clone()); } } + headers.insert( + axum::http::HeaderName::from_static("x-genarrative-client"), + axum::http::HeaderValue::from_static("agc"), + ); let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() { Ok(value) => value, Err(_) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d4ff179e7..f3de56e66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3482,7 +3482,23 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); config.llm.api_key.clear(); config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string(); - config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string(); + if let Some(profile) = config + .model_profiles + .iter() + .find(|profile| profile.enabled && profile.id == config.selected_model_profile_id) + { + if !profile.model_id.trim().is_empty() { + config.llm.model = profile.model_id.trim().to_string(); + } + if let Some(reasoning_effort) = profile.reasoning_effort.as_deref() { + if !reasoning_effort.trim().is_empty() { + config.llm.reasoning_effort = reasoning_effort.trim().to_string(); + } + } + } + if config.llm.model.trim().is_empty() { + config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string(); + } config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); config.agent_llm.clear(); config.editor_api.api_key.clear(); @@ -3616,6 +3632,19 @@ pub(crate) fn merge_game_creator_config_file( config.planning.capability_enabled = capability_enabled; } } + if let Some(model_profiles) = file_config.model_profiles { + config.model_profiles = model_profiles + .into_iter() + .filter(|profile| { + !profile.id.trim().is_empty() + && !profile.name.trim().is_empty() + && !profile.model_id.trim().is_empty() + }) + .collect(); + } + if let Some(selected_model_profile_id) = file_config.selected_model_profile_id { + config.selected_model_profile_id = selected_model_profile_id; + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 838542574..dc22839aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1020,6 +1020,26 @@ struct GameCreatorAppConfigFile { agent_llm: Option>, editor_api: Option, planning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model_profiles: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_model_profile_id: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorModelProfileFile { + id: String, + name: String, + model_id: String, + #[serde(default = "default_model_profile_enabled")] + enabled: bool, + #[serde(default)] + reasoning_effort: Option, +} + +fn default_model_profile_enabled() -> bool { + true } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -1080,6 +1100,14 @@ struct GameCreatorAppConfig { editor_api: GameCreatorEditorApiConfig, #[serde(default)] planning: GameCreatorPlanningConfig, + #[serde(default)] + model_profiles: Vec, + #[serde(default = "default_selected_model_profile_id")] + selected_model_profile_id: String, +} + +fn default_selected_model_profile_id() -> String { + "default".to_string() } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1606,6 +1634,14 @@ impl Default for GameCreatorAppConfig { agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), planning: GameCreatorPlanningConfig::default(), + model_profiles: vec![GameCreatorModelProfileFile { + id: "default".to_string(), + name: "陶泥儿智能创作".to_string(), + model_id: OFFICIAL_LLM_ROUTER_MODEL.to_string(), + enabled: true, + reasoning_effort: Some("max".to_string()), + }], + selected_model_profile_id: default_selected_model_profile_id(), } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index aebc0ea4d..953eeedc0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -737,6 +737,8 @@ fn app_config_commands_write_runtime_config_file() { }, agent_llm, planning: GameCreatorPlanningConfig::default(), + model_profiles: Vec::new(), + selected_model_profile_id: "default".to_string(), }) .expect("write runtime config"); @@ -822,6 +824,8 @@ fn app_config_write_rejects_invalid_api_kind() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), planning: GameCreatorPlanningConfig::default(), + model_profiles: Vec::new(), + selected_model_profile_id: "default".to_string(), }); assert!(result @@ -847,6 +851,8 @@ fn app_config_write_rejects_invalid_reasoning_effort() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), planning: GameCreatorPlanningConfig::default(), + model_profiles: Vec::new(), + selected_model_profile_id: "default".to_string(), }); assert!(result @@ -872,6 +878,8 @@ fn app_config_write_rejects_too_small_request_timeout() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), planning: GameCreatorPlanningConfig::default(), + model_profiles: Vec::new(), + selected_model_profile_id: "default".to_string(), }); assert!(result diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 58d549b60..df3f223ec 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -718,11 +718,21 @@ export interface GameCreatorAppConfig { baseUrl: string; apiKey: string; }; + modelProfiles: GameCreatorModelProfile[]; + selectedModelProfileId: string; planning?: { capabilityEnabled: boolean; }; } +export interface GameCreatorModelProfile { + id: string; + name: string; + modelId: string; + enabled: boolean; + reasoningEffort: GameCreatorLlmReasoningEffort; +} + export interface GameCreatorAppConfigView { path: string; config: GameCreatorAppConfig; diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index e315ba8ac..19955da4c 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -2,10 +2,12 @@ import { Bot, Cable, CheckCircle2, + ChevronDown, CircleAlert, Info, LoaderCircle, Pencil, + Plus, RotateCcw, Save, Settings2, @@ -14,7 +16,16 @@ import { Upload, X, } from 'lucide-react'; -import { type FormEvent, useEffect, useRef, useState } from 'react'; +import { + type CSSProperties, + type FormEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import { APP_NAME, APP_VERSION } from '../../app/appMetadata'; @@ -32,8 +43,146 @@ import { type GameCreatorLlmConfigStatus, type GameCreatorLlmReasoningEffort, gameCreatorLlmReasoningEfforts, + type GameCreatorModelProfile, } from '../../app/types'; import { checkForAppUpdate } from '../../services/appUpdate'; +import { + type ClientLlmModel, + loadClientLlmModels, +} from '../../services/clientApi'; + +function RuntimeSettingsSelect({ + ariaLabel, + children, + onChange, + options, + value, +}: { + ariaLabel: string; + children: ReactNode; + onChange: (value: string) => void; + options: { value: string; label: string }[]; + value: string; +}) { + const controlRef = useRef(null); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const [style, setStyle] = useState(); + + const position = useCallback(() => { + const control = controlRef.current; + if (!control) return; + const rect = control.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = 8; + const width = Math.min(rect.width, viewportWidth - margin * 2); + const estimatedMenuHeight = Math.min( + 300, + options.length * 40 + 14, + viewportHeight - margin * 2, + ); + const spaceBelow = viewportHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + const openDownward = + spaceBelow >= estimatedMenuHeight || spaceBelow >= spaceAbove; + const availableSpace = openDownward ? spaceBelow : spaceAbove; + const maxHeight = Math.max(120, Math.min(300, availableSpace)); + const left = Math.min( + Math.max(margin, rect.left), + Math.max(margin, viewportWidth - width - margin), + ); + const unconstrainedTop = openDownward + ? rect.bottom + 6 + : rect.top - 6 - maxHeight; + const top = Math.min( + Math.max(margin, unconstrainedTop), + Math.max(margin, viewportHeight - maxHeight - margin), + ); + setStyle({ + left, + top, + width, + maxHeight, + }); + }, [options.length]); + + useEffect(() => { + if (!open) return; + position(); + + function isOutside(target: EventTarget | null) { + return ( + !controlRef.current?.contains(target as Node) && + !menuRef.current?.contains(target as Node) + ); + } + + function onPointerDown(event: PointerEvent) { + if (isOutside(event.target)) setOpen(false); + } + + window.addEventListener('resize', position); + window.addEventListener('scroll', position, true); + window.addEventListener('pointerdown', onPointerDown, true); + return () => { + window.removeEventListener('resize', position); + window.removeEventListener('scroll', position, true); + window.removeEventListener('pointerdown', onPointerDown, true); + }; + }, [open, position]); + + return ( + <> + + {open + ? createPortal( +
+ {options.map((option) => ( + + ))} +
, + document.body, + ) + : null} + + ); +} const defaultRuntimeConfigDraft: GameCreatorAppConfig = { schemaVersion: 'game-creator-config.v2', @@ -58,6 +207,16 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { baseUrl: 'https://dev.genarrative.world', apiKey: '', }, + modelProfiles: [ + { + id: 'default', + name: '陶泥儿智能创作', + modelId: 'gpt-5.6-sol', + enabled: true, + reasoningEffort: 'max', + }, + ], + selectedModelProfileId: 'default', }; function formatAccountCredentialState( @@ -180,6 +339,36 @@ function normalizeRuntimeConfigDraft( ) ? config.llm.reasoningEffort : defaultRuntimeConfigDraft.llm.reasoningEffort; + const modelProfiles = Array.isArray(config.modelProfiles) + ? config.modelProfiles + .filter( + (profile): profile is GameCreatorModelProfile => + Boolean(profile) && + typeof profile.id === 'string' && + typeof profile.name === 'string' && + typeof profile.modelId === 'string', + ) + .map((profile) => ({ + id: profile.id.trim(), + name: profile.name.trim(), + modelId: profile.modelId.trim(), + enabled: profile.enabled !== false, + reasoningEffort: isGameCreatorLlmReasoningEffort( + profile.reasoningEffort, + ) + ? profile.reasoningEffort + : defaultRuntimeConfigDraft.llm.reasoningEffort, + })) + .filter((profile) => profile.id && profile.name && profile.modelId) + : defaultRuntimeConfigDraft.modelProfiles; + const selectedModelProfileId = + typeof config.selectedModelProfileId === 'string' && + config.selectedModelProfileId.trim() + ? config.selectedModelProfileId + : defaultRuntimeConfigDraft.selectedModelProfileId; + const selectedProfile = modelProfiles.find( + (profile) => profile.id === selectedModelProfileId && profile.enabled, + ); return { ...config, agentMode: 'codex_app_server', @@ -189,7 +378,7 @@ function normalizeRuntimeConfigDraft( baseUrl: '', model: '', apiKind: 'openai_responses', - reasoningEffort, + reasoningEffort: selectedProfile?.reasoningEffort ?? reasoningEffort, webSearchEnabled: typeof config.llm.webSearchEnabled === 'boolean' ? config.llm.webSearchEnabled @@ -226,6 +415,8 @@ function normalizeRuntimeConfigDraft( editorApi: allowAdvancedExternalEditorConfig ? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi } : { ...defaultRuntimeConfigDraft.editorApi }, + modelProfiles, + selectedModelProfileId, }; } @@ -274,9 +465,19 @@ export function RuntimeConfigDialog({ const [editingExtensionName, setEditingExtensionName] = useState(''); const [appUpdateStatus, setAppUpdateStatus] = useState(''); const [appUpdateChecking, setAppUpdateChecking] = useState(false); + const [availableLlmModels, setAvailableLlmModels] = useState< + ClientLlmModel[] + >([]); + const [llmModelsStatus, setLlmModelsStatus] = useState(''); + const [llmModelsStatusTone, setLlmModelsStatusTone] = useState< + 'default' | 'success' | 'error' + >('default'); + const [modelProfilesOpen, setModelProfilesOpen] = useState(false); const runtimeConfigBusyRef = useRef(false); - useEscapeToClose(onClose); + useEscapeToClose( + modelProfilesOpen ? () => setModelProfilesOpen(false) : onClose, + ); useEffect(() => { const htmlOverflow = document.documentElement.style.overflow; @@ -502,6 +703,18 @@ export function RuntimeConfigDialog({ advancedExternalEditorConfigEnabled, ); setRuntimeConfigDraft(config); + try { + const models = await loadClientLlmModels(); + setAvailableLlmModels(models); + setLlmModelsStatus(`已加载 ${models.length} 个模型`); + setLlmModelsStatusTone(models.length > 0 ? 'success' : 'default'); + } catch (error) { + setAvailableLlmModels([]); + setLlmModelsStatus( + error instanceof Error ? error.message : String(error), + ); + setLlmModelsStatusTone('error'); + } setRuntimeConfigStatus(`已读取:${result.path}`); setLlmConfigChecking(true); try { @@ -595,6 +808,79 @@ export function RuntimeConfigDialog({ setRuntimeConfigStatus('已恢复默认配置,保存后生效'); } + function updateSelectedModelProfile(profileId: string) { + setRuntimeConfigDraft((current) => ({ + ...current, + selectedModelProfileId: profileId, + llm: { + ...current.llm, + reasoningEffort: + current.modelProfiles.find((profile) => profile.id === profileId) + ?.reasoningEffort ?? current.llm.reasoningEffort, + }, + })); + } + + function updateModelProfile( + profileId: string, + patch: Partial, + ) { + setRuntimeConfigDraft((current) => ({ + ...current, + modelProfiles: current.modelProfiles.map((profile) => + profile.id === profileId ? { ...profile, ...patch } : profile, + ), + })); + } + + function addModelProfile() { + const id = `profile-${Date.now().toString(36)}`; + const modelId = availableLlmModels[0]?.id ?? 'gpt-5.6-sol'; + const profile: GameCreatorModelProfile = { + id, + name: `模型方案 ${runtimeConfigDraft.modelProfiles.length + 1}`, + modelId, + enabled: true, + reasoningEffort: runtimeConfigDraft.llm.reasoningEffort, + }; + setRuntimeConfigDraft((current) => ({ + ...current, + modelProfiles: [...current.modelProfiles, profile], + })); + } + + function removeModelProfile(profileId: string) { + if (runtimeConfigDraft.modelProfiles.length <= 1) { + setLlmModelsStatus('至少保留一个模型方案'); + setLlmModelsStatusTone('error'); + return; + } + setRuntimeConfigDraft((current) => { + const modelProfiles = current.modelProfiles.filter( + (profile) => profile.id !== profileId, + ); + const selectedModelProfileId = + current.selectedModelProfileId === profileId + ? (modelProfiles.find((profile) => profile.enabled)?.id ?? + modelProfiles[0]?.id ?? + current.selectedModelProfileId) + : current.selectedModelProfileId; + const selectedProfile = modelProfiles.find( + (profile) => profile.id === selectedModelProfileId, + ); + return { + ...current, + modelProfiles, + selectedModelProfileId, + llm: { + ...current.llm, + reasoningEffort: + selectedProfile?.reasoningEffort ?? current.llm.reasoningEffort, + }, + }; + }); + } + async function checkAppUpdateManually() { if (appUpdateChecking) return; setAppUpdateChecking(true); @@ -767,28 +1053,58 @@ export function RuntimeConfigDialog({ ); })()} +
+
+
+ + 模型方案 + + + {runtimeConfigDraft.modelProfiles.find( + (profile) => + profile.id === + runtimeConfigDraft.selectedModelProfileId, + )?.name ?? '未选择方案'} + +
+ +
+ profile.enabled) + .map((profile) => ({ + value: profile.id, + label: profile.name, + }))} + > + { + runtimeConfigDraft.modelProfiles.find( + (profile) => + profile.id === + runtimeConfigDraft.selectedModelProfileId, + )?.name + } + + + {llmModelsStatus || '模型列表尚未检测'} + +
{runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> -
); } diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 90e25572d..c47fba75a 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -179,6 +179,19 @@ export type ClientEditorAssetLibrary = { }>; }; +export type ClientLlmModel = { + id: string; + capabilities?: string[]; +}; + +export function loadClientLlmModels() { + return requestClientApi<{ models: ClientLlmModel[] }>( + '/api/llm/models', + { method: 'GET' }, + '读取可用模型失败', + ).then((response) => response.models ?? []); +} + export function loadEditorAssetLibrary() { return requestClientApi<{ library: ClientEditorAssetLibrary }>( '/api/editor/assets/library', diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 431eda83b..db1f686c4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -2594,7 +2594,12 @@ textarea { z-index: -1; inset: 0 auto 0 -35%; width: 28%; - background: linear-gradient(90deg, transparent, rgb(255 255 255 / 28%), transparent); + background: linear-gradient( + 90deg, + transparent, + rgb(255 255 255 / 28%), + transparent + ); pointer-events: none; opacity: 0; } @@ -2605,8 +2610,12 @@ textarea { } @keyframes project-supervisor-process-sweep { - from { transform: translateX(0); } - to { transform: translateX(520%); } + from { + transform: translateX(0); + } + to { + transform: translateX(520%); + } } .project-supervisor-process-card > header { @@ -4045,10 +4054,449 @@ h2 { color: var(--platform-text-base); } +.runtime-settings-model-card { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: linear-gradient( + 145deg, + var(--runtime-settings-subpanel-fill), + color-mix(in srgb, var(--runtime-settings-subpanel-fill) 82%, #f5ece2) + ); + box-shadow: 0 5px 18px rgb(84 60 42 / 6%); +} + +.runtime-settings-model-card-title { + display: grid; + gap: 3px; + min-width: 0; +} + +.runtime-settings-model-card-title > strong { + overflow: hidden; + color: var(--platform-text-strong); + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-settings-model-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 32px; +} + +.runtime-settings-field-label, +.runtime-settings-model-profile-fields > label > span { + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 700; +} + +.runtime-settings-model-status { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + max-width: 100%; + padding: 4px 8px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--platform-button-ghost-fill) 82%, + transparent + ); + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 700; + line-height: 1.5; +} + +.runtime-settings-model-status[data-tone='success'] { + background: #edf8ef; + color: var(--platform-success-text); +} + +.runtime-settings-model-status[data-tone='error'] { + background: #fff0ec; + color: var(--platform-warm-text); +} + +.runtime-settings-manage-models { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--platform-subpanel-border); + border-radius: 8px; + background: var(--platform-button-ghost-fill); + color: var(--platform-text-base); + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.runtime-settings-manage-models:hover, +.runtime-settings-manage-models:focus-visible { + border-color: var(--platform-surface-hover-border); + background: var(--platform-nav-active-fill); + color: var(--platform-accent); +} + +.runtime-settings-manage-models:active { + transform: translateY(1px); +} + +.runtime-settings-modal-backdrop { + /* Portal 到 body 后不再继承设置面板里的 platform-theme 变量。 */ + --platform-text-strong: #3d1f10; + --platform-text-soft: #988476; + --platform-line-soft: rgb(226 203 184 / 72%); + --platform-surface-border: rgb(226 203 184 / 88%); + --platform-surface-hover-border: rgb(204 117 76 / 42%); + --platform-subpanel-border: rgb(225 204 187 / 82%); + --platform-nav-active-border: rgb(204 117 76 / 34%); + --platform-button-ghost-fill: rgb(255 253 250 / 56%); + --platform-input-focus-ring: rgb(190 145 108 / 18%); + --platform-success-text: #2f7b46; + --platform-warm-text: #b6623f; + --platform-danger: #b45309; + position: fixed; + inset: 0; + top: var(--window-chrome-height); + right: 0; + bottom: 0; + left: 0; + z-index: 7000; + display: grid; + place-items: center; + padding: 28px; + background: rgb(46 35 28 / 54%); + backdrop-filter: blur(7px); + animation: runtime-settings-modal-fade 140ms ease-out; + overflow: auto; +} + +.runtime-settings-modal { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(760px, 100%); + height: min(640px, 100%); + max-height: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid rgb(234 223 213 / 92%); + border-radius: 16px; + background: #fffaf6; + box-shadow: + 0 28px 72px rgb(60 42 29 / 30%), + 0 2px 10px rgb(60 42 29 / 10%); +} + +@keyframes runtime-settings-modal-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.runtime-settings-modal-header, +.runtime-settings-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + background: #fffdf9; +} + +.runtime-settings-modal-header { + border-bottom: 1px solid var(--platform-line-soft); +} + +.runtime-settings-modal-header h3, +.runtime-settings-modal-header p { + margin: 0; +} + +.runtime-settings-modal-header h3 { + color: var(--platform-text-strong); + font-size: 17px; + letter-spacing: -0.02em; +} + +.runtime-settings-modal-header p { + margin-top: 4px; + color: var(--platform-text-soft); + font-size: 12px; +} + +.runtime-settings-modal-header > button { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.runtime-settings-modal-header > button:hover, +.runtime-settings-modal-header > button:focus-visible { + background: var(--platform-button-ghost-fill); + color: var(--platform-text-strong); +} + +.runtime-settings-model-profile-list { + min-height: 0; + overflow-y: auto; + display: grid; + align-content: start; + gap: 12px; + padding: 20px 22px 24px; + background: radial-gradient( + circle at 12% 0, + rgb(199 101 61 / 7%), + transparent 34% + ), + #fffaf6; +} + +.runtime-settings-model-profile-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.runtime-settings-model-profile-toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; +} + +.runtime-settings-model-profile-toolbar > span { + color: var(--platform-text-soft); + font-size: 12px; +} + +.runtime-settings-model-profile-toolbar-actions > button, +.runtime-settings-modal-footer > button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: #fffdfa; + color: #3d1f10; + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.runtime-settings-model-profile-toolbar-actions > button:hover, +.runtime-settings-model-profile-toolbar-actions > button:focus-visible, +.runtime-settings-modal-footer > button:hover, +.runtime-settings-modal-footer > button:focus-visible { + border-color: #c9b3a1; + background: #fff; + box-shadow: 0 4px 14px rgb(74 53 37 / 9%); +} + +.runtime-settings-model-profile-toolbar-actions > button:active, +.runtime-settings-modal-footer > button:active { + transform: translateY(1px); +} + +.runtime-settings-model-profile-card { + display: grid; + gap: 14px; + padding: 16px 16px 15px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 15px; + background: #fffdfa; + box-shadow: 0 6px 18px rgb(74 53 37 / 7%); + transition: + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.runtime-settings-model-profile-card:hover, +.runtime-settings-model-profile-card:focus-within { + border-color: var(--platform-surface-hover-border); + box-shadow: 0 9px 24px rgb(74 53 37 / 11%); + transform: translateY(-1px); +} + +.runtime-settings-model-profile-card-header { + display: flex; + align-items: center; + gap: 9px; +} + +.runtime-settings-model-profile-index { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + border: 1px solid var(--platform-nav-active-border); + border-radius: 11px; + background: linear-gradient( + 180deg, + rgb(238 208 183 / 48%), + rgb(204 117 76 / 16%) + ); + color: #c7653d; + font-size: 13px; + font-weight: 900; + place-items: center; +} + +.runtime-settings-model-profile-card-header > input { + flex: 1; + min-width: 0; + height: 36px; + padding: 0 11px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: #fff; + color: #3d1f10; + font-size: 14px; + font-weight: 800; +} + +.runtime-settings-model-profile-card-header > input:focus-visible { + border-color: var(--platform-surface-hover-border); + outline: none; + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.runtime-settings-model-profile-card-header > button { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid + color-mix(in srgb, var(--platform-danger, #b45309) 24%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--platform-danger, #b45309) 5%, #fff); + color: var(--platform-danger, #b45309); + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease; +} + +.runtime-settings-model-profile-card-header > button:hover, +.runtime-settings-model-profile-card-header > button:focus-visible { + border-color: color-mix( + in srgb, + var(--platform-danger, #b45309) 36%, + transparent + ); + background: color-mix(in srgb, var(--platform-danger, #b45309) 9%, #fff); +} + +.runtime-settings-model-profile-fields { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(150px, 0.8fr); + gap: 12px; +} + +.runtime-settings-model-profile-fields > label { + display: grid; + gap: 7px; + color: #988476; + font-size: 11px; + font-weight: 700; +} + +.runtime-settings-model-profile-fields select { + min-width: 0; + background-color: #fff; + color: #3d1f10; +} + +.runtime-settings-modal-footer { + justify-content: flex-end; + min-height: 68px; + box-sizing: border-box; + border-top: 1px solid var(--platform-line-soft); +} + +@media (max-width: 680px) { + .runtime-settings-modal-backdrop { + padding: 12px; + } + + .runtime-settings-modal { + height: min(620px, 100%); + border-radius: 15px; + } + + .runtime-settings-modal-header, + .runtime-settings-modal-footer { + padding: 15px 16px; + } + + .runtime-settings-model-profile-list { + padding: 16px; + } + + .runtime-settings-model-profile-toolbar { + align-items: stretch; + flex-direction: column; + } + + .runtime-settings-model-profile-toolbar-actions > button { + width: 100%; + } + + .runtime-settings-model-profile-card { + transform: none; + } + + .runtime-settings-model-profile-fields { + grid-template-columns: 1fr; + } +} + .runtime-settings-fields input, .runtime-settings-fields select { border-color: var(--platform-surface-border); - background: var(--platform-input-fill); + background-color: var(--platform-input-fill); color: var(--platform-text-strong); } @@ -4059,6 +4507,118 @@ h2 { box-shadow: 0 0 0 3px var(--platform-input-focus-ring); } +/* 设置页统一下拉控件样式,菜单交给系统渲染以保持稳定的键盘与窗口行为。 */ +.runtime-settings-select { + width: 100%; + min-width: 0; + height: 38px; + padding: 0 11px; + border: 1px solid var(--platform-surface-border); + border-radius: 11px; + background: var(--platform-input-fill); + color: var(--platform-text-strong); + cursor: pointer; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background-color 140ms ease; +} + +.runtime-settings-select:hover { + border-color: var(--platform-surface-hover-border); + background-color: var(--platform-button-ghost-fill); +} + +.runtime-settings-select:focus-visible { + border-color: var(--platform-surface-hover-border); + outline: none; + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.runtime-settings-select option { + border-radius: 8px; + color: #3d1f10; + background: #fffdfa; +} + +.runtime-settings-select-control { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + text-align: left; +} + +.runtime-settings-select-chevron { + flex: 0 0 auto; + color: #988476; + transition: transform 140ms ease; +} + +.runtime-settings-select-control[aria-expanded='true'] + .runtime-settings-select-chevron { + transform: rotate(180deg); +} + +.runtime-settings-select-menu { + position: fixed; + z-index: 7600; + display: grid; + gap: 4px; + max-height: min(300px, calc(100dvh - 24px)); + padding: 5px; + overflow-y: auto; + border: 1px solid rgb(226 203 184 / 88%); + border-radius: 13px; + background: #fffdfa; + box-shadow: 0 18px 46px rgb(74 53 37 / 20%); + scrollbar-width: none; + -ms-overflow-style: none; +} + +.runtime-settings-select-menu::-webkit-scrollbar { + display: none; +} + +.runtime-settings-select-option { + display: flex; + align-items: center; + min-height: 36px; + padding: 0 10px; + border: 0; + border-radius: 9px; + background: transparent; + color: #3d1f10; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.runtime-settings-select-option:hover, +.runtime-settings-select-option:focus-visible { + background: rgb(238 208 183 / 34%); + outline: none; +} + +.runtime-settings-select-option[data-selected='true'] { + background: rgb(204 117 76 / 15%); + color: #b45309; + font-weight: 800; +} + +.runtime-settings-content, +.runtime-settings-modal, +.runtime-settings-model-profile-list { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.runtime-settings-content::-webkit-scrollbar, +.runtime-settings-modal::-webkit-scrollbar, +.runtime-settings-model-profile-list::-webkit-scrollbar { + display: none; +} + .runtime-settings-about { grid-column: 1 / -1; display: grid; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index d9b5fcf6f..24922721c 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -18,6 +18,19 @@ use shared_contracts::llm::{ use spacetime_client::SpacetimeClientError; use std::convert::Infallible; +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LlmModelSummary { + pub id: String, + pub capabilities: Vec, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct LlmModelsResponse { + models: Vec, +} + #[cfg(test)] use std::collections::HashMap; #[cfg(test)] @@ -171,6 +184,129 @@ pub async fn proxy_llm_chat_completions( .into_response()) } +/// Returns the Router model directory through the authenticated platform API. +/// Router credentials and the upstream response body never leave api-server. +pub async fn list_llm_models( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result { + let (base_url, api_key, _) = + resolve_llm_router_credentials(&state, authenticated.claims().user_id()) + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error), + ) + })?; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("创建 LLM Router 模型目录客户端失败:{error}")), + ) + })?; + let upstream = client + .get(format!("{}/models", base_url.trim_end_matches('/'))) + .bearer_auth(api_key) + .send() + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("读取 LLM Router 模型目录失败:{error}")), + ) + })?; + let status = upstream.status(); + if !status.is_success() { + return Err(llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("LLM Router 模型目录返回 HTTP {status}")), + )); + } + let payload = upstream.json::().await.map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("解析 LLM Router 模型目录失败:{error}")), + ) + })?; + let models = payload + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let id = item.get("id").and_then(Value::as_str)?.trim(); + let capabilities = item + .get("capabilities") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some(LlmModelSummary { + id: id.to_string(), + capabilities, + }) + }) + .filter(|model| !model.id.is_empty()) + .collect(); + Ok(json_success_body(Some(&request_context), LlmModelsResponse { models }).into_response()) +} + +async fn router_model_exists( + state: &AppState, + owner_user_id: &str, + model_id: &str, +) -> Result { + let (base_url, api_key, _) = resolve_llm_router_credentials(state, owner_user_id).await?; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建 LLM Router 模型校验客户端失败:{error}"))?; + let response = client + .get(format!("{}/models", base_url.trim_end_matches('/'))) + .bearer_auth(api_key) + .send() + .await + .map_err(|error| format!("读取 LLM Router 模型目录失败:{error}"))?; + if !response.status().is_success() { + return Err(format!( + "LLM Router 模型目录返回 HTTP {}", + response.status() + )); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析 LLM Router 模型目录失败:{error}"))?; + Ok(payload + .get("data") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|item| { + item.get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.trim() == model_id) + }) + })) +} + /// Proxies the OpenAI-compatible Responses protocol for the LLM Router. /// /// The caller only presents the platform access token. The Router credential @@ -213,6 +349,12 @@ pub async fn proxy_llm_responses( .with_message("LLM Responses 请求体必须是 JSON 对象"), ) })?; + let requested_model = object + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); // The LLM Router is an account-owned route. Ignore legacy client/provider controls; // they must not reach Router even when an older desktop build still sends // them. Runtime controls such as `stream`, `input`, `tools` and `metadata` @@ -232,11 +374,39 @@ pub async fn proxy_llm_responses( ] { object.remove(field); } - // Ignore any client model override. - object.insert( - "model".to_string(), - Value::String(state.config.llm_router_model.clone()), - ); + // The AGC client may select a model from the server-provided Router + // directory. Older callers without the reserved marker remain pinned to + // the official default model. + let agc_client = headers + .get("x-genarrative-client") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "agc"); + let selected_model = if agc_client { + if let Some(model) = requested_model { + if router_model_exists(&state, authenticated.claims().user_id(), &model) + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error), + ) + })? + { + model + } else { + return Err(llm_error_response( + &request_context, + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY) + .with_message("所选模型已不存在,请刷新模型列表后重试"), + )); + } + } else { + state.config.llm_router_model.clone() + } + } else { + state.config.llm_router_model.clone() + }; + object.insert("model".to_string(), Value::String(selected_model)); let (base_url, api_key, key_id) = resolve_llm_router_credentials(&state, authenticated.claims().user_id()) diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index 2c45f526e..43492f64c 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -5,7 +5,7 @@ use axum::{ use crate::{ auth::require_bearer_auth, - llm::{proxy_llm_chat_completions, proxy_llm_responses}, + llm::{list_llm_models, proxy_llm_chat_completions, proxy_llm_responses}, state::AppState, volcengine_speech::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, @@ -15,6 +15,13 @@ use crate::{ pub fn router(state: AppState) -> Router { Router::new() + .route( + "/api/llm/models", + get(list_llm_models).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/llm/chat/completions", post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(