Merge branch 'master' into feat/auto-start
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-09-16 16:51:29 +08:00
21 changed files with 1301 additions and 57 deletions
@@ -2,6 +2,8 @@
"schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server",
"llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
) -> Option<(&'a str, &'a str)> {
match self {
Self::PlatformSession { .. } => None,
#[cfg(test)]
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
#[cfg(test)]
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
.as_deref()
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
#[cfg(not(test))]
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
Self::AuthBridge { .. } => None,
}
}
}
@@ -2018,7 +2017,13 @@ impl CodexAppServerConnection {
let codex_cli_version = game_creator_codex_cli_version_identity()
.map_err(platform_llm::LlmError::InvalidConfig)?;
let mut effective_llm = llm.clone();
let credential = if game_creator_official_llm_route_locked() {
let credential = if llm.custom_enabled {
crate::config::validate_custom_llm_connection(llm)
.map_err(platform_llm::LlmError::InvalidConfig)?;
CodexAppServerCredential::AppDataKey {
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
}
} else if game_creator_official_llm_route_locked() {
let session = current_platform_session().ok_or_else(|| {
platform_llm::LlmError::InvalidConfig(
"authentication-required: 请先登录陶泥儿账号".to_string(),
@@ -2186,7 +2191,8 @@ impl CodexAppServerConnection {
true,
),
_ => (
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
(llm.custom_enabled
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.then(|| credential.direct_provider_route(llm))
.flatten()
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
@@ -4743,6 +4749,8 @@ mod tests {
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
@@ -5528,6 +5536,59 @@ mod tests {
assert_ne!(command_token, provider_key);
}
#[tokio::test]
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
let mut llm = test_llm();
llm.custom_enabled = true;
llm.api_key = "custom-upstream-fixture-secret".into();
llm.base_url = "http://127.0.0.1:9/v1".into();
llm.model = "vendor/model.v1:latest".into();
llm.visible_models = vec![llm.model.clone()];
let credential = CodexAppServerCredential::AppDataKey {
fingerprint: "custom-fixture".into(),
};
let (base, key) = credential
.direct_provider_route(&llm)
.expect("custom route");
assert_eq!(base, llm.base_url);
assert_eq!(key, llm.api_key);
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
for mode in [
CodexAppServerWorkspaceMode::DirectProject,
CodexAppServerWorkspaceMode::ToolHost,
] {
let mut command = tokio::process::Command::new("fixture");
configure_game_creator_codex_app_server_command_for_mode(
&mut command,
&llm,
mode,
Some(&proxy),
None,
true,
)
.unwrap();
let arguments = command
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join("\n");
let params = codex_app_server_thread_start_params(
&llm.model,
std::path::Path::new("fixture-workspace"),
mode,
String::new(),
true,
);
assert_eq!(params["model"], "vendor/model.v1:latest");
assert!(!arguments.contains(&llm.api_key));
assert!(!arguments.contains("/api/llm"));
for (_, value) in command.as_std().get_envs() {
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
@@ -5329,6 +5329,8 @@ mod tests {
fn direct_test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
response_stream_fixture("finalization-tool-plan-repair-chain-run");
let root = project.path();
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "finalization-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "finalization-tool-plan-model".to_string(),
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-provider-model".to_string(),
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
LlmMessage::user("修复格式"),
]);
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-tool-plan-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-tool-plan-model".to_string(),
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
let root = project.path();
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-generic-retry-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-generic-retry-model".to_string(),
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
response_stream_fixture("tool-plan-capacity-preflight-run");
let root = project.path();
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-capacity-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-capacity-model".to_string(),
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
LlmMessage::user("修复格式"),
]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "durable-control-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "durable-control-tool-plan-model".to_string(),
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-cleanup-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-cleanup-model".to_string(),
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "terminal-handoff-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "terminal-handoff-model".to_string(),
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "provider-model".to_string(),
@@ -1991,6 +1991,8 @@ pub(crate) fn write_game_creator_app_config(
.lock()
.map_err(|_| "配置写入锁不可用")?;
let (current, overlays) = load_game_creator_app_config_for_write()?;
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
config.llm.custom_enabled = current.llm.custom_enabled;
config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default;
persist_game_creator_app_config(config, overlays, false)
@@ -2027,7 +2029,12 @@ pub(crate) fn select_game_creator_model(
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
if model_id.is_empty()
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
if config.llm.custom_enabled {
if !config.llm.visible_models.contains(&model_id) {
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
}
} else if model_id.is_empty()
|| model_id.len() > 64
|| !model_id
.bytes()
@@ -2035,12 +2042,21 @@ pub(crate) fn select_game_creator_model(
{
return Err("模型标识无效".into());
}
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true)
}
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
if !load_game_creator_app_config()?.llm.custom_enabled {
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
}
fetch_custom_llm_models(&llm).await
}
fn persist_game_creator_app_config(
config: GameCreatorAppConfig,
overlays: Vec<(PathBuf, serde_json::Value)>,
@@ -2056,8 +2072,8 @@ fn persist_game_creator_app_config(
let previous = overlay.clone();
if let Some(fields) = overlay.as_object_mut() {
for (key, value) in fields.iter_mut() {
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
== model_only
if !model_only
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
{
if let Some(saved_value) = saved.get(key) {
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
File diff suppressed because it is too large Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
custom_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
visible_models: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfig {
#[serde(default)]
custom_enabled: bool,
#[serde(default)]
visible_models: Vec<String>,
api_key: String,
base_url: String,
model: String,
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
impl Default for GameCreatorLlmConfig {
fn default() -> Self {
Self {
custom_enabled: false,
visible_models: Vec::new(),
api_key: String::new(),
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
@@ -2721,6 +2731,7 @@ fn main() {
read_game_creator_app_config,
write_game_creator_app_config,
select_game_creator_model,
discover_game_creator_llm_models,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
@@ -1,5 +1,73 @@
use super::*;
#[test]
fn legacy_official_config_gains_hand_editable_connection_keys_on_startup() {
// 用户现有配置文件(官方路由、连接字段已被清掉)在启动迁移路径上必须补齐
// 开关、模型列表与连接四要素,手写自定义连接时能看到完整字段。
let mut config: GameCreatorAppConfigFile = serde_json::from_str(
r#"{"schemaVersion":"game-creator-config.v2","agentMode":"codex_app_server","llm":{"reasoningEffort":"max","stream":true},"selectedModelId":"quality","selectedModelIsDefault":true}"#,
)
.unwrap();
assert!(ensure_game_creator_custom_llm_file_fields(&mut config));
assert!(scrub_locked_game_creator_config_file(&mut config));
let migrated: serde_json::Value = serde_json::to_value(&config).unwrap();
assert_eq!(migrated["llm"]["customEnabled"], false);
assert_eq!(migrated["llm"]["visibleModels"], serde_json::json!([]));
assert_eq!(migrated["llm"]["apiKey"], "");
assert_eq!(migrated["llm"]["baseUrl"], OFFICIAL_LLM_ROUTER_BASE_URL);
assert_eq!(migrated["llm"]["model"], "quality");
assert_eq!(
migrated["llm"]["apiKind"],
DEFAULT_GAME_CREATOR_LLM_API_KIND
);
assert_eq!(migrated["llm"]["reasoningEffort"], "max");
}
#[test]
fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials() {
let root = unique_project_path();
fs::create_dir_all(&root).unwrap();
let _guard = use_test_runtime_config_dir(root.clone());
let primary = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
write_game_creator_config_atomically(
&primary,
&serde_json::to_string(&GameCreatorAppConfig::default()).unwrap(),
)
.unwrap();
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
let mut config = read_game_creator_app_config().unwrap().config;
assert_eq!(config.llm.model, "a/v1");
let selected = select_game_creator_model("b:v2".into(), false).unwrap();
assert_eq!(selected.config.llm.model, "b:v2");
assert!(select_game_creator_model("not-listed".into(), false).is_err());
config.llm.visible_models = vec!["b:v2".into()];
config.llm.api_key = "changed-fixture-key".into();
write_game_creator_app_config(config).unwrap();
let reloaded = read_game_creator_app_config().unwrap().config;
assert!(reloaded.llm.custom_enabled);
assert_eq!(reloaded.llm.visible_models, ["b:v2"]);
assert_eq!(reloaded.llm.model, "b:v2");
assert_eq!(reloaded.llm.api_key, "changed-fixture-key");
let persisted: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&primary).unwrap()).unwrap();
for key in [
"customEnabled",
"visibleModels",
"apiKey",
"baseUrl",
"model",
"apiKind",
"reasoningEffort",
] {
assert!(
persisted["llm"].get(key).is_some(),
"本地配置始终保留 {key},方便手写自定义连接:{persisted}"
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn config_file_overrides_defaults_without_env() {
let root = unique_project_path();
@@ -313,10 +381,14 @@ fn locked_config_scrub_removes_all_legacy_provider_credentials() {
.llm
.as_ref()
.expect("global llm remains as non-sensitive tuning");
assert!(llm.api_key.is_none());
assert!(llm.base_url.is_none());
assert!(llm.model.is_none());
assert!(llm.api_kind.is_none());
// 连接字段保留在文件里(空 Key + 官方地址),便于手写自定义连接时对照。
assert_eq!(llm.api_key.as_deref(), Some(""));
assert_eq!(llm.base_url.as_deref(), Some(OFFICIAL_LLM_ROUTER_BASE_URL));
assert_eq!(
llm.api_kind.as_deref(),
Some(DEFAULT_GAME_CREATOR_LLM_API_KIND)
);
assert!(llm.model.is_some());
let serialized = serde_json::to_string(&config).expect("serialize scrubbed config");
assert!(!serialized.contains("legacy-global-key"));
assert!(!serialized.contains("legacy-agent-key"));
@@ -688,6 +760,8 @@ fn app_config_commands_write_runtime_config_file() {
agent_llm.insert(
" planner ".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some(" planner-key ".to_string()),
base_url: Some(" https://planner.example.test/v1 ".to_string()),
model: Some(" planner-model ".to_string()),
@@ -709,6 +783,8 @@ fn app_config_commands_write_runtime_config_file() {
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
llm: GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: " unit-test-key ".to_string(),
base_url: " https://runtime.example.test/v1 ".to_string(),
model: " runtime-model ".to_string(),
@@ -838,7 +914,7 @@ fn app_config_save_updates_conflicting_local_overlay() {
fs::create_dir_all(&root).expect("config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
fs::write(
write_game_creator_config_atomically(
&overlay_path,
r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#,
)
@@ -871,7 +947,7 @@ fn app_config_model_selection_only_updates_model_overlay() {
fs::create_dir_all(&root).expect("config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
fs::write(
write_game_creator_config_atomically(
&overlay_path,
r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#,
)
@@ -5885,6 +5885,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
agent_llm.insert(
"planner".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some("planner-key".to_string()),
base_url: Some(planner_base_url),
model: Some("planner-model".to_string()),
@@ -5903,6 +5905,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
agent_llm.insert(
"generator".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some("generator-key".to_string()),
base_url: Some(generator_base_url),
model: Some("generator-model".to_string()),
@@ -5921,6 +5925,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
agent_llm.insert(
"art-asset-plan".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some("art-key".to_string()),
base_url: Some(art_base_url),
model: Some("art-model".to_string()),
@@ -757,6 +757,8 @@ export type RuntimeAgentLlmProviderPresetId =
| RuntimeLlmProviderPresetId;
export interface GameCreatorLlmConfig {
customEnabled?: boolean;
visibleModels?: string[];
apiKey: string;
baseUrl: string;
model: string;
@@ -18,6 +18,8 @@ import { ClientAuthRequestError } from '../../services/clientApi';
import { ClientHttpTimeoutError } from '../../services/clientHttp';
import {
cachedLlmModelCatalog,
LLM_CONFIG_CHANGED_EVENT,
LlmModelCatalogConfigError,
refreshLlmModelCatalog,
} from '../../services/llmModelCatalog';
@@ -30,6 +32,7 @@ export type ConversationModelSelectHandle = {
class ModelSelectionConfigError extends Error {}
function modelCatalogErrorMessage(error: unknown) {
if (error instanceof LlmModelCatalogConfigError) return error.message;
if (error instanceof ClientHttpTimeoutError)
return '模型列表请求超时,请重试';
if (error instanceof ClientAuthRequestError && error.status)
@@ -70,6 +73,7 @@ export function ConversationModelSelect({
const selectedRef = useRef('');
const selectionEpochRef = useRef(0);
const busyTokenRef = useRef(0);
const syncEpochRef = useRef(0);
const configWriteChainRef = useRef<Promise<unknown>>(Promise.resolve());
const onReadyRef = useRef(onReady);
const mountedRef = useRef(true);
@@ -192,6 +196,7 @@ export function ConversationModelSelect({
const syncCatalog = useCallback(
async (showBusy: boolean, manualRefresh = false) => {
const syncEpoch = ++syncEpochRef.current;
if (manualRefresh && mountedRef.current) {
setManualRefreshBusy(true);
setNotice('正在刷新模型列表');
@@ -214,10 +219,17 @@ export function ConversationModelSelect({
try {
catalog = await refreshLlmModelCatalog();
} catch (error) {
if (syncEpoch !== syncEpochRef.current)
return Boolean(selectedRef.current);
catalogError = error;
const cached = cachedLlmModelCatalog();
if (!cached) {
if (mountedRef.current) {
appliedRevisionRef.current = null;
selectedRef.current = '';
setModels([]);
setSelected('');
setDefaultModelId('');
setError(modelCatalogErrorMessage(error));
setNotice('');
}
@@ -227,6 +239,8 @@ export function ConversationModelSelect({
catalog = cached;
usingCachedCatalog = true;
}
if (syncEpoch !== syncEpochRef.current)
return Boolean(selectedRef.current);
const ready = await applyCatalog(catalog, showBusy, epochAtRequest);
if (usingCachedCatalog && mountedRef.current)
setError(modelCatalogErrorMessage(catalogError));
@@ -235,6 +249,8 @@ export function ConversationModelSelect({
}
return ready;
} catch (error) {
if (syncEpoch !== syncEpochRef.current)
return Boolean(selectedRef.current);
if (mountedRef.current) {
setNotice('');
setError(
@@ -268,8 +284,20 @@ export function ConversationModelSelect({
function handleWindowFocus() {
void syncCatalog(false);
}
function handleConfigChanged() {
selectionEpochRef.current += 1;
appliedRevisionRef.current = null;
selectedRef.current = '';
setSelected('');
setModels([]);
void syncCatalog(true);
}
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
window.addEventListener(LLM_CONFIG_CHANGED_EVENT, handleConfigChanged);
return () => {
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, handleConfigChanged);
};
}, [syncCatalog]);
useEffect(() => {
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import { resolveTauriInvoke } from '../../app/tauri';
import type { GameCreatorLlmConfig } from '../../app/types';
export function CustomLlmSettings({
llm,
disabled,
onChange,
}: {
llm: GameCreatorLlmConfig;
disabled: boolean;
onChange: (llm: GameCreatorLlmConfig) => void;
}) {
const [available, setAvailable] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const requestEpoch = useRef(0);
const selected = llm.visibleModels ?? [];
useEffect(() => {
requestEpoch.current += 1;
setAvailable([]);
setBusy(false);
setStatus('');
setError('');
return () => {
requestEpoch.current += 1;
};
}, [llm.baseUrl, llm.apiKey]);
async function discover() {
const invoke = resolveTauriInvoke();
if (!invoke || busy) return;
const epoch = ++requestEpoch.current;
setBusy(true);
setError('');
setStatus('正在读取模型列表');
try {
const models = await invoke<string[]>(
'discover_game_creator_llm_models',
{ llm },
);
if (epoch !== requestEpoch.current) return;
setAvailable(models);
setStatus(
models.length
? `已读取 ${models.length} 个模型`
: '端点没有返回可用模型',
);
} catch (error) {
if (epoch !== requestEpoch.current) return;
setStatus('');
setError(typeof error === 'string' ? error : '模型列表读取失败,请重试');
} finally {
if (epoch === requestEpoch.current) setBusy(false);
}
}
const candidates = [...new Set([...available, ...selected])].filter((id) =>
id.toLowerCase().includes(query.trim().toLowerCase()),
);
return (
<section className="runtime-custom-llm" aria-label="自定义 LLM">
<label>
API
<PlatformTextField
aria-label="自定义 LLM API 地址"
type="url"
value={llm.baseUrl}
disabled={disabled}
placeholder="https://provider.example/v1"
onChange={(event) =>
onChange({
...llm,
baseUrl: event.currentTarget.value,
visibleModels: [],
})
}
/>
</label>
<label>
API Key
<PlatformTextField
aria-label="自定义 LLM API Key"
type="password"
autoComplete="off"
value={llm.apiKey}
disabled={disabled}
onChange={(event) =>
onChange({ ...llm, apiKey: event.currentTarget.value })
}
/>
</label>
<button
type="button"
className="runtime-custom-llm-discover"
disabled={disabled || busy || !llm.baseUrl.trim() || !llm.apiKey.trim()}
aria-busy={busy}
onClick={() => void discover()}
>
{busy ? '读取中…' : '读取模型列表'}
</button>
{status ? <p role="status">{status}</p> : null}
{error ? <p role="alert">{error}</p> : null}
<div className="runtime-custom-model-columns">
<section aria-label="可选模型">
<h4></h4>
<PlatformTextField
aria-label="搜索模型"
placeholder="搜索模型"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
<div className="runtime-custom-model-list">
{candidates.map((id) => (
<label className="settings-checkbox" key={id}>
<input
type="checkbox"
checked={selected.includes(id)}
disabled={disabled}
onChange={(event) =>
onChange({
...llm,
visibleModels: event.currentTarget.checked
? [...selected, id]
: selected.filter((value) => value !== id),
})
}
/>
<span>{id}</span>
</label>
))}
</div>
</section>
<section aria-label="已勾选模型预览">
<h4>{selected.length}</h4>
{selected.length ? (
<ol className="runtime-custom-model-list">
{selected.map((id, index) => (
<li key={id}>
{id}
{index === 0 ? <small> </small> : null}
</li>
))}
</ol>
) : (
<p></p>
)}
</section>
</div>
</section>
);
}
@@ -34,6 +34,7 @@ import {
gameCreatorLlmReasoningEfforts,
} from '../../app/types';
import { checkForAppUpdate } from '../../services/appUpdate';
import { notifyLlmConfigChanged } from '../../services/llmModelCatalog';
import {
listAgcExtensions,
reloadAgcPlugin,
@@ -43,11 +44,15 @@ import {
stopAgcPlugin,
} from '../../services/pluginHost';
import { PluginPanelHost } from '../plugins/PluginPanelHost';
import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort';
import { CustomLlmSettings } from './CustomLlmSettings';
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
schemaVersion: 'game-creator-config.v2',
agentMode: 'codex_app_server',
llm: {
customEnabled: false,
visibleModels: [],
apiKey: '',
baseUrl: '',
model: '',
@@ -148,9 +153,9 @@ function normalizeRuntimeConfigDraft(
agentMode: 'codex_app_server',
llm: {
...config.llm,
apiKey: '',
baseUrl: '',
model: '',
apiKey: config.llm.customEnabled ? config.llm.apiKey : '',
baseUrl: config.llm.customEnabled ? config.llm.baseUrl : '',
model: config.llm.customEnabled ? config.llm.model : '',
apiKind: 'openai_responses',
reasoningEffort,
webSearchEnabled:
@@ -182,9 +187,7 @@ function normalizeRuntimeConfigDraft(
maxRetries: clampRuntimeConfigNumber(config.llm.maxRetries, 0),
retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1),
},
// Official AGC builds have one account-backed route. Drop every legacy
// per-agent override (including non-sensitive tuning) at the UI boundary
// so it cannot be persisted or accidentally re-exposed as a route.
// 客户端统一使用全局连接,设置不保留独立的 Agent 路由覆盖。
agentLlm: {},
editorApi: allowAdvancedExternalEditorConfig
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
@@ -621,6 +624,7 @@ export function RuntimeConfigDialog({
advancedExternalEditorConfigEnabled,
);
setRuntimeConfigDraft(savedConfig);
notifyLlmConfigChanged();
setRuntimeConfigStatus(`已保存:${result.path}`);
setRuntimeConfigToast({
tone: 'success',
@@ -641,7 +645,13 @@ export function RuntimeConfigDialog({
}
function resetRuntimeConfigDraft() {
setRuntimeConfigDraft(defaultRuntimeConfigDraft);
setRuntimeConfigDraft({
...defaultRuntimeConfigDraft,
llm: {
...defaultRuntimeConfigDraft.llm,
customEnabled: runtimeConfigDraft.llm.customEnabled,
},
});
setRuntimeConfigStatus('已恢复默认配置,保存后生效');
}
@@ -785,13 +795,49 @@ export function RuntimeConfigDialog({
<div className="runtime-settings-readonly-field">
<span></span>
<strong></strong>
<small></small>
{!runtimeConfigDraft.llm.customEnabled ? (
<small></small>
) : null}
</div>
<div className="runtime-settings-readonly-field">
<span></span>
<strong></strong>
<small>使</small>
<strong>
{runtimeConfigDraft.llm.customEnabled
? '自定义 LLM'
: '官方账号服务(固定)'}
</strong>
{!runtimeConfigDraft.llm.customEnabled ? (
<small>使</small>
) : null}
</div>
{runtimeConfigDraft.llm.customEnabled ? (
<>
<div className="runtime-settings-readonly-field">
<span></span>
<strong>OpenAI Responses</strong>
<small></small>
</div>
<div className="runtime-settings-readonly-field">
<span></span>
<strong>
{reasoningEffortLabel(
runtimeConfigDraft.llm.reasoningEffort,
)}
</strong>
<small></small>
</div>
<CustomLlmSettings
llm={runtimeConfigDraft.llm}
disabled={runtimeConfigBusy}
onChange={(llm) =>
setRuntimeConfigDraft((current) => ({
...current,
llm,
}))
}
/>
</>
) : null}
{runtimeConfigDraft.agentMode !== 'codex_cli' ? (
<>
{/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效),
@@ -1,7 +1,54 @@
import { resolveTauriInvoke } from '../app/tauri';
import type { GameCreatorAppConfigView } from '../app/types';
import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi';
let cached: ClientLlmModelCatalog | null = null;
let inFlight: Promise<ClientLlmModelCatalog> | null = null;
let source = '';
let generation = 0;
let localRevision = 0;
export const LLM_CONFIG_CHANGED_EVENT = 'agc-llm-config-changed';
export class LlmModelCatalogConfigError extends Error {}
export function notifyLlmConfigChanged() {
generation += 1;
cached = null;
inFlight = null;
source = '';
window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT));
}
async function loadEffectiveCatalog(epoch: number) {
const invoke = resolveTauriInvoke();
let config: GameCreatorAppConfigView | undefined;
try {
config = invoke
? await invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
: undefined;
} catch (error) {
if (epoch === generation) cached = null;
throw new LlmModelCatalogConfigError('读取客户端配置失败');
}
if (epoch !== generation) throw new Error('模型配置已更新');
const llm = config?.config.llm;
const nextSource = llm?.customEnabled
? JSON.stringify(['custom', llm.baseUrl, llm.visibleModels ?? []])
: 'official';
if (source !== nextSource) {
source = nextSource;
cached = null;
}
if (llm?.customEnabled) {
if (cached) return cached;
const ids = llm.visibleModels ?? [];
return {
defaultModelId: ids[0] ?? '',
models: ids.map((id) => ({ id, displayName: id })),
revision: --localRevision,
};
}
return loadClientLlmModels();
}
/** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */
export function cachedLlmModelCatalog() {
@@ -14,8 +61,10 @@ export function cachedLlmModelCatalog() {
*/
export function refreshLlmModelCatalog() {
if (inFlight) return inFlight;
const request = loadClientLlmModels()
const epoch = generation;
const request = loadEffectiveCatalog(epoch)
.then((catalog) => {
if (epoch !== generation) throw new Error('模型配置已更新');
cached = catalog;
return catalog;
})
@@ -27,6 +76,8 @@ export function refreshLlmModelCatalog() {
}
export function resetLlmModelCatalogCacheForTest() {
generation += 1;
source = '';
cached = null;
inFlight = null;
}
+83
View File
@@ -3940,6 +3940,61 @@ h2 {
backdrop-filter: blur(6px);
}
.runtime-custom-llm {
grid-column: 1 / -1;
display: grid;
gap: 12px;
min-width: 0;
}
.runtime-custom-llm-discover {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
justify-self: start;
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--platform-surface-border);
border-radius: 9px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.runtime-custom-llm-discover:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.runtime-custom-model-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 16px;
}
.runtime-custom-model-columns > section {
min-width: 0;
padding: 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
}
.runtime-custom-model-list {
max-height: 240px;
overflow: auto;
overflow-wrap: anywhere;
}
.runtime-custom-model-list .settings-checkbox {
display: flex;
align-items: start;
gap: 8px;
margin: 8px 0;
}
.runtime-settings-toast {
position: absolute;
top: 24px;
@@ -4357,6 +4412,34 @@ h2 {
color: var(--platform-text-base);
}
/* 固定项(工作方式 / 智能服务 / 协议 / 推理档)按「标签 → 取值 → 说明」纵向排列。 */
.runtime-settings-readonly-field {
display: grid;
gap: 3px;
min-width: 0;
padding: 13px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--runtime-settings-subpanel-fill);
}
.runtime-settings-readonly-field > span {
color: var(--platform-text-soft);
font-size: 11px;
}
.runtime-settings-readonly-field > strong {
color: var(--platform-text-strong);
font-size: 14px;
line-height: 1.35;
}
.runtime-settings-readonly-field > small {
color: var(--platform-text-soft);
font-size: 10px;
line-height: 1.4;
}
.runtime-settings-fields input,
.runtime-settings-fields select {
border-color: var(--platform-surface-border);
@@ -22,7 +22,10 @@ import {
loadClientLlmModels,
} from '../src/services/clientApi';
import { ClientHttpTimeoutError } from '../src/services/clientHttp';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
import {
notifyLlmConfigChanged,
resetLlmModelCatalogCacheForTest,
} from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
const MockClientAuthRequestError = vi.hoisted(
@@ -79,6 +82,100 @@ beforeEach(() => {
});
afterEach(cleanup);
test('turning custom mode off cannot reuse custom models when the official catalog fails', async () => {
let customEnabled = true;
savedModelId = 'custom-model';
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = input.modelId;
savedModelIsDefault = input.isDefault;
}
return {
config: {
llm: {
customEnabled,
baseUrl: 'https://custom.example/v1',
visibleModels: ['custom-model'],
},
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const onReady = await renderReadyModelMenu();
expect(screen.getByRole('option', { name: /custom-model/ })).not.toBeNull();
customEnabled = false;
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(
new Error('unavailable'),
);
fireEvent(window, new Event('focus'));
await screen.findByText('模型列表加载失败');
expect(onReady).toHaveBeenLastCalledWith(false);
expect(screen.queryByRole('option', { name: /custom-model/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByRole('option', { name: /高质量/ });
expect(savedModelId).toBe('quality');
});
test('custom mode only shows checked endpoint model IDs and never requests the platform catalog', async () => {
savedModelId = 'vendor/model.v1';
const models = ['vendor/model.v1', 'vendor/fast:latest'];
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = input.modelId;
savedModelIsDefault = input.isDefault;
}
return {
config: {
llm: {
customEnabled: true,
baseUrl: 'https://custom.example/v1',
visibleModels: models,
},
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
await renderReadyModelMenu();
expect(screen.getAllByRole('option')).toHaveLength(2);
fireEvent.click(screen.getByRole('option', { name: 'vendor/fast:latest' }));
await waitFor(() => expect(savedModelId).toBe('vendor/fast:latest'));
expect(loadClientLlmModels).not.toHaveBeenCalled();
});
test('saving a custom catalog replaces official models and falls back when the old selection is unchecked', async () => {
await renderReadyModelMenu();
vi.mocked(loadClientLlmModels).mockClear();
let models = ['custom.v1', 'custom.v2'];
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = input.modelId;
savedModelIsDefault = input.isDefault;
}
return {
config: {
llm: {
customEnabled: true,
baseUrl: 'https://custom.example/v1',
visibleModels: models,
},
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
act(() => notifyLlmConfigChanged());
await waitFor(() => expect(savedModelId).toBe('custom.v1'));
expect(screen.queryByRole('option', { name: /高质量/ })).toBeNull();
expect(screen.getAllByRole('option')).toHaveLength(2);
models = ['custom.v2'];
act(() => notifyLlmConfigChanged());
await waitFor(() => expect(savedModelId).toBe('custom.v2'));
expect(screen.getAllByRole('option')).toHaveLength(1);
expect(loadClientLlmModels).not.toHaveBeenCalled();
});
async function renderReadyModelMenu() {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
@@ -111,6 +208,7 @@ test('shows manual refresh progress immediately without clearing the selected mo
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
await waitFor(() => expect(resolveRefresh).toBeTypeOf('function'));
await act(async () => {
resolveRefresh({
defaultModelId: 'quality',
@@ -498,11 +596,11 @@ test('recovers the selector when reading the native config fails', async () => {
await screen.findByText('读取客户端配置失败');
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false));
// 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。
// 配置不可读时不能猜测官方路由;恢复读取后选项与刷新按钮重新可用。
expect(loadClientLlmModels).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(
screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'),
).toBe(false);
const option = await screen.findByRole('option', { name: /高质量/ });
expect(option.hasAttribute('disabled')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('读取客户端配置失败')).toBeNull();
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import defaults from '../game-creator.config.json';
import type {
GameCreatorAppConfig,
GameCreatorLlmConfig,
} from '../src/app/types';
import { CustomLlmSettings } from '../src/features/runtime-config/CustomLlmSettings';
import { RuntimeConfigDialog } from '../src/features/runtime-config/RuntimeConfigDialog';
const invoke = vi.fn();
const llm = {
...defaults.llm,
customEnabled: true,
baseUrl: 'https://models.example/v1',
apiKey: 'test-custom-key',
visibleModels: ['vendor/model.v1'],
} as GameCreatorLlmConfig;
function Harness() {
const [draft, setDraft] = useState(llm);
return <CustomLlmSettings llm={draft} disabled={false} onChange={setDraft} />;
}
beforeEach(() => {
invoke.mockReset();
window.__TAURI__ = { core: { invoke } };
});
afterEach(() => {
cleanup();
delete window.__TAURI__;
});
test('reads endpoint models, filters candidates, and previews only checked models', async () => {
invoke.mockResolvedValue([
'vendor/model.v1',
'vendor/fast:latest',
'hidden-model',
]);
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: '读取模型列表' }));
await screen.findByText('已读取 3 个模型');
expect(invoke).toHaveBeenCalledWith('discover_game_creator_llm_models', {
llm,
});
fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' }));
const preview = screen.getByRole('region', { name: '已勾选模型预览' });
expect(
within(preview)
.getAllByRole('listitem')
.map((item) => item.textContent),
).toEqual(['vendor/model.v1 默认', 'vendor/fast:latest']);
expect(within(preview).queryByText('hidden-model')).toBeNull();
fireEvent.change(screen.getByRole('textbox', { name: '搜索模型' }), {
target: { value: 'FAST' },
});
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' }));
expect(within(preview).getAllByRole('listitem')).toHaveLength(1);
});
test('failed discovery preserves checked models and allows retry', async () => {
invoke
.mockRejectedValueOnce('模型列表读取失败(HTTP 401')
.mockResolvedValueOnce(['vendor/model.v1']);
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: '读取模型列表' }));
await screen.findByRole('alert');
expect(
within(screen.getByRole('region', { name: '已勾选模型预览' })).getByText(
/vendor\/model.v1/,
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '读取模型列表' }));
await screen.findByText('已读取 1 个模型');
expect(screen.queryByRole('alert')).toBeNull();
});
test('changing endpoint discards an old in-flight result and clears old selections', async () => {
let finish!: (models: string[]) => void;
invoke.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: '读取模型列表' }));
fireEvent.change(screen.getByLabelText('自定义 LLM API 地址'), {
target: { value: 'https://new.example/v1' },
});
await act(async () => finish(['old-model']));
expect(screen.queryByRole('checkbox', { name: 'old-model' })).toBeNull();
expect(screen.getByText('尚未勾选模型')).not.toBeNull();
expect(screen.getByRole('button', { name: '读取模型列表' })).toHaveProperty(
'disabled',
false,
);
});
test('settings save retains custom credentials and checked models, and reopening restores them', async () => {
let config = {
...defaults,
llm,
agentLlm: {},
editorApi: { baseUrl: 'https://platform.example', apiKey: '' },
} as GameCreatorAppConfig;
invoke.mockImplementation(async (command, args) => {
if (command === 'write_game_creator_app_config') config = args.config;
if (
command === 'read_game_creator_app_config' ||
command === 'write_game_creator_app_config'
)
return { path: '/private/game-creator.config.json', config };
if (command === 'discover_game_creator_llm_models')
return ['vendor/model.v1', 'vendor/fast:latest'];
return [];
});
const first = render(<RuntimeConfigDialog onClose={() => {}} />);
await screen.findByLabelText('自定义 LLM API 地址');
expect(screen.getByLabelText('自定义 LLM API Key')).toHaveProperty(
'type',
'password',
);
expect(screen.getByText('OpenAI Responses')).not.toBeNull();
expect(screen.getByText('最高')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '读取模型列表' }));
await screen.findByText('已读取 2 个模型');
fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() =>
expect(config.llm.visibleModels).toEqual([
'vendor/model.v1',
'vendor/fast:latest',
]),
);
expect(config.llm.apiKey).toBe('test-custom-key');
expect(config.llm.customEnabled).toBe(true);
first.unmount();
render(<RuntimeConfigDialog onClose={() => {}} />);
await screen.findByLabelText('自定义 LLM API 地址');
expect(
within(screen.getByRole('region', { name: '已勾选模型预览' })).getAllByRole(
'listitem',
),
).toHaveLength(2);
});