From af099abbfac9b944c7bb493af0c91cd06c8d01e8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 19:44:06 +0800 Subject: [PATCH 01/10] =?UTF-8?q?AGC=20ACL=20=E6=8F=90=E6=9D=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8C=89=E7=9B=AE=E6=A0=87=E5=81=9A=20single-flight?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=B9=B6=E5=8F=91=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=BC=B9=20UAC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 acl_repair_gate:以 (规范化 repair target, scope) 为 key 的进程级 single-flight 与结果冷却(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭 - acl_repair_gate:leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住 - config:提权修复改经闸门执行;用户取消 UAC 统一返回稳定标记 AGC_ACL_ELEVATION_DENIED,不再依赖中文文案判定 - commands/main:新增 clear_game_creator_acl_elevation_denials,供用户主动操作时解除拒绝记忆 - useRecentProjects:识别新的稳定标记;打开/新建项目与重命名刷新时清除提权拒绝记忆 - tests/acl_repair_gate:并发只执行一次、冷却复用、清除后可重试、follower 超时、leader panic 唤醒等待者 - docs:decision-log 与 pitfalls 记录 single-flight 取舍与未做项 --- .../src-tauri/src/acl_repair_gate.rs | 244 ++++++++++++++++ .../src-tauri/src/commands.rs | 6 + .../src-tauri/src/config.rs | 90 +++++- .../src-tauri/src/main.rs | 2 + .../src-tauri/src/tests/acl_repair_gate.rs | 260 ++++++++++++++++++ .../src-tauri/src/tests/mod.rs | 1 + .../features/app-shell/useRecentProjects.ts | 20 +- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + 9 files changed, 622 insertions(+), 17 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs new file mode 100644 index 000000000..1662b71fa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -0,0 +1,244 @@ +//! ACL 提权修复目标的并发去重与结果记忆。 +//! +//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果; +//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长, +//! 避免自动重试把用户反复拽回安全桌面。 + +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::{Condvar, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AclRepairOutcome { + Repaired, + Denied(String), + Failed(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AclRepairGateResult { + Executed(AclRepairOutcome), + Reused(AclRepairOutcome), + /// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。 + WaitTimedOut, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct AclRepairPolicy { + pub(crate) success_cooldown: Duration, + pub(crate) denial_cooldown: Duration, + pub(crate) failure_cooldown: Duration, + pub(crate) wait_timeout: Duration, +} + +impl AclRepairPolicy { + fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration { + match outcome { + AclRepairOutcome::Repaired => self.success_cooldown, + AclRepairOutcome::Denied(_) => self.denial_cooldown, + AclRepairOutcome::Failed(_) => self.failure_cooldown, + } + } + + fn retention(&self) -> Duration { + self.success_cooldown + .max(self.denial_cooldown) + .max(self.failure_cooldown) + } +} + +struct Entry { + running: bool, + outcome: Option, + recorded_at: Option, +} + +pub(crate) struct AclRepairGate { + entries: Mutex>, + settled: Condvar, +} + +impl AclRepairGate { + pub(crate) fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + settled: Condvar::new(), + } + } + + /// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行, + /// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。 + pub(crate) fn run( + &self, + key: K, + now: Instant, + policy: &AclRepairPolicy, + execute: F, + ) -> AclRepairGateResult + where + F: FnOnce() -> AclRepairOutcome, + { + let wait_deadline = Instant::now() + policy.wait_timeout; + let mut entries = lock(&self.entries); + loop { + match entries.get(&key) { + Some(entry) if entry.running => { + let remaining = wait_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return AclRepairGateResult::WaitTimedOut; + } + let (guard, _) = self + .settled + .wait_timeout(entries, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + entries = guard; + } + Some(entry) => { + let reusable = entry.outcome.clone().zip(entry.recorded_at).filter( + |(outcome, recorded_at)| { + now.saturating_duration_since(*recorded_at) + < policy.cooldown_for(outcome) + }, + ); + match reusable { + Some((outcome, _)) => return AclRepairGateResult::Reused(outcome), + None => break, + } + } + None => break, + } + } + + prune(&mut entries, now, policy); + entries.insert( + key.clone(), + Entry { + running: true, + outcome: None, + recorded_at: Some(now), + }, + ); + drop(entries); + + let guard = LeaderGuard { + gate: self, + key: key.clone(), + recorded_at: now, + armed: true, + }; + let outcome = execute(); + guard.complete(outcome) + } + + /// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。 + pub(crate) fn clear_denials(&self) { + let mut entries = lock(&self.entries); + entries.retain(|_, entry| { + entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_))) + }); + drop(entries); + self.settled.notify_all(); + } + + #[cfg(test)] + pub(crate) fn is_running(&self, key: &K) -> bool { + lock(&self.entries) + .get(key) + .is_some_and(|entry| entry.running) + } +} + +impl Default for AclRepairGate +where + K: Clone + Eq + Hash, +{ + fn default() -> Self { + Self::new() + } +} + +struct LeaderGuard<'a, K: Clone + Eq + Hash> { + gate: &'a AclRepairGate, + key: K, + recorded_at: Instant, + armed: bool, +} + +impl LeaderGuard<'_, K> { + fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult { + self.armed = false; + let mut entries = lock(&self.gate.entries); + entries.insert( + self.key.clone(), + Entry { + running: false, + outcome: Some(outcome.clone()), + recorded_at: Some(self.recorded_at), + }, + ); + drop(entries); + self.gate.settled.notify_all(); + AclRepairGateResult::Executed(outcome) + } +} + +impl Drop for LeaderGuard<'_, K> { + /// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。 + fn drop(&mut self) { + if !self.armed { + return; + } + let mut entries = lock(&self.gate.entries); + entries.insert( + self.key.clone(), + Entry { + running: false, + outcome: Some(AclRepairOutcome::Failed( + "AGC ACL 提权修复执行线程异常退出".to_string(), + )), + recorded_at: Some(self.recorded_at), + }, + ); + drop(entries); + self.gate.settled.notify_all(); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn prune(entries: &mut HashMap, now: Instant, policy: &AclRepairPolicy) { + // 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。 + let retention = policy.retention().saturating_mul(4); + entries.retain(|_, entry| { + if entry.running { + return true; + } + entry + .recorded_at + .is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention) + }); +} + +/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。 +pub(crate) type AclRepairKey = (String, &'static str); + +pub(crate) static ACL_REPAIR_GATE: LazyLock> = + LazyLock::new(AclRepairGate::new); + +pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy { + success_cooldown: Duration::from_secs(30), + denial_cooldown: Duration::from_secs(120), + failure_cooldown: Duration::from_secs(15), + wait_timeout: Duration::from_secs(60), +}; + +/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。 +pub(crate) fn clear_acl_repair_denials() { + ACL_REPAIR_GATE.clear_denials(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a18bdb369..fabbb8471 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1874,6 +1874,12 @@ pub(crate) fn read_game_creator_app_config() -> Result = std::sync::Mutex::new(()); #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 839311a3f..991b32acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -2789,15 +2789,33 @@ fn windows_acl_repair_argument_list( .join(" ") } +/// 用户取消 UAC 的稳定错误标记:调用方(前端)据此判定「不可自动重试」, +/// 而不是去匹配中文文案。 +#[cfg(windows)] +pub(crate) const WINDOWS_ACL_REPAIR_DENIED_MARKER: &str = "AGC_ACL_ELEVATION_DENIED"; + +/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除提权拒绝记忆, +/// 使同一次会话内的显式重试仍能再次请求提权。 +pub(crate) fn clear_windows_acl_repair_denials() { + crate::acl_repair_gate::clear_acl_repair_denials(); +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. +/// +/// 同一 (规范化目标, scope) 的修复在进程内做 single-flight:并发调用只会有一次 +/// 真实提权,其余等待并复用结果;冷却窗口内直接复用,避免自动重试反复弹 UAC。 #[cfg(windows)] fn attempt_elevated_windows_acl_repair( path: &Path, target_user_sid: &str, scope: WindowsAclRepairScope, ) -> Result<(), String> { + use crate::acl_repair_gate::{ + AclRepairGateResult, AclRepairOutcome, ACL_REPAIR_GATE, ACL_REPAIR_POLICY, + }; + if !scope.allows_path(path) { return Err(format!( "AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}", @@ -2805,13 +2823,56 @@ fn attempt_elevated_windows_acl_repair( path.display() )); } - let executable = - std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?; - if !executable.is_file() { - return Err("AGC ACL 修复程序不存在".to_string()); - } let repair_path = windows_acl_repair_target(path, scope); - let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; + let key = ( + repair_path.to_string_lossy().to_lowercase(), + scope.wire_name(), + ); + let gate_result = + ACL_REPAIR_GATE.run(key, std::time::Instant::now(), &ACL_REPAIR_POLICY, || { + run_elevated_windows_acl_repair_once(path, target_user_sid, scope, &repair_path) + }); + match gate_result { + AclRepairGateResult::Executed(outcome) | AclRepairGateResult::Reused(outcome) => { + match outcome { + AclRepairOutcome::Repaired => Ok(()), + AclRepairOutcome::Denied(detail) => { + Err(format!("{WINDOWS_ACL_REPAIR_DENIED_MARKER}:{detail}")) + } + AclRepairOutcome::Failed(detail) => Err(detail), + } + } + AclRepairGateResult::WaitTimedOut => Err(format!( + "AGC ACL 提权修复等待超时:同一目标的提权仍在进行中:{}", + repair_path.display() + )), + } +} + +#[cfg(windows)] +fn run_elevated_windows_acl_repair_once( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, + repair_path: &Path, +) -> crate::acl_repair_gate::AclRepairOutcome { + use crate::acl_repair_gate::AclRepairOutcome; + + let executable = match std::env::current_exe() { + Ok(executable) => executable, + Err(error) => { + return AclRepairOutcome::Failed(format!("定位 AGC ACL 修复程序失败:{error}")); + } + }; + if !executable.is_file() { + return AclRepairOutcome::Failed("AGC ACL 修复程序不存在".to_string()); + } + let nonce = match create_windows_acl_repair_authorization(repair_path, target_user_sid, scope) { + Ok(nonce) => nonce, + Err(error) => { + return AclRepairOutcome::Failed(format!("准备 AGC ACL 提权授权失败:{error}")); + } + }; let escaped_executable = executable.to_string_lossy().replace('\'', "''"); let arguments = windows_acl_repair_argument_list( &repair_path.to_string_lossy(), @@ -2834,19 +2895,20 @@ fn attempt_elevated_windows_acl_repair( script.as_str(), ]) .creation_flags(0x0800_0000) - .status() - .map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}")); + .status(); let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| { fs::remove_file(authorization_path).map_err(|error| error.to_string()) }); - let status = status?; - if status.success() { - Ok(()) - } else { - Err(format!( + match status { + Err(error) => AclRepairOutcome::Failed(format!("启动 AGC ACL 提权修复失败:{error}")), + Ok(status) if status.success() => AclRepairOutcome::Repaired, + Ok(status) if status.code() == Some(1_223) => AclRepairOutcome::Denied( + "AGC ACL 提权修复被用户取消(exit code Some(1223))".to_string(), + ), + Ok(status) => AclRepairOutcome::Failed(format!( "AGC ACL 提权修复未成功(exit code {:?})", status.code() - )) + )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 256c38c3d..2d392a679 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -107,6 +107,7 @@ fn register_non_canonical_asset_kind_reporter() { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); +mod acl_repair_gate; mod agent; mod agent_native_tools; mod analytics; @@ -2686,6 +2687,7 @@ fn main() { install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, + clear_game_creator_acl_elevation_denials, write_game_creator_app_config, select_game_creator_model, discover_game_creator_llm_models, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs new file mode 100644 index 000000000..758c99cd8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -0,0 +1,260 @@ +use super::*; +use crate::acl_repair_gate::{ + AclRepairGate, AclRepairGateResult, AclRepairOutcome, AclRepairPolicy, +}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn test_policy() -> AclRepairPolicy { + AclRepairPolicy { + success_cooldown: Duration::from_secs(30), + denial_cooldown: Duration::from_secs(300), + failure_cooldown: Duration::from_secs(15), + wait_timeout: Duration::from_secs(5), + } +} + +fn test_key(target: &str) -> (String, &'static str) { + (target.to_string(), "managed") +} + +#[test] +fn concurrent_requests_for_one_target_run_the_repair_once() { + let gate = Arc::new(AclRepairGate::new()); + let executions = Arc::new(AtomicUsize::new(0)); + let started_at = Instant::now(); + + let handles = (0..8) + .map(|_| { + let gate = Arc::clone(&gate); + let executions = Arc::clone(&executions); + std::thread::spawn(move || { + gate.run(test_key("c:\\target"), started_at, &test_policy(), || { + executions.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(150)); + AclRepairOutcome::Repaired + }) + }) + }) + .collect::>(); + let results = handles + .into_iter() + .map(|handle| handle.join().expect("提权闸门线程不得 panic")) + .collect::>(); + + assert_eq!(executions.load(Ordering::SeqCst), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, AclRepairGateResult::Executed(_))) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!( + result, + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + )) + .count(), + 7 + ); +} + +#[test] +fn different_targets_are_not_deduplicated() { + let gate = AclRepairGate::new(); + let executions = AtomicUsize::new(0); + let now = Instant::now(); + + for target in ["c:\\one", "c:\\two"] { + let result = gate.run(test_key(target), now, &test_policy(), || { + executions.fetch_add(1, Ordering::SeqCst); + AclRepairOutcome::Repaired + }); + assert!(matches!(result, AclRepairGateResult::Executed(_))); + } + + assert_eq!(executions.load(Ordering::SeqCst), 2); +} + +#[test] +fn denied_elevation_is_reused_for_the_denial_cooldown() { + let gate = AclRepairGate::new(); + let key = test_key("c:\\denied"); + let started_at = Instant::now(); + let policy = test_policy(); + + let first = gate.run(key.clone(), started_at, &policy, || { + AclRepairOutcome::Denied("UAC 已取消".to_string()) + }); + assert!(matches!( + first, + AclRepairGateResult::Executed(AclRepairOutcome::Denied(_)) + )); + + let inside_cooldown = gate.run( + key.clone(), + started_at + Duration::from_secs(60), + &policy, + || panic!("拒绝冷却期内不得再次触发提权"), + ); + assert!(matches!( + inside_cooldown, + AclRepairGateResult::Reused(AclRepairOutcome::Denied(_)) + )); + + let after_cooldown = gate.run(key, started_at + Duration::from_secs(301), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + after_cooldown, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() { + let gate = AclRepairGate::new(); + let policy = test_policy(); + let started_at = Instant::now(); + + let repaired_key = test_key("c:\\repaired"); + assert!(matches!( + gate.run(repaired_key.clone(), started_at, &policy, || { + AclRepairOutcome::Repaired + }), + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + )); + assert_eq!( + gate.run( + repaired_key.clone(), + started_at + Duration::from_secs(29), + &policy, + || panic!("成功冷却期内不得重复提权") + ), + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); + assert!(matches!( + gate.run( + repaired_key, + started_at + Duration::from_secs(31), + &policy, + || { AclRepairOutcome::Repaired } + ), + AclRepairGateResult::Executed(_) + )); + + let failed_key = test_key("c:\\failed"); + assert!(matches!( + gate.run(failed_key.clone(), started_at, &policy, || { + AclRepairOutcome::Failed("提权修复退出码 1".to_string()) + }), + AclRepairGateResult::Executed(AclRepairOutcome::Failed(_)) + )); + assert!(matches!( + gate.run( + failed_key.clone(), + started_at + Duration::from_secs(14), + &policy, + || { panic!("失败冷却期内不得重复提权") } + ), + AclRepairGateResult::Reused(AclRepairOutcome::Failed(_)) + )); + assert!(matches!( + gate.run( + failed_key, + started_at + Duration::from_secs(16), + &policy, + || { AclRepairOutcome::Repaired } + ), + AclRepairGateResult::Executed(_) + )); +} + +#[test] +fn clearing_denials_allows_an_explicit_user_retry() { + let gate = AclRepairGate::new(); + let key = test_key("c:\\denied-cleared"); + let started_at = Instant::now(); + let policy = test_policy(); + + gate.run(key.clone(), started_at, &policy, || { + AclRepairOutcome::Denied("UAC 已取消".to_string()) + }); + gate.clear_denials(); + + let retried = gate.run(key, started_at + Duration::from_secs(1), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + retried, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); +} + +#[test] +fn followers_give_up_when_the_leader_never_finishes() { + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\slow"); + let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>(); + let leader_gate = Arc::clone(&gate); + let leader_key = key.clone(); + let leader = std::thread::spawn(move || { + leader_gate.run(leader_key, Instant::now(), &test_policy(), || { + let _ = release_receiver.recv_timeout(Duration::from_secs(5)); + AclRepairOutcome::Repaired + }) + }); + + let policy = AclRepairPolicy { + wait_timeout: Duration::from_millis(50), + ..test_policy() + }; + let follower = std::thread::spawn(move || { + gate.run(key, Instant::now(), &policy, || { + panic!("follower 不得自行执行提权") + }) + }); + let follower_result = follower.join().expect("follower 线程不得 panic"); + assert_eq!(follower_result, AclRepairGateResult::WaitTimedOut); + + release_sender.send(()).expect("放行 leader"); + assert!(matches!( + leader.join().expect("leader 线程不得 panic"), + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + )); +} + +#[test] +fn leader_panic_releases_followers_instead_of_letting_them_wait() { + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\panicking"); + let (entered_sender, entered_receiver) = std::sync::mpsc::channel::<()>(); + let leader_gate = Arc::clone(&gate); + let leader_key = key.clone(); + let leader = std::thread::spawn(move || { + leader_gate.run(leader_key, Instant::now(), &test_policy(), || { + entered_sender.send(()).expect("通知 follower"); + panic!("提权执行线程异常退出"); + }) + }); + entered_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("leader 已进入执行"); + + let follower_gate = Arc::clone(&gate); + let follower = std::thread::spawn(move || { + follower_gate.run(key, Instant::now(), &test_policy(), || { + panic!("follower 不得自行执行提权") + }) + }); + assert!(leader.join().is_err()); + let follower_result = follower.join().expect("follower 线程不得 panic"); + assert!(matches!( + follower_result, + AclRepairGateResult::Reused(AclRepairOutcome::Failed(_)) + )); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 621e24906..b28f8261f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6127,6 +6127,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { fs::remove_dir_all(root).ok(); } +mod acl_repair_gate; mod asset_delete; mod asset_rename; mod collaboration; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 00080005e..d066d6aa8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -27,10 +27,12 @@ const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300]; const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000]; /** * 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`, - * 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。 - * 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。 + * 重试等于在用户刚点「否」后再弹一次 UAC。 + * `AGC_ACL_ELEVATION_DENIED` 是 Rust 侧用户取消 UAC 的稳定标记(config.rs), + * 其余为 ACL/DACL 判据与历史文案,与 `windows_acl_error_may_need_elevation` 同口径。 */ const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [ + 'AGC_ACL_ELEVATION_DENIED', 'DACL', '权限', 'error 5', @@ -222,8 +224,19 @@ export function useRecentProjects(setStatus: Dispatch>) { }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); + /** 用户主动操作后解除 Rust 侧的提权拒绝记忆(否则冷却期内不会再请求提权)。 */ + function resetAclElevationDenials() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + void invoke('clear_game_creator_acl_elevation_denials').catch(() => { + // 重置失败不影响本次列表刷新:下一次用户操作会再试。 + }); + } + function rememberRecentWorkspace(projectPath: string) { - // 用户主动打开或新建项目:解除提权类失败的跳过标记。 + resetAclElevationDenials(); nonRetryablePathsRef.current.clear(); setRecentWorkspaces(writeRecentWorkspace(projectPath)); setRecentWorkspaceRefreshKey((current) => current + 1); @@ -234,6 +247,7 @@ export function useRecentProjects(setStatus: Dispatch>) { if (!invoke) { return; } + resetAclElevationDenials(); nonRetryablePathsRef.current.delete(projectPath); const inspection = await inspectRecentWorkspaceWithRetry( invoke, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 265ec8690..600482177 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-23 ACL 提权修复按目标做 single-flight + +- 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。 +- 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。 +- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 +- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。 +- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 + ## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName` - 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..b1701425d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 同一祖先下的多个项目会各自弹一次 UAC + +- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 +- **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。 +- **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 +- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者)。 +- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 From d94ee96836316e9e6aa62d5b6c9bcebebe8bbd43 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 20:11:34 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E8=A1=A5=E4=B8=80=E6=9D=A1=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=9ARust=20=E4=BE=A7=E5=8F=96=E6=B6=88=20UAC=20?= =?UTF-8?q?=E7=9A=84=E7=A8=B3=E5=AE=9A=E6=A0=87=E8=AE=B0=E5=90=8C=E6=A0=B7?= =?UTF-8?q?=E4=B8=8D=E8=A7=A6=E5=8F=91=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/recentProjectsHook:AGC_ACL_ELEVATION_DENIED 这类失败只检查一次,钉住 Rust 错误标记与前端「不可重试」判定之间的契约 --- .../tests/recentProjectsHook.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 7abb15b64..c94636a71 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -206,3 +206,30 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { }); expect(attempts).toBe(1); }); + +test('Rust 侧取消 UAC 的稳定标记同样不触发重试', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, _args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + attempts += 1; + throw new Error( + 'AGC_ACL_ELEVATION_DENIED:AGC ACL 提权修复被用户取消(exit code Some(1223))', + ); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/denied-elevation-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]?.status).toBe('检查失败'); + }); + expect(attempts).toBe(1); +}); From 3480a2f33158b63bb619d54e363fd4a759181d6f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 12:05:42 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E9=97=B8=E9=97=A8=20key=20=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=86=99=E6=B3=95=20+=20=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=E6=94=B9=E4=BB=8E=E7=BB=93=E6=9E=9C=E8=90=BD=E5=BA=93=E6=97=B6?= =?UTF-8?q?=E5=88=BB=E7=AE=97=E8=B5=B7=EF=BC=88#498=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acl_repair_gate:冷却基准从 leader 起跑时刻改为结果落库时刻;UAC 被挂着几十秒到两分钟时,120s 拒绝冷却不再提前过期,避免紧跟的自动整表重查立刻再弹一次 - config:新增 windows_acl_repair_gate_key,闸门 key 的路径半边先去掉 \\?\ / \\?\UNC\ 前缀再统一小写;最近项目列表里同一项目实测同时存在 \\?\C:\... 与 C:\... 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC - tests/acl_repair_gate:补「冷却从结果落库时刻算起」与「路径写法归一成一个 key」两条用例;两条都做过逆向确认(改回修复前语义即失败) - docs:decision-log 与 pitfalls 补记 key 归一化、冷却基准,以及真机复现的三个坑(DENY 要加在祖先的父目录、夹具路径必须落在 Managed 放行范围内、提权子进程会按 repair target 再校验 scope) --- .../src-tauri/src/acl_repair_gate.rs | 9 +-- .../src-tauri/src/config.rs | 22 ++++-- .../src-tauri/src/tests/acl_repair_gate.rs | 71 +++++++++++++++++++ .../shared-memory/decision-log.md | 2 + docs/project-memory/shared-memory/pitfalls.md | 3 +- 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs index 1662b71fa..2902ec5f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -125,7 +125,6 @@ impl AclRepairGate { let guard = LeaderGuard { gate: self, key: key.clone(), - recorded_at: now, armed: true, }; let outcome = execute(); @@ -162,7 +161,6 @@ where struct LeaderGuard<'a, K: Clone + Eq + Hash> { gate: &'a AclRepairGate, key: K, - recorded_at: Instant, armed: bool, } @@ -175,7 +173,10 @@ impl LeaderGuard<'_, K> { Entry { running: false, outcome: Some(outcome.clone()), - recorded_at: Some(self.recorded_at), + // 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着 + // 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期, + // 紧接着的自动重查会立刻再弹一次。 + recorded_at: Some(Instant::now()), }, ); drop(entries); @@ -198,7 +199,7 @@ impl Drop for LeaderGuard<'_, K> { outcome: Some(AclRepairOutcome::Failed( "AGC ACL 提权修复执行线程异常退出".to_string(), )), - recorded_at: Some(self.recorded_at), + recorded_at: Some(Instant::now()), }, ); drop(entries); diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 991b32acd..32a83d2bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -2800,6 +2800,23 @@ pub(crate) fn clear_windows_acl_repair_denials() { crate::acl_repair_gate::clear_acl_repair_denials(); } +/// 闸门 key 的路径半边:`\\?\` 扩展长度前缀与 `\\?\UNC\` 必须先归一化, +/// 否则同一个物理目录的不同写法会算出不同 key,single-flight 就退化成「每种写法弹一次」。 +/// 最近项目列表里同一项目会同时存在 `\\?\C:\...` 与 `C:\...` 两种形态,归一化后它们共用一次提权。 +/// 这里只做前缀与大小写归一(不 `canonicalize`):待修复目标恰恰是「读不动的目录」,解析不可靠。 +#[cfg(windows)] +pub(crate) fn windows_acl_repair_gate_key( + repair_path: &Path, + scope: WindowsAclRepairScope, +) -> crate::acl_repair_gate::AclRepairKey { + ( + normalize_windows_policy_path(repair_path) + .to_string_lossy() + .to_lowercase(), + scope.wire_name(), + ) +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. @@ -2824,10 +2841,7 @@ fn attempt_elevated_windows_acl_repair( )); } let repair_path = windows_acl_repair_target(path, scope); - let key = ( - repair_path.to_string_lossy().to_lowercase(), - scope.wire_name(), - ); + let key = windows_acl_repair_gate_key(&repair_path, scope); let gate_result = ACL_REPAIR_GATE.run(key, std::time::Instant::now(), &ACL_REPAIR_POLICY, || { run_elevated_windows_acl_repair_once(path, target_user_sid, scope, &repair_path) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs index 758c99cd8..8249b55d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -174,6 +174,77 @@ fn successful_repair_and_failure_are_reused_for_their_own_cooldowns() { )); } +#[test] +fn cooldown_is_measured_from_the_recorded_result_not_the_leader_start() { + // 真机场景:UAC 弹窗被挂着几十秒到两分钟。若冷却从 leader 起跑时刻算, + // 120s 拒绝冷却会在用户应答前就过期,紧接着的自动重查立刻再弹一次。 + let gate = AclRepairGate::new(); + let key = test_key("c:\\slow-success"); + let policy = AclRepairPolicy { + success_cooldown: Duration::from_millis(200), + ..test_policy() + }; + let executions = AtomicUsize::new(0); + + let executed = gate.run(key.clone(), Instant::now(), &policy, || { + executions.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(400)); + AclRepairOutcome::Repaired + }); + assert_eq!( + executed, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); + + let reused = gate.run(key, Instant::now(), &policy, || { + executions.fetch_add(1, Ordering::SeqCst); + panic!("冷却必须从结果落库时刻算起,不能用 leader 起跑时刻") + }); + assert_eq!( + reused, + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); + assert_eq!(executions.load(Ordering::SeqCst), 1); +} + +#[cfg(windows)] +#[test] +fn repair_gate_key_merges_path_spelling_variants_of_one_target() { + use crate::config::{windows_acl_repair_gate_key, WindowsAclRepairScope}; + use std::path::Path; + + // 最近项目里同一项目会同时出现 `\\?\C:\...` 与 `C:\...` 两种写法(客户端列表实测), + // 不归一化就是两个 key -> 同一个目录弹两次 UAC。 + let plain = + Path::new(r"C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects"); + let extended = + Path::new(r"\\?\C:\Users\dongy\AppData\Roaming\world.genarrative.ai-game-creator\projects"); + let share = Path::new(r"\\server\share\projects"); + let share_extended = Path::new(r"\\?\UNC\server\share\projects"); + + for scope in [ + WindowsAclRepairScope::Managed, + WindowsAclRepairScope::UserSelected, + ] { + assert_eq!( + windows_acl_repair_gate_key(plain, scope), + windows_acl_repair_gate_key(extended, scope) + ); + assert_eq!( + windows_acl_repair_gate_key(share, scope), + windows_acl_repair_gate_key(share_extended, scope) + ); + assert_ne!( + windows_acl_repair_gate_key(plain, scope), + windows_acl_repair_gate_key(share, scope) + ); + } + assert_ne!( + windows_acl_repair_gate_key(plain, WindowsAclRepairScope::Managed), + windows_acl_repair_gate_key(plain, WindowsAclRepairScope::UserSelected) + ); +} + #[test] fn clearing_denials_allows_an_explicit_user_retry() { let gate = AclRepairGate::new(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 68d795a29..7ece2e6e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,6 +4,8 @@ - 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。 - 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。 +- 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。 +- 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。 - 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 - 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。 - 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dce1ff335..7738d5a9f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5,7 +5,8 @@ - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 - **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。 - **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 -- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者)。 +- **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。 +- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 ## 策划回复的重复终态不能重新启动伪流式 From 1931852e9ea555a9aa3cdf780ec1eea4f5397284 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 12:56:47 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=89=93=E5=BC=80/?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=9B=AE=E5=BD=95=E5=85=A5=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E6=8F=90=E6=9D=83=E6=8B=92=E7=BB=9D=E8=AE=B0=E5=BF=86=E6=B8=85?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aclElevation:新增唯一入口 clearAclElevationDenials()(Tauri 环境判断 + 命令失败只吞掉,旁路动作不影响本次操作) - useRecentProjects:rememberRecentWorkspace / refreshRecentWorkspace 改用该共享入口,删掉本地同名实现 - useHomeProjectCreation:openProject 入口先清除拒绝记忆再 inspect,覆盖行内打开、运行中项目入口与文件选择器选择目录;此前只挂在「打开/新建成功之后」,用户点了打开会撞上 120s 冷却直接失败且不弹 UAC - appSurface/home.suite:新增断言「用户动作先 clear 再 inspect」,并做逆向确认(去掉该调用即红) - docs:decision-log 写清前端唯一入口与必须挂的四个入口,pitfalls 记录 leader 失效接管这条残余边界 --- .../src/features/app-shell/aclElevation.ts | 23 +++++++++++++++++++ .../app-shell/useHomeProjectCreation.ts | 4 ++++ .../features/app-shell/useRecentProjects.ts | 16 +++---------- .../tests/appSurface/home.suite.ts | 18 +++++++++++++++ .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 1 + 6 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts diff --git a/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts b/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts new file mode 100644 index 000000000..373ffb56a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts @@ -0,0 +1,23 @@ +import { resolveTauriInvoke } from '../../app/tauri'; + +/** + * 用户主动操作(打开/新建项目、选择目录、重命名刷新)时调用:解除 Rust 侧的 ACL 提权拒绝记忆。 + * + * Rust 侧闸门对「用户取消 UAC」有 120s 冷却,冷却期内同一目标的提权请求直接复用拒绝结果、 + * 不再弹窗。所以只要入口是明确的用户动作,就必须先清掉这份记忆,否则用户会看到 + * 「点了打开却立刻失败、也不问我要不要授权」。 + * + * 零成本失败关闭:不在 Tauri 环境直接返回;命令失败也只吞掉(下一次用户操作会再试), + * 不能让「重置拒绝记忆」这种旁路动作影响本次操作本身。 + */ +export function clearAclElevationDenials() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + try { + void invoke('clear_game_creator_acl_elevation_denials').catch(() => {}); + } catch { + // 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。 + } +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index f47f69b4a..75b0e8cb7 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -54,6 +54,7 @@ import { projectPathHasControlCharacter, } from '../project-summary/projectSummary'; import { importDesignFiles } from '../project-workspace/importDesignFiles'; +import { clearAclElevationDenials } from './aclElevation'; import { ensureHomeWebCreationEnvironment, HOME_WEB_PREFLIGHT_FAILURE, @@ -614,6 +615,9 @@ export function useHomeProjectCreation({ mode: 'open' | 'create', analytics?: ProjectOpenAnalytics, ) { + // 打开/新建是明确的用户动作:先解除 Rust 侧的提权拒绝记忆,否则 120s 冷却内 + // 首条 inspect_local_project_directory 会直接复用「用户取消」的结果,既不弹 UAC 也打不开。 + clearAclElevationDenials(); if (mode === 'create') { await createProjectFromProjectPage(nextProjectPath); return; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index d066d6aa8..eba55356d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -13,6 +13,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { clearAclElevationDenials } from './aclElevation'; import { buildRecentProjectRows, readRecentWorkspaces, @@ -224,19 +225,8 @@ export function useRecentProjects(setStatus: Dispatch>) { }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); - /** 用户主动操作后解除 Rust 侧的提权拒绝记忆(否则冷却期内不会再请求提权)。 */ - function resetAclElevationDenials() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - void invoke('clear_game_creator_acl_elevation_denials').catch(() => { - // 重置失败不影响本次列表刷新:下一次用户操作会再试。 - }); - } - function rememberRecentWorkspace(projectPath: string) { - resetAclElevationDenials(); + clearAclElevationDenials(); nonRetryablePathsRef.current.clear(); setRecentWorkspaces(writeRecentWorkspace(projectPath)); setRecentWorkspaceRefreshKey((current) => current + 1); @@ -247,7 +237,7 @@ export function useRecentProjects(setStatus: Dispatch>) { if (!invoke) { return; } - resetAclElevationDenials(); + clearAclElevationDenials(); nonRetryablePathsRef.current.delete(projectPath); const inspection = await inspectRecentWorkspaceWithRetry( invoke, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e081afb8b..9fcfbbf19 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2883,6 +2883,9 @@ export function registerRecentProjectsTests() { '厨房突围', ); } + if (command === 'clear_game_creator_acl_elevation_denials') { + return undefined; + } if (command === 'open_game_creator_workspace_window') { return undefined; } @@ -2986,10 +2989,25 @@ export function registerRecentProjectsTests() { { projectPath: '/tmp/broken-status' }, ); + // 打开是明确的用户动作:必须先解除 Rust 侧的提权拒绝记忆,再 inspect。 + // 否则 120s 拒绝冷却内首条 inspect 直接复用「用户取消」的结果:既不弹 UAC,也打不开项目。 + const callsBeforeOpen = invoke.mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' })); await waitFor(() => { expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull(); }); + const openedCalls = invoke.mock.calls.slice(callsBeforeOpen); + const clearedAt = openedCalls.findIndex( + ([command]) => command === 'clear_game_creator_acl_elevation_denials', + ); + const inspectedAt = openedCalls.findIndex( + ([command, args]) => + command === 'inspect_local_project_directory' && + (args as { projectPath?: string } | undefined)?.projectPath === + '/tmp/ok-game', + ); + expect(clearedAt).toBeGreaterThanOrEqual(0); + expect(inspectedAt).toBeGreaterThan(clearedAt); expect(screen.getByLabelText('项目开发工作台')).not.toBeNull(); expect(invoke).not.toHaveBeenCalledWith( 'open_game_creator_workspace_window', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7ece2e6e9..ec13ca835 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,7 +7,7 @@ - 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。 - 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。 - 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 -- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。 +- 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。 - 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 ## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6e4a24d24..c881902b7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,7 @@ - **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 - **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。 - **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 +- **已知残余边界**:闸门只有 follower 的有界等待(60s),没有 leader 失效接管——若提权子进程真的挂死(`Start-Process -Wait` 无超时),该 key 会一直 `running`,之后所有同目标调用都按 60s 超时失败,`clear_game_creator_acl_elevation_denials` 也不清理 running,只能重启客户端恢复。需要更激进策略时再单独讨论(记 `started_at` + 硬上限接管)。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 ## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格 From 6c92e5d67314521a62fa1bbb2d810c714ccbed0c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:19 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9AAGC=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E5=88=9D=E5=A7=8B=E5=80=BC?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=8A=E6=B8=B8=E5=90=8C=E6=AD=A5=E7=9A=84?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E4=B8=8E=E8=BF=90=E7=BB=B4=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范补充目录初始化来源、失败关闭与重试口径,并记录本变更不改变目录格式与客户端契约 - 后端数据契约补充缺行语义与启动期同步 - 运维文档新增「AGC 模型目录上游同步」小节(来源、恢复路径、同批发布约束) - decision-log 记录本次决策、真实上游实测结论与残留项 - 新增里程碑《AGC 模型目录初始值改为上游同步》与对应实施计划 --- ...实施计划】AGC模型目录上游同步-2026-09-24.md | 36 ++++++++++++ ...€�里程碑】AGC模型目录上游同步-2026-09-24.md | 56 +++++++++++++++++++ .../shared-memory/decision-log.md | 14 +++++ ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 11 +++- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +++ 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md create mode 100644 docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md diff --git a/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md new file mode 100644 index 000000000..5f5b383b3 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md @@ -0,0 +1,36 @@ +# AGC 模型目录上游同步实施计划 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress | +| Date | 2026-09-24 | +| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md` | + +## 修改边界与顺序 + +1. **领域模型(`module-runtime/src/agc_models.rs`)**:删除写死的 `Default` 实现(原 `quality → gpt-6-astra`、`fast → gpt-5.6-luna`),新增 `from_upstream_models`:按上游模型名排序去重后生成目录项(`modelId`/`alias` = 上游原名,`id` = 模型名 slug,`enabled = true`),默认项取排序后第一项;新增 `resolve_requested`(未选或 `platform-default` 用默认项)。字段、校验规则(32 项上限、id/alias/model_id 约束)与 `resolve` 保持原样。 +2. **procedure(`spacetime-module/src/agc_models.rs`)**:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不再返回内置目录;`save_agc_model_catalog` 不变。无表结构变化,不改 `migration.rs`。 +3. **api-server 目录模块(`src/agc_models.rs`)**:新增启动期 `ensure_agc_model_catalog_initialized`(读 → 解析/校验 → 缺行或非法则 `GET {控制面}/api/pricing?group=taonier` → 生成目录 → 按存量 revision 写回;冲突后重读确认可用);上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;未初始化统一 `503` 文案;后台 PUT 增加未初始化门禁。 +4. **api-server 接线(`src/main.rs`、`src/external_api_keys.rs`)**:`try_restore_app_state_for_startup` 按 HTTP 角色调用初始化,失败只 `error!` 记录;抽出 `ensure_llm_router_url_allowed`(只校验地址/scheme,避免被已下线的固定模型哨兵挡住),`LLM_ROUTER_TOKEN_GROUP` / `router_control_origin` 供同步复用。 +5. **客户端与后台**:不改。`GET /api/llm/models` 形状、admin DTO、后台「AGC 模型」页、客户端 `select_game_creator_model` 校验全部保持原样。 + +## 不改的部分 + +目录字段语义、后台 DTO 与页面、公开 DTO 形状、客户端模型标识校验、`/api/external/v1` 与 OpenAPI、SpacetimeDB 表结构、Router provisioning/额度。 + +## 验证命令 + +- `cargo test --locked -p module-runtime --lib agc_models::` +- `cargo test --locked -p api-server --bin api-server agc`、`... llm::` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins configuration::` +- `cargo fmt --all -- --check`(两套 workspace)、`npx vitest run apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx`、admin-web 页面定向 Vitest 与 typecheck +- `npm run check:encoding`、`npm run check:doc-index`、`npm run check:spacetime-schema`、`git diff --check` +- 运行时 smoke:本地 dev 栈清空 `agc_model_catalog` 后启动 api-server,确认日志 `已按上游模型列表初始化 AGC 模型目录`、库中 `catalog_json` 为「slug id + 上游原名 alias/modelId」、`GET /api/llm/models` 返回原名;再把上游地址指向不可达端口验证 `503` 与「无替代目录」。 + +## 风险与回滚点 + +- **上游端点与鉴权**:分组定价列表端点为实测确认的公开只读接口;若上游改版,同步失败只会让目录保持未初始化(接口 503 + 启动 error),不会写入错误模型。 +- **混合版本**:module 的缺行语义变化要求 module 与 api-server 同批发布/回滚;未升级的 api-server 会把自己的 AGC 接口打到 `503`(后台 DTO 未变,admin-web 可独立发布)。回滚点必须同时覆盖 module 与 api-server。 +- **存量目录**:结构合法的旧目录(含 `quality/fast`)不会自动重建,需要 owner 在后台修改或清空该行后重启。 +- **目录规模**:目录项上限仍是 32;上游在售模型超过 32 条时同步会失败并记录原因,需要 owner 在后台维护子集。 diff --git a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md new file mode 100644 index 000000000..b3da552a7 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md @@ -0,0 +1,56 @@ +# AGC 模型目录初始值改为上游同步 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress(实现与本地真实上游验证完成;生产发布未执行) | +| Date | 2026-09-24 | +| Parent Spec | `docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md` | + +## 背景与触发 + +`agc_model_catalog` 缺行时,`read_agc_model_catalog` 兜底返回写死的初始目录(`高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)。这两个模型已从上游 Router 移除,于是从未配置过目录的环境(新库、清库、本地调试)会把两个不存在的模型下发给客户端,选中后上游 `model_not_found`,必须人工在后台保存一次目录才恢复。 + +## 目标 + +1. 初始目录不再写死:api-server 启动期从上游分组定价列表生成,`alias` 与 `modelId` 都用上游原始模型名(不再填“高质量/快速”这类人工别名)。 +2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。 +3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。 + +## 不在本里程碑内 + +- 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。 +- 不做上游变化的自动跟随同步(由 owner 在后台维护)。 +- 不改 Router provisioning、额度与计费链路。 +- 不改 `/api/external/v1` 与 OpenAPI,不改 SpacetimeDB 表结构。 + +## 合同要点 + +- **初始化**:api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据),取 `data[].model_name`,按模型名排序生成目录:`modelId` 与 `alias` 为上游原名、`id` 为模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`)、全部 enabled、默认项取排序后第一项,并以存量 revision 写回自增。 +- **失败关闭**:拉取失败、空列表、响应超过 1 MiB、缺可解析 revision、写回失败都只记录 error,不写替代目录;未初始化期间 AGC 目录/对话接口与后台目录接口返回 `503`“模型目录未初始化”。 +- **重试口径**:只启动期尝试一次;失败不阻塞启动,下次启动重试,直到目录里有数据。请求侧无法触发同步。 +- **幂等与并发**:目录只取决于模型集合(排序后生成),重复同步一致;多实例并发只有一个写入成功,冲突方接受既有目录并校验其可用性。 +- **存量目录**:结构合法的目录不会被自动重建(包括旧版写死的 `quality/fast`),需要 owner 在后台改掉或清空该行后重启。 + +## 依赖 + +- `module-runtime`:`AgcModelCatalog::from_upstream_models`(slug 生成 + 默认项 + 校验),删除写死的 `Default` 实现。 +- `spacetime-module`:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`。 +- `api-server`:启动期 `ensure_agc_model_catalog_initialized`;上游请求硬化(10s 超时、1 MiB 流式上限、禁止重定向、不带凭据);`ensure_llm_router_url_allowed`(只校验地址,不绑定已下线的固定模型)。 +- 文档:主规范、后端数据契约、运维文档、decision-log。 + +## 验收标准 + +1. 空目录 + 上游可达:启动后目录自动生成(别名即上游原名),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 是上游原名,界面不出现内置模型名。 +2. 空目录 + 上游不可达/非 2xx/空列表:启动只记录 error、不写替代目录;AGC 与后台目录接口 `503`;上游恢复后重启即同步成功。 +3. 幂等:同一模型集合重复同步得到一致的目录与默认项。 +4. 目录领域校验(id/alias/model_id、32 项上限、默认项必须启用)与请求侧 `422`/`409` 行为与改动前一致。 +5. 回归:Rust 定向测试、AGC/admin-web 类型检查与定向测试、`npm run check:encoding`、`check:doc-index`、`check:spacetime-schema`、`git diff --check`。 + +## 已决与待决 + +- 已决:上游来源用分组定价列表(2026-09-24 实测:`/v1/models` 用管理 token 返回 401;管理面注册表会带出已下线、无路由绑定的模型)。 +- 已决:`id` 用模型名 slug,保持客户端标识校验契约不变。 +- 已决:初始化失败不阻塞启动,只在下次启动重试。 +- 已决:目录结构与既有 DTO/页面保持不变,本变更不引入不兼容改动。 +- 待决:是否需要“上游自动跟随同步”(当前不做)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b7aef73f4..f5741f66b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9335,3 +9335,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。 - 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。 - 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。 + +## 2026-09-24 AGC 模型目录初始值改为上游同步:不再回退写死的 gpt-6-astra/gpt-5.6-luna + +- 背景:`agc_model_catalog` 缺行时 procedure 兜底返回内置目录(`quality → gpt-6-astra`、`fast → gpt-5.6-luna`),两个模型都已从上游移除;从未配置过目录的环境(新库、清库、本地调试)会把不存在的模型下发给客户端,选中后上游 `model_not_found`。 +- 决策(范围):本次只改目录初始值的来源,保持既有格式与契约不变 —— 目录字段仍是 `id`/`alias`/`modelId`/`defaultModelId`,后台页面与 admin DTO、`GET /api/llm/models` 形状、客户端 `select_game_creator_model` 的标识校验都不动,因此没有不兼容变更。 +- 决策(初始化):api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求上游 Router 控制面的分组定价列表 `GET {控制面}/api/pricing?group=taonier`(公开只读、不带凭据),按 `data[].model_name` 排序生成目录:`modelId` 与 `alias` 用上游原名(不再填“高质量/快速”),`id` 用模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`,因此客户端标识校验无需放宽),全部 enabled,默认项取排序后第一项,并按存量 revision 写回自增。 +- 决策(来源选择,2026-09-24 实测后确定):不用管理面模型注册表 `/api/models/`(会带出已下线、没有路由绑定的 `gpt-6-astra`/`gpt-6-luna`),也不用 `/v1/models`(要求 Router 用户 Key,用管理 token 实测 401)。当日 `group=taonier` 在售 6 个:`deepseek-flash`、`deepseek-v4-pro`、`glm-5.3`、`glm-5.3-flash`、`qwen-image-3.0`、`qwen3.8-flash`。 +- 决策(失败关闭与重试):拉取失败、空列表、响应超 1 MiB、缺可解析 revision 或写回失败都只记录 error,不写替代目录;未初始化期间 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`“模型目录未初始化”;只在启动期尝试一次,下一次启动重试,直到目录里有数据。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 起不来。 +- 决策(幂等与并发):目录只取决于模型集合(排序后生成),重复同步结果一致;多实例并发启动只有一个写入成功,冲突方重读并校验既有目录可用性。目录只在未初始化时重建,上游变化不自动跟随。 +- 决策(存量目录):结构合法的目录不会被自动重建,包括旧版写死的 `quality/fast` —— 需要 owner 在后台改掉,或清空该行后重启重新同步。 +- 影响范围:`module-runtime`(`from_upstream_models` + slug 生成,删除写死的 `Default`)、`spacetime-module`(缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`)、`api-server`(启动期同步、上游请求硬化、只校验地址的目标校验、后台 PUT 未初始化门禁)、AGC 客户端(默认模型占位改为 `platform-default`)、AGC 模型弹层 CSS、主规范/后端契约/运维文档。 +- 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。 +- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 +- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index cad3e359d..418fc565d 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -1,5 +1,7 @@ # AGC 后台模型别名与对话选择 +更新时间:`2026-09-24`。本次只改“目录初始值从哪来”:缺配置时不再回退写死的 `高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`,改为启动期从上游同步(这两条初始目录里的模型已从上游移除)。目录结构、后台维护字段和客户端契约都保持不变。 + ## 本地自定义 LLM - 本地 `game-creator.config.json` 的 `llm.customEnabled` 默认 `false`;显式设为 `true` 后,常用设置展示 API 地址、API Key、读取模型列表与勾选区域。DirectProject 沿用 OpenAI Responses 协议,地址填写 API 根地址(例如 `https://provider.example/v1`)。开关只由配置文件控制。 @@ -33,7 +35,11 @@ ## 官方路由契约 - 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId`、`enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。 -- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`。 +- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。 +- 目录初始值来自上游同步:api-server(API/All 角色)启动时检查目录,缺失、结构与当前定义不符或校验不通过都算“未初始化”;此时调用上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(控制面地址由 `{LLM Router 地址}` 去掉 `/v1` 得到;公开只读接口,不带凭据),读取 `data[].model_name` 作为“该分组可见的在售模型”,按模型名排序后生成目录:每项 `modelId` 与 `alias` 都用上游原始模型名(不再填“高质量/快速”这类人工别名),`id` 是模型名的稳定 slug(小写字母、数字、`-`、`_`,同名冲突追加 `-2`),`enabled = true`,默认项取排序后第一项,并以存量 revision 写回(`revision` 自增)。不使用管理面模型注册表 `/api/models/`——它会残留已下线、没有路由绑定的条目;也不使用 `/v1/models`——它要求 Router 用户 Key,平台没有服务级 Key。并发启动的多个实例里只有一个写入成功,其余接受既有目录。 +- 同步失败(网络、非 2xx、空列表、响应超过 1 MiB、缺少目录行 revision、写回失败)只记录 error 日志,不写任何替代目录、不使用任何内置模型名;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。 +- 目录未初始化时 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律失败关闭(`503`),错误文案指向“模型目录未初始化”。恢复路径是修好上游可达性后重启 api-server,或由运维清空 `agc_model_catalog` 该行后再重启。 +- 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 @@ -48,6 +54,9 @@ ## 验收 +- 空目录 + 上游可达:启动后目录自动生成(`id` 为模型名 slug、`alias`/`modelId` 为上游原名、`enabled` 全为真、默认项为排序后第一项),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 就是上游原名,界面不出现任何内置模型名。 +- 空目录 + 上游不可达/空列表/非 2xx:启动只记录 error,不生成替代目录;AGC 接口与后台目录接口返回 `503`“模型目录未初始化”;下游可恢复后重启即同步成功(不需要人工造目录)。 +- 同一模型集合重复同步结果一致(上游返回顺序不影响目录与默认项)。 - 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。 - 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。 - 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 64030aad6..4f61f7f79 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -507,7 +507,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `agc_model_catalog` - 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。 -- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。 +- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁;缺行时读取返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不返回任何内置目录。 +- 目录初始值来自上游同步:api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,就用分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据)的 `data[].model_name` 生成目录(`id` 为模型名 slug,`alias`/`modelId` 为上游原名),失败只记录 error、不写替代目录,由下一次启动重试;未初始化期间 AGC 目录与对话接口、后台目录接口都失败关闭(`503`)。 - 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。 - Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ac0f0cb97..2bf1b55cf 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -589,6 +589,14 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 本地联调使用 `dev`,且 `GENARRATIVE_ENV` 为 `development`(默认)、`test` 或 `container` 时,允许规范 HTTP(S) loopback 地址及可变端口,无需配置独立埋点变量。客户端登录时使用实际 API 入口,容器使用宿主机映射入口。线上部署设置 `GENARRATIVE_ENV=production`,不接受 loopback 例外;详细合同见[客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)第 13 节。 +### AGC 模型目录上游同步 + +`api-server`(API/All 角色)启动时检查 `agc_model_catalog`:缺失、结构与当前定义不符或校验不通过都算未初始化,此时请求上游 Router 控制面的分组定价列表 `GET {GENARRATIVE_LLM_ROUTER_BASE_URL 去掉 /v1}/api/pricing?group=taonier`(公开只读接口,不带凭据),按返回的 `data[].model_name` 排序生成目录并写回(revision 自增):`modelId` 与 `alias` 都是上游原始模型名,`id` 是模型名的 slug,默认项为排序后第一项。目录结构、后台字段与客户端契约都保持不变。 + +上游不可达、返回非 2xx、列表为空或响应超过 1 MiB 时,启动日志打印 `AGC 模型目录未初始化:本次启动未从上游同步到模型列表…`,`GET /api/llm/models`、`/api/llm/responses` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`,不返回任何内置模型;修好上游可达性后重启 `api-server` 即会重试成功。目录只在未初始化时重建,上游新增或移除模型由后台「AGC 模型」页维护,不会自动跟随。存量目录(含旧版写死的 `高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)结构合法时不会被自动重建,需要 owner 在后台改掉,或清空 `agc_model_catalog` 该行后重启让其重新同步。 + +发布与回滚注意:本变更改的是 module 的缺行语义(由“返回内置目录”改为报错)与 api-server 的启动期同步,**module 与 api-server 必须同批发布、同批回滚**;混合版本期间未升级的 api-server 会把自己的 AGC 目录与对话接口打到 `503`(不会崩,但 AGC 不可用)。后台 DTO 与 admin-web 未改动,可独立发布。 + ### AGC 项目快照上传目标 后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 From 4234a21eed98249917951229d4edb68dc715d85f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:35 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=EF=BC=9AAGC?= =?UTF-8?q?=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E7=BC=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=97=B6=E6=94=B9=E4=B8=BA=E5=90=AF=E5=8A=A8=E6=9C=9F?= =?UTF-8?q?=E4=BB=8E=E4=B8=8A=E6=B8=B8=E5=90=8C=E6=AD=A5=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8E=9F=E7=9B=AE=E5=BD=95=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime:删除写死的初始目录(高质量→gpt-6-astra、快速→gpt-5.6-luna),新增 from_upstream_models:id 用模型名 slug、alias/modelId 用上游原名、默认项取排序后第一项;字段与校验规则不变 - spacetime-module:read_agc_model_catalog 缺行返回 AGC_MODEL_CATALOG_NOT_INITIALIZED,不再返回内置目录 - api-server:新增启动期 ensure_agc_model_catalog_initialized,未初始化时从分组定价列表 GET /api/pricing?group=taonier 生成目录并按存量 revision 写回;拉不到只记录 error、不写替代目录,下次启动重试 - api-server:目录未初始化时 AGC 目录/对话接口与后台目录接口返回 503,不回落任何内置模型名;后台 PUT 同样要求已初始化 - api-server:上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;目标校验只校验地址,不再绑定已下线的固定模型哨兵;写回冲突后重读校验既有目录 --- server-rs/crates/api-server/src/agc_models.rs | 377 +++++++++++++++++- .../api-server/src/external_api_keys.rs | 29 +- server-rs/crates/api-server/src/llm/mod.rs | 133 ++++-- server-rs/crates/api-server/src/main.rs | 14 + .../crates/module-runtime/src/agc_models.rs | 274 +++++++++++-- .../crates/spacetime-module/src/agc_models.rs | 10 +- 6 files changed, 749 insertions(+), 88 deletions(-) diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index dbd83c0cc..f0634b0df 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -7,26 +7,208 @@ use axum::{ extract::{Extension, State}, http::StatusCode, }; -use module_runtime::AgcModelCatalog; +use module_runtime::{ + AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcModelCatalog, +}; use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog}; +use spacetime_client::SpacetimeClientError; +use std::time::Duration; +use tracing::warn; + +/// 目录未初始化时对外统一的失败文案:目录只能来自上游同步或后台保存。 +pub(crate) const AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE: &str = + "模型目录未初始化,服务端正在尝试从上游同步,请稍后重试"; +/// 上游模型列表请求超时与响应大小上限;越界按同步失败处理。 +/// +/// 同步发生在启动期、且在开始对外服务之前,超时必须足够短:上游挂起时不能让 +/// 每个 API/All 实例都延迟三十秒才可用。单次失败只记录 error,下次启动会重试。 +const AGC_MODEL_LIST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const AGC_MODEL_LIST_MAX_BYTES: usize = 1024 * 1024; + +/// 只读 `revision`:存量目录内容不合法时,覆盖写入仍需对齐乐观锁版本。 +#[derive(serde::Deserialize)] +struct StoredCatalogRevision { + revision: u64, +} pub(crate) async fn load_catalog(state: &AppState) -> Result { - let json = state - .spacetime_client() - .read_agc_model_catalog() - .await - .map_err(|_| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用") - })?; - let catalog: AgcModelCatalog = serde_json::from_str(&json).map_err(|_| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录格式无效") - })?; - catalog.validate().map_err(|message| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) + let stored = read_stored_catalog(state).await.map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用") })?; + let Some(json) = stored else { + return Err(uninitialized_error()); + }; + parse_catalog(&json).map_err(|_| uninitialized_error()) +} + +fn uninitialized_error() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message(AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE) +} + +/// 解析并校验目录内容;解析或校验失败都按“未初始化”处理,由启动期重新同步。 +fn parse_catalog(json: &str) -> Result { + let catalog: AgcModelCatalog = + serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?; + catalog.validate()?; Ok(catalog) } +async fn read_stored_catalog(state: &AppState) -> Result, SpacetimeClientError> { + match state.spacetime_client().read_agc_model_catalog().await { + Ok(json) => Ok(Some(json)), + Err(SpacetimeClientError::Procedure(message)) + if message == AGC_MODEL_CATALOG_NOT_INITIALIZED => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +/// 启动期确保目录已初始化:未初始化时从上游模型列表重建,失败只返回错误由调用方记录。 +/// +/// 目录已可用时不做任何写入;只有缺失、结构与当前定义不符或校验不通过才重建,因此上游模型 +/// 变化不会自动覆盖后台维护过的目录。 +pub(crate) async fn ensure_agc_model_catalog_initialized(state: &AppState) -> Result<(), String> { + let stored = read_stored_catalog(state) + .await + .map_err(|error| format!("读取 AGC 模型目录失败:{error}"))?; + let revision = match stored.as_deref() { + Some(json) => match parse_catalog(json) { + Ok(_) => return Ok(()), + Err(message) => { + warn!( + error = %message, + "AGC 模型目录内容与当前定义不符,按未初始化处理并从上游重建" + ); + stored_catalog_revision(json) + } + }, + None => Some(0), + }; + let revision = revision.ok_or_else(|| { + "存量 AGC 模型目录缺少可解析的 revision,需要先清理该行再重启".to_string() + })?; + + let models = fetch_upstream_model_names(state).await?; + let catalog = AgcModelCatalog::from_upstream_models(models, revision)?; + let payload = + serde_json::to_string(&catalog).map_err(|_| "AGC 模型目录序列化失败".to_string())?; + match state + .spacetime_client() + .save_agc_model_catalog(payload) + .await + { + Ok(saved) => { + let saved: AgcModelCatalog = serde_json::from_str(&saved) + .map_err(|_| "AGC 模型目录写回结果格式无效".to_string())?; + tracing::info!( + revision = saved.revision, + model_count = saved.models.len(), + "已按上游模型列表初始化 AGC 模型目录" + ); + Ok(()) + } + // 多实例同时启动时只有一个写入成功:接受既有目录,但仍要确认它可用, + // 否则会静默地把「每次启动都冲突、目录一直不可用」变成没有任何线索的黑洞。 + Err(SpacetimeClientError::Procedure(message)) if message == AGC_MODEL_CATALOG_CONFLICT => { + let stored = read_stored_catalog(state) + .await + .map_err(|error| format!("写入冲突后重读 AGC 模型目录失败:{error}"))?; + if stored + .as_deref() + .map(parse_catalog) + .is_some_and(|result| result.is_ok()) + { + warn!("AGC 模型目录写入冲突:已接受其它实例写入的目录"); + Ok(()) + } else { + Err("AGC 模型目录写入冲突后仍不可用,需要人工检查该行内容与 revision".to_string()) + } + } + Err(error) => Err(format!("写入 AGC 模型目录失败:{error}")), + } +} + +fn stored_catalog_revision(json: &str) -> Option { + serde_json::from_str::(json) + .ok() + .map(|stored| stored.revision) +} + +/// 上游在售模型列表:`GET {Router 控制面}/api/pricing?group=taonier` 的 `data[].model_name`。 +/// +/// 取“该分组可见的在售模型”,而不是管理面模型注册表:注册表里会残留已下线、没有路由绑定的 +/// 条目(例如已从上游移除的 `gpt-6-astra`/`gpt-6-luna`),而定价列表就是 AGC 账号实际能调用的集合。 +/// 该端点是公开只读接口,不需要管理凭据。 +async fn fetch_upstream_model_names(state: &AppState) -> Result, String> { + crate::external_api_keys::ensure_llm_router_url_allowed(state)?; + let origin = + crate::external_api_keys::router_control_origin(&state.config.llm_router_base_url)?; + let url = format!( + "{origin}/api/pricing?group={}", + crate::external_api_keys::LLM_ROUTER_TOKEN_GROUP + ); + let client = reqwest::Client::builder() + .timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?; + let response = client + .get(url) + .send() + .await + .map_err(|error| format!("请求上游模型列表失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("上游模型列表返回 HTTP {status}")); + } + let bytes = read_bounded_json_body(response).await?; + let payload: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| "上游模型列表格式无效".to_string())?; + parse_upstream_model_names(&payload) +} + +async fn read_bounded_json_body(mut response: reqwest::Response) -> Result, String> { + // 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度, + // 逐块累计超阈值立即中断,避免 `bytes()` 一次性分配任意大小响应撑爆内存。 + if response + .content_length() + .is_some_and(|length| length > AGC_MODEL_LIST_MAX_BYTES as u64) + { + return Err("上游模型列表响应超过大小上限".to_string()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("读取上游模型列表失败:{error}"))? + { + if bytes.len().saturating_add(chunk.len()) > AGC_MODEL_LIST_MAX_BYTES { + return Err("上游模型列表响应超过大小上限".to_string()); + } + bytes.extend_from_slice(chunk.as_ref()); + } + Ok(bytes) +} + +fn parse_upstream_model_names(payload: &serde_json::Value) -> Result, String> { + let data = payload + .get("data") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?; + let models = data + .iter() + .filter_map(|entry| entry.get("model_name").and_then(serde_json::Value::as_str)) + .map(str::to_string) + .collect::>(); + if models.iter().all(|model| model.trim().is_empty()) { + return Err("上游模型列表为空".to_string()); + } + Ok(models) +} + pub async fn admin_get_agc_models( State(state): State, Extension(context): Extension, @@ -44,6 +226,8 @@ pub async fn admin_save_agc_models( Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { + // 目录只来自上游同步:未初始化时后台写入同样失败关闭,避免出现第二条绕过同步的写入口。 + load_catalog(&state).await?; let catalog = AgcModelCatalog { revision: payload.revision, default_model_id: payload.default_model_id, @@ -68,7 +252,7 @@ pub async fn admin_save_agc_models( .save_agc_model_catalog(payload) .await .map_err(|error| { - if matches!(error, spacetime_client::SpacetimeClientError::Procedure(ref message) if message == module_runtime::AGC_MODEL_CATALOG_CONFLICT) { + if matches!(&error, SpacetimeClientError::Procedure(message) if message == AGC_MODEL_CATALOG_CONFLICT) { AppError::from_status(StatusCode::CONFLICT).with_message("模型目录已被更新,请重新读取") } else { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("保存模型目录失败,请稍后重试") @@ -95,3 +279,168 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { .collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn upstream_model_names_come_from_pricing_data_array() { + let payload = json!({ + "auto_groups": ["default"], + "data": [ + {"model_name": "glm-5.3", "model_ratio": 1.0}, + {"model_name": "deepseek-flash", "model_ratio": 0.075}, + {"model_ratio": 1.0} + ] + }); + assert_eq!( + parse_upstream_model_names(&payload).unwrap(), + vec!["glm-5.3".to_string(), "deepseek-flash".to_string()] + ); + + assert_eq!( + parse_upstream_model_names(&json!({"data": []})).unwrap_err(), + "上游模型列表为空" + ); + assert_eq!( + parse_upstream_model_names(&json!({"data": [{"model_name": " "}]})).unwrap_err(), + "上游模型列表为空" + ); + assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err()); + } + + #[test] + fn stored_catalog_revision_reads_row_revision() { + assert_eq!( + stored_catalog_revision( + r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"# + ), + Some(4) + ); + assert_eq!(stored_catalog_revision("not json"), None); + assert_eq!(stored_catalog_revision(r#"{"models":[]}"#), None); + } + + #[test] + fn catalog_parsing_marks_unusable_content_as_uninitialized() { + // 后台保存过的目录结构必须能直接解析。 + let catalog = AgcModelCatalog::from_upstream_models( + vec!["deepseek-v4-pro".to_string(), "glm-5.3".to_string()], + 4, + ) + .unwrap(); + assert_eq!( + parse_catalog(&serde_json::to_string(&catalog).unwrap()).unwrap(), + catalog + ); + + // 结构或内容不合法(例如被外部工具改过)都按未初始化处理,由启动期重新同步。 + assert!( + parse_catalog(r#"{"revision":1,"defaultModel":"deepseek-v4-pro","models":[]}"#) + .is_err() + ); + assert!(parse_catalog( + r#"{"revision":1,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":false}]}"# + ) + .is_err()); + } + + struct MockModelListServer { + base_url: String, + captured: std::sync::Arc>>, + _handle: std::thread::JoinHandle<()>, + } + + fn spawn_mock_model_list_server(status_line: &str, body: &str) -> MockModelListServer { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("mock listener binds"); + let address = listener.local_addr().expect("mock address"); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let captured_for_thread = std::sync::Arc::clone(&captured); + let response = format!( + "HTTP/1.1 {status_line}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock accept"); + let mut buffer = [0u8; 8192]; + let read = stream.read(&mut buffer).unwrap_or_default(); + *captured_for_thread.lock().expect("captured lock") = + Some(String::from_utf8_lossy(&buffer[..read]).to_string()); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + }); + MockModelListServer { + base_url: format!("http://{address}/v1"), + captured, + _handle: handle, + } + } + + fn model_list_state(base_url: &str) -> AppState { + AppState::new(crate::config::AppConfig { + llm_router_base_url: base_url.to_string(), + ..crate::config::AppConfig::default() + }) + .expect("state should build") + } + + #[tokio::test] + async fn fetch_upstream_model_names_reads_group_pricing_without_credentials() { + let server = spawn_mock_model_list_server( + "200 OK", + &json!({"data": [{"model_name": "glm-5.3"}, {"model_name": "deepseek-flash"}]}) + .to_string(), + ); + let state = model_list_state(&server.base_url); + + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap(), + vec!["glm-5.3".to_string(), "deepseek-flash".to_string()] + ); + + let request = server + .captured + .lock() + .expect("captured lock") + .clone() + .expect("mock server should capture request"); + // 控制面路径由 base_url 推导(去掉 /v1),并显式带 AGC 账号所在分组。 + assert!( + request.starts_with("GET /api/pricing?group=taonier HTTP/1.1"), + "{request}" + ); + // 定价列表是公开只读接口:不得把任何凭据发过去。 + assert!( + !request.to_ascii_lowercase().contains("authorization:"), + "{request}" + ); + } + + #[tokio::test] + async fn fetch_upstream_model_names_fails_closed_when_upstream_unavailable_or_empty() { + let unauthorized = spawn_mock_model_list_server("401 Unauthorized", "{}"); + let state = model_list_state(&unauthorized.base_url); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表返回 HTTP 401 Unauthorized" + ); + + let empty = spawn_mock_model_list_server("200 OK", &json!({"data": []}).to_string()); + let state = model_list_state(&empty.base_url); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表为空" + ); + + let failing = spawn_mock_model_list_server("500 Internal Server Error", "{}"); + let state = model_list_state(&failing.base_url); + assert_eq!( + fetch_upstream_model_names(&state).await.unwrap_err(), + "上游模型列表返回 HTTP 500 Internal Server Error" + ); + } +} diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 3e70d8e5f..cef612c13 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -49,7 +49,7 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate"; /// Router 用户(账号)与它名下固定 Token / API Key 都归属同一分组 `taonier`。 const LLM_ROUTER_USER_GROUP: &str = "taonier"; -const LLM_ROUTER_TOKEN_GROUP: &str = "taonier"; +pub(crate) const LLM_ROUTER_TOKEN_GROUP: &str = "taonier"; const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"]; const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1; const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60; @@ -1624,7 +1624,7 @@ async fn ensure_router_token_contract( Ok(()) } -fn router_control_origin(base_url: &str) -> Result { +pub(crate) fn router_control_origin(base_url: &str) -> Result { let mut url = reqwest::Url::parse(base_url.trim_end_matches('/')) .map_err(|error| format!("LLM Router 地址无效:{error}"))?; let is_loopback = url.host_str().is_some_and(|host| { @@ -1643,7 +1643,11 @@ fn router_control_origin(base_url: &str) -> Result { Ok(url.to_string().trim_end_matches('/').to_string()) } -fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { +/// 只校验 LLM Router 目标地址是否允许(官方路由 / loopback、scheme),不校验固定模型。 +/// +/// 与具体模型无关的调用(例如按分组定价列表同步 AGC 模型目录)用这个入口, +/// 避免被“必须使用官方固定模型”的哨兵常量挡住。 +pub(crate) fn ensure_llm_router_url_allowed(state: &AppState) -> Result<(), String> { let base_url = state.config.llm_router_base_url.trim_end_matches('/'); let url = reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?; @@ -1664,9 +1668,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { if base_url != OFFICIAL_LLM_ROUTER_BASE_URL { return Err("生产环境 LLM Router 必须使用官方固定路由".to_string()); } - if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { - return Err("生产环境 LLM Router 必须使用官方固定模型".to_string()); - } if url.scheme() != "https" { return Err("生产环境 LLM Router 只允许 HTTPS 地址".to_string()); } @@ -1674,9 +1675,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { } if base_url == OFFICIAL_LLM_ROUTER_BASE_URL { - if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { - return Err("LLM Router 必须使用官方固定模型".to_string()); - } if url.scheme() != "https" { return Err("官方 LLM Router 只允许 HTTPS 地址".to_string()); } @@ -1698,6 +1696,19 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { Ok(()) } +pub(crate) fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { + ensure_llm_router_url_allowed(state)?; + if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { + if state.config.is_production() { + return Err("生产环境 LLM Router 必须使用官方固定模型".to_string()); + } + if state.config.llm_router_base_url.trim_end_matches('/') == OFFICIAL_LLM_ROUTER_BASE_URL { + return Err("LLM Router 必须使用官方固定模型".to_string()); + } + } + Ok(()) +} + fn router_username_for_owner(owner_user_id: &str) -> String { // New API 的 User.Username 校验上限是 20 个字符。保留可读前缀后只 // 能放 11 个字符;使用完整 owner id 做 SHA-256,再编码成 8 字节的 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 32ee19402..025823e3f 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -37,18 +37,24 @@ mod model_catalog_tests { use super::*; #[test] - fn public_catalog_only_exposes_alias_and_stable_id() { - let mut catalog = module_runtime::AgcModelCatalog::default(); - catalog.revision = 7; + fn public_catalog_exposes_stable_id_and_upstream_alias() { + let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], + 7, + ) + .expect("catalog should build"); catalog.models[1].enabled = false; let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); + // 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。 assert_eq!( payload["models"], - json!([{"id": "quality", "displayName": "高质量"}]) + json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol"}]) ); - assert_eq!(payload["defaultModelId"], "quality"); + assert_eq!(payload["defaultModelId"], "gpt-5-6-sol"); assert_eq!(payload["revision"], json!(7)); - assert!(!payload.to_string().contains("gpt-")); + assert!(payload.get("defaultModel").is_none()); + assert!(payload["models"][0].get("enabled").is_none()); + assert!(payload["models"][0].get("modelId").is_none()); } } @@ -194,6 +200,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe .filter(|model| model.enabled) .map(|model| LlmModelSummary { id: model.id, + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 display_name: model.alias, }) .collect(), @@ -201,6 +208,29 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe } } +/// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」, +/// 也验证标识 → 实际模型名的映射;默认项是排序后的第一项,`TEST_AGC_MODEL_ID` 不是默认项。 +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_ID: &str = "test-router-model"; +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_MODEL_ID: &str = "test-router.model"; +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_DEFAULT_ID: &str = "test-router-default"; +#[cfg(test)] +pub(crate) const TEST_AGC_MODEL_DEFAULT_MODEL_ID: &str = "test-router.default"; + +#[cfg(test)] +pub(crate) fn test_agc_model_catalog() -> module_runtime::AgcModelCatalog { + module_runtime::AgcModelCatalog::from_upstream_models( + vec![ + TEST_AGC_MODEL_DEFAULT_MODEL_ID.to_string(), + TEST_AGC_MODEL_MODEL_ID.to_string(), + ], + 0, + ) + .expect("test catalog should build") +} + async fn load_llm_catalog( state: &AppState, owner: &str, @@ -211,7 +241,7 @@ async fn load_llm_catalog( .expect("fixture lock") .contains_key(owner) { - return Ok(module_runtime::AgcModelCatalog::default()); + return Ok(test_agc_model_catalog()); } let _ = owner; crate::agc_models::load_catalog(state).await @@ -283,9 +313,8 @@ pub async fn proxy_llm_responses( ] { object.remove(field); } - // The AGC client may select a model from the server-provided Router - // directory. Older callers without the reserved marker remain pinned to - // the official default model. + // AGC 客户端可以在服务端目录内选择模型;`model` 就是上游原始模型名。 + // 老客户端存的历史稳定标识与目录外模型一律拒绝,不回退其它模型。 let agc_client = headers .get("x-genarrative-client") .and_then(|value| value.to_str().ok()) @@ -293,16 +322,13 @@ pub async fn proxy_llm_responses( let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; - let selected_id = if agc_client { - requested_model - .as_deref() - .filter(|id| *id != "platform-default") + let requested_model = if agc_client { + requested_model.as_deref() } else { None - } - .unwrap_or(&catalog.default_model_id); + }; let selected_model = catalog - .resolve(selected_id) + .resolve_requested(requested_model) .map_err(|message| { llm_error_response( &request_context, @@ -847,7 +873,7 @@ async fn resolve_llm_router_client( let catalog = load_llm_catalog(state, owner_user_id) .await .map_err(|_| "模型目录暂不可用".to_string())?; - let model = catalog.resolve(&catalog.default_model_id)?; + let model = catalog.resolve_requested(None)?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), @@ -1304,11 +1330,14 @@ mod tests { } #[tokio::test] - async fn llm_responses_proxy_forces_official_model_and_keeps_router_key_server_side() { + async fn llm_responses_without_agc_marker_uses_catalog_default_and_keeps_router_key_server_side() + { let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", - body: r#"{"id":"resp_proxy_01","model":"gpt-6-astra","output":[]}"#.to_string(), + body: format!( + r#"{{"id":"resp_proxy_01","model":"{TEST_AGC_MODEL_DEFAULT_MODEL_ID}","output":[]}}"# + ), extra_headers: Vec::new(), }); let (state, user_id) = seed_authenticated_state(AppConfig { @@ -1373,12 +1402,64 @@ mod tests { .expect("upstream request body"); let upstream_payload: Value = serde_json::from_str(upstream_body).expect("upstream body should be json"); - assert_eq!(upstream_payload["model"], "gpt-6-astra"); + assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID); assert_ne!(upstream_payload["model"], "client-must-not-control"); } #[tokio::test] - async fn llm_responses_rejects_upstream_names_and_unknown_catalog_ids() { + async fn llm_responses_forwards_catalog_model_selected_by_agc_client() { + let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: format!( + r#"{{"id":"resp_proxy_02","model":"{TEST_AGC_MODEL_MODEL_ID}","output":[]}}"# + ), + extra_headers: Vec::new(), + }); + let (state, user_id) = seed_authenticated_state(AppConfig { + llm_router_base_url: server_url.clone(), + llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()), + ..AppConfig::default() + }) + .await; + install_test_provisioned_router_credential(&user_id, server_url, "fixture-router-key"); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-client", "agc") + .header("content-type", "application/json") + .body(Body::from( + json!({"model": TEST_AGC_MODEL_ID, "input": "hello"}).to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + + let upstream_request = captured_request + .lock() + .expect("captured request lock") + .clone() + .expect("mock server should capture upstream request"); + let (_, upstream_body) = upstream_request + .split_once("\r\n\r\n") + .expect("upstream request body"); + let upstream_payload: Value = + serde_json::from_str(upstream_body).expect("upstream body should be json"); + // 客户端只能回传目录标识,服务端映射成上游实际模型名;默认项不参与。 + assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_MODEL_ID); + assert_ne!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID); + } + + #[tokio::test] + async fn llm_responses_rejects_models_outside_catalog() { let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; install_test_provisioned_router_credential( &user_id, @@ -1387,7 +1468,13 @@ mod tests { ); let token = issue_access_token(&state, &user_id); let app = build_router(state); - for model in ["gpt-6-astra", "unlisted"] { + // 历史稳定标识、目录外名称、以及「直接拿上游实际模型名当标识」都必须拒绝。 + for model in [ + "quality", + "gpt-6-astra", + "unlisted", + TEST_AGC_MODEL_MODEL_ID, + ] { let response = app .clone() .oneshot( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 241551c49..8515721f5 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -500,6 +500,10 @@ fn should_initialize_editor_generation_pricing_for_startup(process_role: Process process_role.runs_http() } +fn should_initialize_agc_model_catalog_for_startup(process_role: ProcessRole) -> bool { + process_role.runs_http() +} + async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let bind_address = config.bind_socket_addr(); let listen_backlog = config.listen_backlog; @@ -764,6 +768,16 @@ async fn try_restore_app_state_for_startup( )) })?; } + // AGC 模型目录只来自上游同步或后台保存;这里同步失败不阻塞启动,由下一次启动重试, + // 未初始化期间 AGC 相关接口失败关闭。 + if should_initialize_agc_model_catalog_for_startup(process_role) { + if let Err(error) = crate::agc_models::ensure_agc_model_catalog_initialized(&state).await { + error!( + error = %error, + "AGC 模型目录未初始化:本次启动未从上游同步到模型列表,AGC 目录与对话接口将失败关闭,下次启动会重试" + ); + } + } Ok(state) } diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 74c9f96ec..216dc1aaf 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -1,9 +1,18 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +/// 目录 revision 乐观锁冲突。 pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT"; +/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。 +pub const AGC_MODEL_CATALOG_NOT_INITIALIZED: &str = "AGC_MODEL_CATALOG_NOT_INITIALIZED"; +/// 客户端未显式选择模型时使用的占位标识。 +pub const AGC_MODEL_PLATFORM_DEFAULT: &str = "platform-default"; +/// 模型标识的长度上限,与客户端 `select_game_creator_model` 的校验保持一致。 +pub const AGC_MODEL_ID_MAX_BYTES: usize = 64; +/// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。 +pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgcModel { pub id: String, @@ -12,7 +21,7 @@ pub struct AgcModel { pub enabled: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgcModelCatalog { pub revision: u64, @@ -20,40 +29,62 @@ pub struct AgcModelCatalog { pub models: Vec, } -impl Default for AgcModelCatalog { - fn default() -> Self { - Self { - revision: 0, - default_model_id: "quality".into(), - models: vec![ - AgcModel { - id: "quality".into(), - alias: "高质量".into(), - model_id: "gpt-6-astra".into(), - enabled: true, - }, - AgcModel { - id: "fast".into(), - alias: "快速".into(), - model_id: "gpt-5.6-luna".into(), - enabled: true, - }, - ], - } - } -} - impl AgcModelCatalog { + /// 按上游模型列表生成目录:`modelId` 是上游原始模型名,`alias` 也直接用原名 + /// (不再填「高质量/快速」这类人工别名),`id` 是模型名的稳定 slug。 + /// + /// 上游返回顺序不稳定,所以先按原始模型名排序再生成,重复同步得到一致的目录与默认项。 + pub fn from_upstream_models( + models: impl IntoIterator, + revision: u64, + ) -> Result { + let mut model_names = models + .into_iter() + .map(|model| model.trim().to_string()) + .filter(|model| !model.is_empty()) + .collect::>(); + model_names.sort(); + model_names.dedup(); + if model_names.is_empty() { + return Err("上游模型列表为空".into()); + } + + let mut used_ids = HashSet::new(); + let mut entries = Vec::with_capacity(model_names.len()); + for model_id in model_names { + let id = unique_model_id(&model_id, &mut used_ids); + entries.push(AgcModel { + id, + alias: model_id.clone(), + model_id, + enabled: true, + }); + } + let default_model_id = entries + .first() + .map(|entry| entry.id.clone()) + .ok_or_else(|| "上游模型列表为空".to_string())?; + let catalog = Self { + revision, + default_model_id, + models: entries, + }; + catalog.validate()?; + Ok(catalog) + } + pub fn validate(&self) -> Result<(), String> { - if self.models.is_empty() || self.models.len() > 32 { - return Err("模型列表必须包含 1 至 32 项".into()); + if self.models.is_empty() || self.models.len() > AGC_MODEL_CATALOG_MAX_MODELS { + return Err(format!( + "模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项" + )); } let mut ids = HashSet::new(); let mut aliases = HashSet::new(); for model in &self.models { if model.id.is_empty() - || model.id == "platform-default" - || model.id.len() > 64 + || model.id == AGC_MODEL_PLATFORM_DEFAULT + || model.id.len() > AGC_MODEL_ID_MAX_BYTES || !model .id .bytes() @@ -88,31 +119,202 @@ impl AgcModelCatalog { .map(|m| m.model_id.as_str()) .ok_or_else(|| "所选模型不可用,请刷新模型列表".into()) } + + /// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。 + pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> { + let requested = requested + .map(str::trim) + .filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT); + match requested { + Some(id) => self.resolve(id), + None => self.resolve(&self.default_model_id), + } + } +} + +/// 由上游模型名生成稳定标识:只保留小写字母、数字、连字符与下划线,其余字符折叠成 `-`。 +fn agc_model_id_from_name(model_name: &str) -> String { + let mut id = String::new(); + let mut separator_pending = false; + for value in model_name.chars() { + let lowered = value.to_ascii_lowercase(); + if lowered.is_ascii_alphanumeric() || lowered == '_' { + if separator_pending && !id.is_empty() { + id.push('-'); + } + separator_pending = false; + id.push(lowered); + } else { + separator_pending = true; + } + } + id +} + +/// 生成在本次目录内唯一的标识:同名 slug 追加 `-2`/`-3`,并保证不超过长度上限。 +fn unique_model_id(model_name: &str, used_ids: &mut HashSet) -> String { + let slug = agc_model_id_from_name(model_name); + let slug = if slug.is_empty() { + "model".to_string() + } else { + slug + }; + // 预留后缀空间(`-` 加最多两位序号)后截断,保证候选标识仍在长度上限内。 + let base = slug + .char_indices() + .take_while(|(index, _)| *index < AGC_MODEL_ID_MAX_BYTES - 3) + .map(|(_, value)| value) + .collect::(); + let base = base.trim_end_matches('-').to_string(); + let base = if base.is_empty() { + "model".to_string() + } else { + base + }; + + let mut candidate = base.clone(); + let mut suffix = 2; + while !used_ids.insert(candidate.clone()) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + candidate } #[cfg(test)] mod tests { use super::*; + fn upstream(models: &[&str]) -> Vec { + models.iter().map(|model| (*model).to_string()).collect() + } + + fn model(id: &str, alias: &str, model_id: &str) -> AgcModel { + AgcModel { + id: id.into(), + alias: alias.into(), + model_id: model_id.into(), + enabled: true, + } + } + + #[test] + fn catalog_builds_from_upstream_models_with_stable_ids() { + let catalog = AgcModelCatalog::from_upstream_models( + upstream(&[ + " qwen3.8-flash ", + "glm-5.3", + "qwen3.8-flash", + "deepseek-v4-pro", + "", + "vendor/model.v1:latest", + ]), + 3, + ) + .unwrap(); + + assert_eq!(catalog.revision, 3); + // 默认项是排序后第一项,与上游返回顺序无关。 + assert_eq!(catalog.default_model_id, "deepseek-v4-pro"); + assert_eq!( + catalog.models, + vec![ + model("deepseek-v4-pro", "deepseek-v4-pro", "deepseek-v4-pro"), + model("glm-5-3", "glm-5.3", "glm-5.3"), + model("qwen3-8-flash", "qwen3.8-flash", "qwen3.8-flash"), + model( + "vendor-model-v1-latest", + "vendor/model.v1:latest", + "vendor/model.v1:latest" + ), + ] + ); + assert!(catalog.validate().is_ok()); + // 同一模型集合重复生成结果一致。 + assert_eq!( + AgcModelCatalog::from_upstream_models( + upstream(&[ + "vendor/model.v1:latest", + "deepseek-v4-pro", + "glm-5.3", + "qwen3.8-flash", + ]), + 3 + ) + .unwrap(), + catalog + ); + assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err()); + } + + #[test] + fn catalog_keeps_ids_unique_and_within_client_contract() { + // 不同模型名折叠成同一个 slug 时按排序追加序号,且标识始终符合客户端校验。 + let catalog = AgcModelCatalog::from_upstream_models( + upstream(&["GLM-5.3", "glm/5.3", "glm_5.3", "模型名"]), + 0, + ) + .unwrap(); + let ids = catalog + .models + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["glm-5-3", "glm-5-3-2", "glm_5-3", "model"]); + for entry in &catalog.models { + assert!(entry.id.len() <= AGC_MODEL_ID_MAX_BYTES); + assert!( + entry + .id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + ); + } + assert!(catalog.validate().is_ok()); + } + #[test] fn catalog_maps_only_enabled_ids() { - let mut catalog = AgcModelCatalog::default(); + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); assert!(catalog.validate().is_ok()); - assert_eq!(catalog.resolve("quality").unwrap(), "gpt-6-astra"); - assert!(catalog.resolve("gpt-6-astra").is_err()); - assert!(catalog.resolve("unknown").is_err()); + assert_eq!(catalog.resolve("model-a").unwrap(), "model-a"); + // 客户端不能直接指定实际模型名,只能回传目录标识。 + assert!(catalog.resolve("model-c").is_err()); + assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a"); + assert_eq!( + catalog + .resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT)) + .unwrap(), + "model-a" + ); catalog.models[0].enabled = false; - assert!(catalog.resolve("quality").is_err()); + assert!(catalog.resolve("model-a").is_err()); assert!(catalog.validate().is_err()); } #[test] fn catalog_rejects_duplicate_aliases_and_ids() { - let mut catalog = AgcModelCatalog::default(); + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); catalog.models[1].alias = catalog.models[0].alias.clone(); assert!(catalog.validate().is_err()); - catalog.models[1].alias = "快速".into(); + catalog.models[1].alias = "model-b".into(); catalog.models[1].id = catalog.models[0].id.clone(); assert!(catalog.validate().is_err()); + catalog.models[1].id = "model-b".into(); + catalog.models[1].id = AGC_MODEL_PLATFORM_DEFAULT.into(); + assert!(catalog.validate().is_err()); + catalog.models[1].id = "model-b".into(); + catalog.models[1].model_id = "".into(); + assert!(catalog.validate().is_err()); + + let too_many = (0..AGC_MODEL_CATALOG_MAX_MODELS + 1) + .map(|index| format!("model-{index}")) + .collect::>(); + assert_eq!( + AgcModelCatalog::from_upstream_models(too_many, 0).unwrap_err(), + format!("模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项") + ); } } diff --git a/server-rs/crates/spacetime-module/src/agc_models.rs b/server-rs/crates/spacetime-module/src/agc_models.rs index 5fba815ab..03bbb3536 100644 --- a/server-rs/crates/spacetime-module/src/agc_models.rs +++ b/server-rs/crates/spacetime-module/src/agc_models.rs @@ -16,16 +16,14 @@ pub fn read_agc_model_catalog(ctx: &mut ProcedureContext) -> Result Date: Thu, 24 Sep 2026 16:14:43 +0800 Subject: [PATCH 07/10] =?UTF-8?q?AGC=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E6=AD=BB=20gpt-6-astra=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.rs:DEFAULT_GAME_CREATOR_LLM_MODEL 改用官方占位标识 platform-default - game-creator.config.json:首启模板 llm.model 同步为 platform-default - check-config.mjs 与客户端测试同步该口径,去掉已退役模型名的断言 --- apps/ai-game-creator-shell/game-creator.config.json | 2 +- apps/ai-game-creator-shell/scripts/check-config.mjs | 9 +++++++++ apps/ai-game-creator-shell/src-tauri/src/main.rs | 4 +++- .../src-tauri/src/tests/configuration.rs | 5 +++++ .../tests/conversationModelSelect.test.tsx | 2 +- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 3c0005da3..4aa61279a 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -6,7 +6,7 @@ "visibleModels": [], "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", - "model": "gpt-6-astra", + "model": "platform-default", "apiKind": "openai_responses", "reasoningEffort": "max", "stream": true, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b22a44081..6b2c74b13 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1497,6 +1497,15 @@ if (defaultAppConfig.llm?.apiKey !== '') { throw new Error('AI game creator shell default llm.apiKey must stay empty'); } +// 首次启动模板必须写入官方路由占位模型(与 config.rs 的 +// OFFICIAL_LLM_ROUTER_DEFAULT_MODEL 同源):钉死具体上游模型名会随上游目录 +// 变动失效,留空则首启配置不合法。 +if (defaultAppConfig.llm?.model !== 'platform-default') { + throw new Error( + 'AI game creator shell default llm.model must stay the official route placeholder', + ); +} + if (defaultAppConfig.agentMode !== 'codex_app_server') { throw new Error( 'AI game creator shell default agentMode must be codex_app_server', diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7175acf53..061c90877 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1457,7 +1457,9 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1"; -const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra"; +// 默认模型不再写死具体上游模型名:正式构建锁定官方路由, +// 未选择平台目录模型时该占位标识表示“跟随平台默认”(与 config.rs 同源)。 +const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = OFFICIAL_LLM_ROUTER_DEFAULT_MODEL; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 121354839..4f7b446ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -554,6 +554,11 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { template.llm.as_ref().and_then(|llm| llm.max_retries), Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) ); + assert_eq!( + template.llm.as_ref().and_then(|llm| llm.model.as_deref()), + Some(DEFAULT_GAME_CREATOR_LLM_MODEL), + "首次启动模板必须写入官方路由占位模型,不能钉死具体上游模型名" + ); assert!( template.agent_llm.unwrap_or_default().is_empty(), "bundled template must not persist canonical defaults as explicit overrides" diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index c7441a14f..7e9fa6f87 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -297,7 +297,7 @@ test('only displays aliases and persists selection through the native command', const onReady = vi.fn(); render(); await screen.findByRole('button', { name: '对话模型' }); - expect(screen.queryByText('gpt-6-astra')).toBeNull(); + expect(screen.queryByText('quality')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); fireEvent.click(screen.getByRole('option', { name: '快速' })); await waitFor(() => From f04d9ae3f5cd5c201961206c6efb206eb88545fb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:56 +0800 Subject: [PATCH 08/10] =?UTF-8?q?AGC=20=E6=A8=A1=E5=9E=8B=E5=BC=B9?= =?UTF-8?q?=E5=B1=82=E5=8E=BB=E6=8E=89=E6=BB=9A=E5=8A=A8=E6=9D=A1=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E6=9C=80=E9=95=BF=E6=A8=A1=E5=9E=8B=E5=90=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8B=93=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .conversation-model-menu 宽度改为 max-content(保留 min-width 150px 与视口宽度上限),不再用固定 150–190px 截断 - 去掉 max-height/overflow 滚动,菜单按目录项展开 --- apps/ai-game-creator-shell/src/styles.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 368ddf987..4e1aa4437 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -10975,14 +10975,13 @@ button.design-workspace-tree__entry:hover, bottom: calc(100% + 8px); z-index: 20; display: grid; + /* 目录项就是上游原始模型名,长度不可控:菜单按最宽条目自动拓宽(锚在触发钮右缘, + 向左侧生长),不再用固定 150–190px 把名字截掉;只有极端长名字才受视口宽度限制。 */ + width: max-content; min-width: 150px; - max-width: 190px; - /* 条目多时菜单不能无限长:240px 与视口 40vh 取小者,超出部分在菜单内滚动 - (窄屏 / 移动端优先下 40vh 更稳)。滚动不外溢给背后的消息列表,与 - `.resource-reference-menu` 同一口径。 */ - max-height: min(240px, 40vh); - overflow: auto; - overscroll-behavior: contain; + max-width: calc(100vw - 24px); + /* 目录规模由后台维护(当前是上游在售的个位数模型),菜单按内容高度展开, + 不再设 max-height,因此不会出现滚动条。 */ padding: 5px; border: 1px solid var(--platform-surface-border, #e5e7eb); border-radius: 10px; From 8514f1094ca433b4bfa630b405b2b0c3185c4bec Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 16:14:59 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E5=BF=BD=E7=95=A5=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E7=9A=84=20server-rs/.data=20=E8=BF=90=E8=A1=8C=E6=9C=9F?= =?UTF-8?q?=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以 crates/api-server 为 cwd 启动时会生成 server-rs/crates/api-server/server-rs/.data,补充 **/server-rs/.data/ 忽略规则 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c278ff3fa..5529773cd 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ temp*build*/ /apps/preview-deployer-web/node_modules/ /server-rs/.spacetimedb/ /server-rs/.data/ +**/server-rs/.data/ /public/generated-animations /public/generated-character-drafts /public/generated-characters From 2e45608f6821694dd37170201c1b9c4758afcdea Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 17:28:38 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E9=97=B8=E9=97=A8=E8=A1=A5=20leader=20?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E6=8E=A5=E7=AE=A1=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB=E7=9B=AE=E6=A0=87=E6=B0=B8=E4=B9=85=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acl_repair_gate:策略新增 leader_deadline(默认 5 分钟),超过后新调用接管仍是 running 的 key;Entry 记 started_at + leader_id,被接管后旧 leader 迟到的结果按令牌丢弃,不覆盖接管者写下的结果 - acl_repair_gate:complete()/Drop 改为按令牌就地更新(不再无条件 insert),panic 兜底与接管语义保持一致 - acl_repair_gate:running 状态的 entry 不再写 recorded_at(那时还没有结果),冷却基准只在真正落库时记录 - tests/acl_repair_gate:新增 stale_leader_is_taken_over_and_its_late_result_is_discarded;临时关掉接管分支即红(逆向确认:Reused(Failed(...)) 而非 Executed(Repaired)) - docs:decision-log 记 leader 失效接管决策,pitfalls 把「已知残余边界」改成已兜底说明,验证清单补该用例 --- .../src-tauri/src/acl_repair_gate.rs | 60 +++++++++++++------ .../src-tauri/src/tests/acl_repair_gate.rs | 45 ++++++++++++++ .../shared-memory/decision-log.md | 1 + docs/project-memory/shared-memory/pitfalls.md | 4 +- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs index 2902ec5f6..f473568f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Condvar, LazyLock, Mutex}; use std::time::{Duration, Instant}; @@ -31,6 +32,10 @@ pub(crate) struct AclRepairPolicy { pub(crate) denial_cooldown: Duration, pub(crate) failure_cooldown: Duration, pub(crate) wait_timeout: Duration, + /// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。 + /// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它, + /// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。 + pub(crate) leader_deadline: Duration, } impl AclRepairPolicy { @@ -53,11 +58,16 @@ struct Entry { running: bool, outcome: Option, recorded_at: Option, + /// leader 起跑时刻,用于判定该 leader 是否已经卡死。 + started_at: Instant, + /// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。 + leader_id: u64, } pub(crate) struct AclRepairGate { entries: Mutex>, settled: Condvar, + next_leader_id: AtomicU64, } impl AclRepairGate { @@ -65,6 +75,7 @@ impl AclRepairGate { Self { entries: Mutex::new(HashMap::new()), settled: Condvar::new(), + next_leader_id: AtomicU64::new(1), } } @@ -85,6 +96,11 @@ impl AclRepairGate { loop { match entries.get(&key) { Some(entry) if entry.running => { + // 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key: + // 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。 + if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline { + break; + } let remaining = wait_deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return AclRepairGateResult::WaitTimedOut; @@ -112,12 +128,16 @@ impl AclRepairGate { } prune(&mut entries, now, policy); + let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed); entries.insert( key.clone(), Entry { running: true, outcome: None, - recorded_at: Some(now), + // 结果尚未落库:冷却基准只在真正记录结果时才写。 + recorded_at: None, + started_at: now, + leader_id, }, ); drop(entries); @@ -125,6 +145,7 @@ impl AclRepairGate { let guard = LeaderGuard { gate: self, key: key.clone(), + leader_id, armed: true, }; let outcome = execute(); @@ -161,6 +182,7 @@ where struct LeaderGuard<'a, K: Clone + Eq + Hash> { gate: &'a AclRepairGate, key: K, + leader_id: u64, armed: bool, } @@ -168,17 +190,18 @@ impl LeaderGuard<'_, K> { fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult { self.armed = false; let mut entries = lock(&self.gate.entries); - entries.insert( - self.key.clone(), - Entry { - running: false, - outcome: Some(outcome.clone()), + // 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃, + // 否则会把接管者已经写下的结果覆盖回去。 + if let Some(entry) = entries.get_mut(&self.key) { + if entry.leader_id == self.leader_id { + entry.running = false; + entry.outcome = Some(outcome.clone()); // 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着 // 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期, // 紧接着的自动重查会立刻再弹一次。 - recorded_at: Some(Instant::now()), - }, - ); + entry.recorded_at = Some(Instant::now()); + } + } drop(entries); self.gate.settled.notify_all(); AclRepairGateResult::Executed(outcome) @@ -192,16 +215,15 @@ impl Drop for LeaderGuard<'_, K> { return; } let mut entries = lock(&self.gate.entries); - entries.insert( - self.key.clone(), - Entry { - running: false, - outcome: Some(AclRepairOutcome::Failed( + if let Some(entry) = entries.get_mut(&self.key) { + if entry.leader_id == self.leader_id { + entry.running = false; + entry.outcome = Some(AclRepairOutcome::Failed( "AGC ACL 提权修复执行线程异常退出".to_string(), - )), - recorded_at: Some(Instant::now()), - }, - ); + )); + entry.recorded_at = Some(Instant::now()); + } + } drop(entries); self.gate.settled.notify_all(); } @@ -237,6 +259,8 @@ pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy { denial_cooldown: Duration::from_secs(120), failure_cooldown: Duration::from_secs(15), wait_timeout: Duration::from_secs(60), + // 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。 + leader_deadline: Duration::from_secs(300), }; /// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs index 8249b55d3..2cdb2bbe4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs @@ -12,6 +12,7 @@ fn test_policy() -> AclRepairPolicy { denial_cooldown: Duration::from_secs(300), failure_cooldown: Duration::from_secs(15), wait_timeout: Duration::from_secs(5), + leader_deadline: Duration::from_secs(300), } } @@ -245,6 +246,50 @@ fn repair_gate_key_merges_path_spelling_variants_of_one_target() { ); } +#[test] +fn stale_leader_is_taken_over_and_its_late_result_is_discarded() { + // 真机场景:`Start-Process -Wait` 挂死时,follower 等到 60s 只会失败关闭, + // 而这个 key 会被永久占住(clear_denials 也不清理 running)——只能重启客户端。 + // 超过 leader_deadline 必须允许接管,且旧 leader 迟到的结果不得覆盖接管者。 + let gate = Arc::new(AclRepairGate::new()); + let key = test_key("c:\\stale-leader"); + let policy = AclRepairPolicy { + leader_deadline: Duration::from_millis(150), + ..test_policy() + }; + let started_at = Instant::now(); + let slow = { + let gate = Arc::clone(&gate); + let key = key.clone(); + std::thread::spawn(move || { + gate.run(key, started_at, &policy, || { + std::thread::sleep(Duration::from_millis(400)); + AclRepairOutcome::Failed("卡死的 leader 迟到落库".to_string()) + }) + }) + }; + + std::thread::sleep(Duration::from_millis(250)); + let taken_over = gate.run(key.clone(), Instant::now(), &policy, || { + AclRepairOutcome::Repaired + }); + assert_eq!( + taken_over, + AclRepairGateResult::Executed(AclRepairOutcome::Repaired) + ); + + assert!(matches!( + slow.join().expect("leader 线程不得 panic"), + AclRepairGateResult::Executed(AclRepairOutcome::Failed(_)) + )); + assert_eq!( + gate.run(key, Instant::now(), &policy, || { + panic!("冷却内必须复用接管者的结果,不得再执行") + }), + AclRepairGateResult::Reused(AclRepairOutcome::Repaired) + ); +} + #[test] fn clearing_denials_allows_an_explicit_user_retry() { let gate = AclRepairGate::new(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ec13ca835..b55a58e55 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6,6 +6,7 @@ - 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。 - 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。 - 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。 +- 决策补充(leader 失效接管):`leader_deadline`(默认 5 分钟)之后,新调用可以接管仍是 `running` 的 key;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者的结果。真机上无人应答的 UAC 约 2 分钟自然超时,所以这个上限只兜「提权子进程真挂死」——否则该目标会永久按失败关闭(`clear_denials` 不清理 running,只能重启客户端)。 - 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。 - 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。 - 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c881902b7..6ab268c16 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6,8 +6,8 @@ - **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。 - **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。 - **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。 -- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 -- **已知残余边界**:闸门只有 follower 的有界等待(60s),没有 leader 失效接管——若提权子进程真的挂死(`Start-Process -Wait` 无超时),该 key 会一直 `running`,之后所有同目标调用都按 60s 超时失败,`clear_game_creator_acl_elevation_denials` 也不清理 running,只能重启客户端恢复。需要更激进策略时再单独讨论(记 `started_at` + 硬上限接管)。 +- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。 +- **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 ## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格