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
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:
@@ -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(),
|
||||
|
||||
+18
@@ -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;
|
||||
|
||||
+29
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 与写入)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user