Merge remote-tracking branch 'origin/master' into feat/fail-as-event
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / Backend tests (pull_request) Successful in 5m10s
Project CI / Native shell tests (pull_request) Successful in 6m36s
Project CI / Frontend tests (pull_request) Successful in 2m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m2s
Project CI / Repository checks (pull_request) Successful in 1m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / Backend tests (pull_request) Successful in 5m10s
Project CI / Native shell tests (pull_request) Successful in 6m36s
Project CI / Frontend tests (pull_request) Successful in 2m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m2s
Project CI / Repository checks (pull_request) Successful in 1m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! ACL 提权修复目标的并发去重与结果记忆。
|
||||
//!
|
||||
//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果;
|
||||
//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长,
|
||||
//! 避免自动重试把用户反复拽回安全桌面。
|
||||
|
||||
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};
|
||||
|
||||
/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。
|
||||
#[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,
|
||||
/// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。
|
||||
/// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它,
|
||||
/// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。
|
||||
pub(crate) leader_deadline: 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<AclRepairOutcome>,
|
||||
recorded_at: Option<Instant>,
|
||||
/// leader 起跑时刻,用于判定该 leader 是否已经卡死。
|
||||
started_at: Instant,
|
||||
/// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。
|
||||
leader_id: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct AclRepairGate<K> {
|
||||
entries: Mutex<HashMap<K, Entry>>,
|
||||
settled: Condvar,
|
||||
next_leader_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq + Hash> AclRepairGate<K> {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
settled: Condvar::new(),
|
||||
next_leader_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行,
|
||||
/// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。
|
||||
pub(crate) fn run<F>(
|
||||
&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 => {
|
||||
// 卡死的 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;
|
||||
}
|
||||
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);
|
||||
let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
key.clone(),
|
||||
Entry {
|
||||
running: true,
|
||||
outcome: None,
|
||||
// 结果尚未落库:冷却基准只在真正记录结果时才写。
|
||||
recorded_at: None,
|
||||
started_at: now,
|
||||
leader_id,
|
||||
},
|
||||
);
|
||||
drop(entries);
|
||||
|
||||
let guard = LeaderGuard {
|
||||
gate: self,
|
||||
key: key.clone(),
|
||||
leader_id,
|
||||
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<K> Default for AclRepairGate<K>
|
||||
where
|
||||
K: Clone + Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
|
||||
gate: &'a AclRepairGate<K>,
|
||||
key: K,
|
||||
leader_id: u64,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
|
||||
fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
|
||||
self.armed = false;
|
||||
let mut entries = lock(&self.gate.entries);
|
||||
// 只在仍是当前 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 拒绝冷却在用户应答前就过期,
|
||||
// 紧接着的自动重查会立刻再弹一次。
|
||||
entry.recorded_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
drop(entries);
|
||||
self.gate.settled.notify_all();
|
||||
AclRepairGateResult::Executed(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
|
||||
/// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let mut entries = lock(&self.gate.entries);
|
||||
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(),
|
||||
));
|
||||
entry.recorded_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
drop(entries);
|
||||
self.gate.settled.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn prune<K: Eq + Hash>(entries: &mut HashMap<K, Entry>, 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<AclRepairGate<AclRepairKey>> =
|
||||
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),
|
||||
// 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。
|
||||
leader_deadline: Duration::from_secs(300),
|
||||
};
|
||||
|
||||
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
|
||||
pub(crate) fn clear_acl_repair_denials() {
|
||||
ACL_REPAIR_GATE.clear_denials();
|
||||
}
|
||||
@@ -1874,6 +1874,12 @@ pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView,
|
||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||
}
|
||||
|
||||
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除 ACL 提权拒绝记忆。
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_game_creator_acl_elevation_denials() {
|
||||
crate::config::clear_windows_acl_repair_denials();
|
||||
}
|
||||
|
||||
static GAME_CREATOR_CONFIG_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -2779,15 +2779,50 @@ 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();
|
||||
}
|
||||
|
||||
/// 闸门 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.
|
||||
///
|
||||
/// 同一 (规范化目标, 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 提权目标不在当前用户允许的 {} 范围内:{}",
|
||||
@@ -2795,13 +2830,53 @@ 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 = 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)
|
||||
});
|
||||
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(),
|
||||
@@ -2824,19 +2899,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()
|
||||
))
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,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;
|
||||
@@ -1457,7 +1458,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;
|
||||
@@ -2685,6 +2688,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,
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
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),
|
||||
leader_deadline: Duration::from_secs(300),
|
||||
}
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
let results = handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("提权闸门线程不得 panic"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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 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 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();
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -6150,6 +6150,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;
|
||||
|
||||
@@ -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 {
|
||||
// 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { clearAclElevationDenials } from './aclElevation';
|
||||
import {
|
||||
buildRecentProjectRows,
|
||||
readRecentWorkspaces,
|
||||
@@ -27,10 +28,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',
|
||||
@@ -223,7 +226,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
|
||||
|
||||
function rememberRecentWorkspace(projectPath: string) {
|
||||
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
|
||||
clearAclElevationDenials();
|
||||
nonRetryablePathsRef.current.clear();
|
||||
setRecentWorkspaces(writeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
@@ -234,6 +237,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
clearAclElevationDenials();
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
const inspection = await inspectRecentWorkspaceWithRetry(
|
||||
invoke,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -297,7 +297,7 @@ test('only displays aliases and persists selection through the native command',
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
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(() =>
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 在后台维护子集。
|
||||
@@ -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/页面保持不变,本变更不引入不兼容改动。
|
||||
- 待决:是否需要“上游自动跟随同步”(当前不做)。
|
||||
@@ -97,6 +97,17 @@
|
||||
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_delivery.rs,direct_runtime/mod.rs,direct_runtime/user_input.rs,codex_app_server/mod.rs,codex_app_server/execution.rs}` 与 `apps/ai-game-creator-shell/src-tauri/src/cli.rs`;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。
|
||||
- 验证:`cargo test --bins -- direct_`(460 passed)、`cargo test --bins -- codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、定向 `git diff --check`。整包 `cargo test --bins` 在本机被既有 `tests::provider` / `tests::project` 重型用例挂住(并发跑测试时另有一条锁竞争用例会假失败),非本次改动引入。真实客户端观感未复核。
|
||||
|
||||
## 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 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。
|
||||
- 决策补充(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 窗口兜底。
|
||||
|
||||
## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错
|
||||
|
||||
- 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。
|
||||
@@ -9500,3 +9511,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 代价(已知并接受):接单窗口与订阅重建窗口里聊天区没有这一轮的显示,反馈只有 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段还读不出「已耗时」——起点是宿主的 `turn.started.at`,开始事件到了才开始读秒);`project.jsonl` 用户条目依旧只在首屏 / 翻页读进前端。
|
||||
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/{directTurnPresentation.ts,directCodexConversation.ts}`、`.../chat/controller/{useDirectProjectChatController.ts,useDirectProjectTurnStatus.ts}`、`.../chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`src-tauri/src/agent/codex_app_server/mod.rs`(用户条目时间的注释口径)、对应 ADR 与实施计划。
|
||||
- 验证:`npx vitest run tests/directTurnPresentation.test.ts tests/directProjectTurn.test.tsx tests/directProjectTurnStatus.test.ts tests/directThreadChat.test.ts tests/chatComposerAttachmentCap.test.tsx`、`tests/appSurface.test.ts`(213 passed / 9 skipped)、`npx tsc -p tsconfig.json --noEmit`、`eslint`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全绿。真实客户端观感未复核。
|
||||
|
||||
## 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 必须同批发布/回滚。
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 同一祖先下的多个项目会各自弹一次 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`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
|
||||
- **不要踩的坑**:① 闸门 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 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *<sid>:(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。
|
||||
|
||||
> 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
|
||||
|
||||
## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
|
||||
|
||||
@@ -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` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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}/` 目录下载当成工程。
|
||||
|
||||
@@ -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<AgcModelCatalog, AppError> {
|
||||
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<AgcModelCatalog, String> {
|
||||
let catalog: AgcModelCatalog =
|
||||
serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?;
|
||||
catalog.validate()?;
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
async fn read_stored_catalog(state: &AppState) -> Result<Option<String>, 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<u64> {
|
||||
serde_json::from_str::<StoredCatalogRevision>(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<Vec<String>, 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<Vec<u8>, 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<Vec<String>, 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::<Vec<_>>();
|
||||
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<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -44,6 +226,8 @@ pub async fn admin_save_agc_models(
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminAgcModelCatalog>,
|
||||
) -> Result<Json<serde_json::Value>, 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<std::sync::Mutex<Option<String>>>,
|
||||
_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user