feat: AGC 支持模型方案选择

新增 Router 模型目录接口并返回可用模型列表

客户端配置新增模型方案与当前选中方案字段

设置页新增模型方案卡片和管理弹层,支持方案名称、模型和推理强度

自定义下拉菜单圆角、遮罩层级与窗口边界定位,并隐藏滚动条

AGC 官方路由保留所选模型并携带客户端标记,由服务端校验后转发

补齐配置结构变更后的既有测试初始化字段
This commit is contained in:
2026-09-04 13:52:00 +08:00
parent 938c37ca1b
commit 4bbef74859
11 changed files with 1342 additions and 35 deletions
@@ -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:{}:{}:{}",
@@ -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(_) => {
@@ -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(())
}
@@ -1020,6 +1020,26 @@ struct GameCreatorAppConfigFile {
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
editor_api: Option<GameCreatorEditorApiConfigFile>,
planning: Option<GameCreatorPlanningConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model_profiles: Option<Vec<GameCreatorModelProfileFile>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_profile_id: Option<String>,
}
#[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<String>,
}
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<GameCreatorModelProfileFile>,
#[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(),
}
}
}
@@ -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
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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',
File diff suppressed because it is too large Load Diff