AGC 新增本地自定义 LLM 配置与展示模型勾选
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m28s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m44s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Repository checks (push) Successful in 4m17s
Project CI / Backend tests (push) Successful in 7m48s
Project CI / AI game creator shell web tests (push) Successful in 3m43s

- 本地配置新增 llm.customEnabled 与 llm.visibleModels,显式开启后保留自定义连接和勾选列表,官方路由下仍清空凭据
- 配置迁移始终写出 customEnabled、visibleModels、apiKey、baseUrl、model、apiKind、reasoningEffort,便于手写自定义连接
- 新增 discover_game_creator_llm_models 命令,直连自定义端点 GET /models,限制超时与响应大小并脱敏错误
- codex_app_server 自定义模式改用配置里的地址与 Key 走本地凭据代理,不再经过平台 /api/llm,也不回退官方路由
- 模型目录按官方与自定义来源隔离缓存,自定义模式只展示勾选模型,选项失效时回退第一项
- 设置页在自定义模式展示 API 地址、API Key、协议与推理档,并提供模型读取、搜索、勾选与已勾选预览
- 补齐固定项排版与读取按钮样式,同步 AGC 模型选择规范、实施计划与共享概览
This commit is contained in:
kdletters
2026-09-16 16:39:15 +08:00
parent 9663bbf911
commit c36a5170f8
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);
});
+1 -1
View File
@@ -102,7 +102,7 @@
- [后台 Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)
- [后台多账号与 Tab 访问权限方案](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)
- [Pingora 独立网关试点](<./technical/【开发运维】Pingora独立网关试点-2026-06-11.md>)
- [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md)
- [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md):官方目录、本地自定义 LLM 开关、端点模型勾选与预览。
- [UI 编辑器工作流完成通知弹窗](./technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md)
- [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md)
- [UI 编辑器拖动变换提交边界](./【UI编辑器】拖动变换提交边界-2026-09-03.md)
@@ -51,6 +51,8 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
## AGC DirectProject 与 UI workflow
- AGC 的本地 `llm.customEnabled` 默认关闭,只能手动修改配置文件;开启后设置支持自定义 Responses 端点、读取 `/models`、勾选和预览 `visibleModels`。对话下拉只显示勾选项,LLM 请求经客户端凭据代理直连自定义上游;不会回退官方中转,平台资源服务仍使用账号权限。详见 AGC 后台模型别名与对话选择规范。
- DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。
- 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。
@@ -1,6 +1,36 @@
# AGC 后台模型别名与对话选择
## 契约
## 本地自定义 LLM
- 本地 `game-creator.config.json``llm.customEnabled` 默认 `false`;显式设为 `true` 后,常用设置展示 API 地址、API Key、读取模型列表与勾选区域。DirectProject 沿用 OpenAI Responses 协议,地址填写 API 根地址(例如 `https://provider.example/v1`)。开关只由配置文件控制。
- 点击读取后,客户端原生侧直接请求该地址下的 `GET /models`,使用自定义 Bearer Key,读取 OpenAI 兼容的 `data[].id`。请求有超时与响应大小上限,禁止携带平台登录凭据、禁止重定向;错误仅展示安全状态,不回显上游响应体或 Key。
- 模型支持搜索、逐项勾选和独立的已勾选列表预览。`llm.visibleModels` 按勾选顺序保存模型 ID,第一项作为默认项。至少勾选一项才可保存;读取失败保留草稿和已勾选列表,不替用户清空或新增选择。
- 首页与项目对话复用现有模型选择器;自定义模式只读取本地勾选目录,不请求平台模型目录。模型 ID 原样用于上游请求(允许 `/``.``:`),所选项被取消勾选时回退第一项。保存后刷新目录,下一回合使用新连接与模型,活动回合继续使用原快照。
- 开启后,Codex 客户端凭据代理和 Rust LLM 调用均直连自定义端点,不走平台 `/api/llm` 或内置中转;配置缺失或请求失败明确报错,不回退官方路由。真实 Key 留在客户端,不进入 Codex 子进程环境、参数或模型上下文。平台图片、音频、账户和计费契约不变。
- 配置加载、迁移和覆盖文件写入保留显式开启的连接和勾选列表;关闭时仍使用官方模型目录与官方路由。缓存不能跨自定义/官方目录或不同自定义连接复用。旧配置缺少新字段时保持官方行为。
- 验收覆盖开关默认值、配置合并/保存/重载、直连凭据与模型传递、模型发现成功/失败/超时、勾选预览、仅显示勾选模型、目录切换、无平台目录请求和设置保存失败。自动化本地 HTTP 验证与真实供应商 smoke 分开报告。
在设置页显示的本地配置文件路径中,手动把现有 `llm` 对象的 `customEnabled` 设为 `true`(保留其它字段),重新打开常用设置即可配置连接和读取模型。界面没有开启开关,保存设置也不能把关闭状态改为开启。配置文件也支持直接填写以下字段:
```json
{
"llm": {
"customEnabled": true,
"baseUrl": "https://provider.example/v1",
"apiKey": "填写自己的密钥",
"apiKind": "openai_responses",
"visibleModels": ["provider/model-name", "another-model"]
}
}
```
`visibleModels` 可先留空,再从端点读取并勾选;未完成勾选前不能发起自定义 LLM 对话。关闭时手动改回 `false`,后续回合使用官方目录和路由。
本地配置文件始终保留 `customEnabled``visibleModels``apiKey``baseUrl``model``apiKind``reasoningEffort` 七个键:官方路由下连接字段写官方地址与空 Key、协议写 `openai_responses`,便于手写自定义连接时对照;切换为自定义后这些值原样保留而不被启动清理。
自定义连接固定使用 OpenAI Responses 协议(`apiKind` 只接受 `openai_responses`),设置页把协议展示为只读项;推理档沿用对话输入盒中按回合生效的选择器,设置页只读展示当前档位,不新增第二处写入入口。
## 官方路由契约
- 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId``enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`
@@ -14,7 +44,7 @@
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口与项目对话共用同一份目录缓存、挂载即加载(失败时沿用上一次成功目录),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
- 自定义开关关闭时,设置页不包含模型管理或模型选择,使用官方代理锁定。
## 验收
@@ -58,9 +58,11 @@ DirectProject 自身的 `read_direct_project_conversation` 也必须在 blocking
## 常用设置职责
本地配置 `llm.customEnabled``true` 时,常用设置开放自定义 Responses 连接、端点模型发现与勾选预览;开关只允许手动修改配置文件。模型选择、直连与凭据边界以 [AGC 后台模型别名与对话选择](./【技术方案】AGC后台模型别名与对话选择-2026-09-05.md) 的“本地自定义 LLM”为准,默认仍使用官方服务。
常用设置负责运行参数的读取、编辑和保存,配置读写独立于账号权限诊断。账号权限由登录会话与实际智能服务请求链路处理,设置面板只维护配置草稿与读写反馈。
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;模型选择仅同步 `selectedModelId``selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;自定义模式取消勾选当前模型时回退第一项,并同步覆盖文件中的选择。模型选择仅同步 `selectedModelId``selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。
## 2026-09-09 manifest 资源功能分类与自定义标签(UI 与写入)