Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4296a4bdd | |||
| cec971c438 | |||
| 7eee89ded3 | |||
| f9bc2d917c | |||
| 70157673b6 | |||
| b96cd4b7c2 | |||
| f185d21f55 | |||
| a61b0cce81 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 0594a90bdd | |||
| aee862532c | |||
| 42be8ea060 | |||
| b48293fb1f | |||
| e08171fd2e | |||
| 479120d368 | |||
| 4182979a19 | |||
| 2a75a2d2c8 | |||
| 512f669f57 | |||
| 737a2266b9 | |||
| 21dbbb4c3c | |||
| 262deaf9b7 | |||
| c36a5170f8 | |||
| 9663bbf911 | |||
| b3e9d0a906 | |||
| a60328623d | |||
| 9e63b76991 | |||
| a98ebcf68f | |||
| 576ff07a5e | |||
| 5aa616134c | |||
| 187b66c3b1 | |||
| 55d0c99e2b | |||
| 3907a8bf74 | |||
| 46dfa2cffb | |||
| 97e2b26900 | |||
| 39e33f7a5b | |||
| 040f646480 | |||
| 8b007c637d | |||
| f0a62c6306 | |||
| 30edfee00e | |||
| c2da8e184e | |||
| a19319d815 | |||
| d07ea186fa | |||
| 80caec373a | |||
| 3caefd6716 | |||
| 4d0dd2d721 | |||
| 2befaaef9d | |||
| 20b0bf4dd7 | |||
| b4fd8d9b8b | |||
| fe85fa2a62 | |||
| b14a533b17 | |||
| 42969b2218 | |||
| c4ee75a1af | |||
| 6f2137cbb0 | |||
| 70a2246e8b | |||
| b7e3dac661 | |||
| 2e4a1996c8 | |||
| fe4e952853 | |||
| 18654b6806 | |||
| ee7d00c0b1 | |||
| 9cd1a94369 | |||
| 470e85c0ff |
@@ -164,6 +164,7 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
'apps/ai-game-creator-shell/src/contracts/generated/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run format:staged
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run check:pre-push-master -- "$@"
|
||||
|
||||
@@ -4,6 +4,8 @@ node_modules
|
||||
.codex-logs
|
||||
public/Icons
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
# ts-rs 直接落盘的生成产物:保持生成器原始格式,`cargo test export_bindings` 才可幂等复现。
|
||||
apps/ai-game-creator-shell/src/contracts/generated/
|
||||
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
|
||||
media
|
||||
*.log
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+3
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.47"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
@@ -4900,6 +4900,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.47"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -14,7 +14,7 @@ cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-i
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
@@ -50,7 +50,7 @@ portable-pty = "0.9"
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
|
||||
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
match self {
|
||||
Self::PlatformSession { .. } => None,
|
||||
#[cfg(test)]
|
||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||
#[cfg(test)]
|
||||
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
|
||||
.as_deref()
|
||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||
#[cfg(not(test))]
|
||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||
Self::AuthBridge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +195,20 @@ impl CodexAppServerStderrSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// 派发 app-server 的收尾与中断任务。
|
||||
///
|
||||
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
|
||||
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
|
||||
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
|
||||
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor
|
||||
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
|
||||
/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER
|
||||
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast
|
||||
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。
|
||||
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
|
||||
tauri::async_runtime::spawn(task);
|
||||
}
|
||||
|
||||
struct CodexTurnStartCancellation {
|
||||
inner: Weak<CodexAppServerInner>,
|
||||
thread_id: String,
|
||||
@@ -257,7 +270,7 @@ impl CodexTurnStartCancellation {
|
||||
};
|
||||
let connection = CodexAppServerConnection { inner };
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let _ = connection
|
||||
.request(
|
||||
"turn/interrupt",
|
||||
@@ -2018,7 +2031,13 @@ impl CodexAppServerConnection {
|
||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
let mut effective_llm = llm.clone();
|
||||
let credential = if game_creator_official_llm_route_locked() {
|
||||
let credential = if llm.custom_enabled {
|
||||
crate::config::validate_custom_llm_connection(llm)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
||||
}
|
||||
} else if game_creator_official_llm_route_locked() {
|
||||
let session = current_platform_session().ok_or_else(|| {
|
||||
platform_llm::LlmError::InvalidConfig(
|
||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||
@@ -2186,7 +2205,8 @@ impl CodexAppServerConnection {
|
||||
true,
|
||||
),
|
||||
_ => (
|
||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
(llm.custom_enabled
|
||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||
@@ -3560,7 +3580,7 @@ impl Drop for CodexThreadLease {
|
||||
let connection = self.connection.clone();
|
||||
let key = self.key.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let mut threads = connection.inner.threads.lock().await;
|
||||
if let Some(entry) = threads.get_mut(&key) {
|
||||
if entry.thread_id == thread_id {
|
||||
@@ -3587,7 +3607,7 @@ impl Drop for CodexTurnGuard {
|
||||
let connection = self.connection.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
let turn_id = self.turn_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
connection.inner.turns.lock().await.remove(&turn_id);
|
||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||
let _ = connection
|
||||
@@ -4483,6 +4503,19 @@ mod tests {
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
|
||||
#[test]
|
||||
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
|
||||
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
|
||||
assert!(
|
||||
joined.join().is_ok(),
|
||||
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
|
||||
);
|
||||
}
|
||||
|
||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||
#[test]
|
||||
@@ -4743,6 +4776,8 @@ mod tests {
|
||||
|
||||
fn test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
@@ -5528,6 +5563,59 @@ mod tests {
|
||||
assert_ne!(command_token, provider_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
|
||||
let mut llm = test_llm();
|
||||
llm.custom_enabled = true;
|
||||
llm.api_key = "custom-upstream-fixture-secret".into();
|
||||
llm.base_url = "http://127.0.0.1:9/v1".into();
|
||||
llm.model = "vendor/model.v1:latest".into();
|
||||
llm.visible_models = vec![llm.model.clone()];
|
||||
let credential = CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: "custom-fixture".into(),
|
||||
};
|
||||
let (base, key) = credential
|
||||
.direct_provider_route(&llm)
|
||||
.expect("custom route");
|
||||
assert_eq!(base, llm.base_url);
|
||||
assert_eq!(key, llm.api_key);
|
||||
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
|
||||
for mode in [
|
||||
CodexAppServerWorkspaceMode::DirectProject,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
] {
|
||||
let mut command = tokio::process::Command::new("fixture");
|
||||
configure_game_creator_codex_app_server_command_for_mode(
|
||||
&mut command,
|
||||
&llm,
|
||||
mode,
|
||||
Some(&proxy),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let arguments = command
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let params = codex_app_server_thread_start_params(
|
||||
&llm.model,
|
||||
std::path::Path::new("fixture-workspace"),
|
||||
mode,
|
||||
String::new(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(params["model"], "vendor/model.v1:latest");
|
||||
assert!(!arguments.contains(&llm.api_key));
|
||||
assert!(!arguments.contains("/api/llm"));
|
||||
for (_, value) in command.as_std().get_envs() {
|
||||
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||
|
||||
@@ -527,10 +527,6 @@ fn process_design_batch(
|
||||
let result = if uncertain {
|
||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||
} else {
|
||||
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"design.tool",
|
||||
)?;
|
||||
execute_design_tool(root, resources, session, &call)
|
||||
};
|
||||
let error = result
|
||||
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
|
||||
finish_design_command(root, resources, session, active, run, emit).await
|
||||
}
|
||||
|
||||
async fn recover_uncertain_design_batch(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
session: DesignSession,
|
||||
active: File,
|
||||
) -> Result<DesignView, String> {
|
||||
finish_design_command(root, resources, session, active, true, |_| {}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn decide_design_phase_at(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn hydrate_design_agent_session(
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
|
||||
if session.project_id != project_id {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
}
|
||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
||||
Ok(Some(design_view(&session, active.is_none())))
|
||||
let Some(active) =
|
||||
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||||
else {
|
||||
return Ok(Some(design_view(&session, true)));
|
||||
};
|
||||
if design_session_has_uncertain_batch(&session) {
|
||||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||||
return Ok(Some(view));
|
||||
}
|
||||
drop(active);
|
||||
Ok(Some(design_view(&session, false)))
|
||||
}
|
||||
|
||||
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
|
||||
let Some(batch) = session.pending_batch.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if !batch.executing || batch.cursor >= batch.calls.len() {
|
||||
return false;
|
||||
}
|
||||
let call_id = batch.calls[batch.cursor].id.as_str();
|
||||
session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||
&& !session.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||
@@ -1958,4 +1989,94 @@ mod tests {
|
||||
.any(|message| message.text.contains("重试后继续")));
|
||||
assert!(next.session.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
execute_design_file_tool(
|
||||
&root,
|
||||
"write_file",
|
||||
&json!({"path":"project/00_concept/design.md","content":"概念"}),
|
||||
)
|
||||
.expect("write concept");
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
let call = platform_llm::LlmToolCall {
|
||||
id: "interrupted-call".into(),
|
||||
name: "patch_file".into(),
|
||||
arguments: json!({
|
||||
"path":"project/00_concept/design.md",
|
||||
"old_text":"概念",
|
||||
"new_text":"概念设计"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
session.history.push(json!({
|
||||
"type":"function_call",
|
||||
"call_id":call.id,
|
||||
"name":call.name,
|
||||
"arguments":call.arguments,
|
||||
}));
|
||||
session.messages = vec![DesignMessage {
|
||||
id: "turn:user".into(),
|
||||
role: "user".into(),
|
||||
text: "继续".into(),
|
||||
}];
|
||||
session.turn = Some(DesignTurn {
|
||||
id: "turn-recovery".into(),
|
||||
pending: true,
|
||||
request_index: 0,
|
||||
attempt: 0,
|
||||
});
|
||||
session.pending_batch = Some(DesignToolBatch {
|
||||
calls: vec![call],
|
||||
cursor: 0,
|
||||
executing: true,
|
||||
});
|
||||
assert!(design_session_has_uncertain_batch(&session));
|
||||
write_design_session(&root, &session).expect("write interrupted session");
|
||||
|
||||
let _fake = fake_provider::install(
|
||||
vec![Ok(fake_response(
|
||||
"recovered-after-uncertain-tool",
|
||||
"已读取文件并确认。",
|
||||
Vec::new(),
|
||||
))],
|
||||
0,
|
||||
);
|
||||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||||
try_open_game_creator_agent_runtime_task_lock_file(
|
||||
&root,
|
||||
".agent/design-agent/active.lock",
|
||||
)
|
||||
.expect("open active lock")
|
||||
.expect("active lock is free")
|
||||
})
|
||||
.await
|
||||
.expect("recover uncertain batch");
|
||||
|
||||
assert!(!view.running);
|
||||
assert!(view.session.last_error.is_none());
|
||||
let restored = read_design_session(&root)
|
||||
.expect("read restored")
|
||||
.expect("session");
|
||||
assert!(restored.pending_batch.is_none());
|
||||
assert!(!restored.turn.expect("turn").pending);
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
|
||||
&& item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|output| output.contains("执行结果未保存"))
|
||||
}));
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& item.get("content").is_some()
|
||||
}));
|
||||
assert!(
|
||||
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
|
||||
.expect("read target")
|
||||
== "概念"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = Vec::new();
|
||||
let mut edit_errors = Vec::new();
|
||||
let mut valid_edits = 0;
|
||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
||||
if old == new {
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let count = content.matches(old).count();
|
||||
if count == 0 {
|
||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文未找到:{}{}",
|
||||
display,
|
||||
design_patch_location_hint(&content, old)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if count != 1 {
|
||||
return Err(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||
let start = content.find(old).expect("count checked");
|
||||
let line = design_patch_line_number(&content, start);
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let start = content.find(old).expect("count checked");
|
||||
let end = start + old.len();
|
||||
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
|
||||
.iter()
|
||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||
{
|
||||
return Err(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
matches.push((index, start, end));
|
||||
valid_edits += 1;
|
||||
let _ = new;
|
||||
}
|
||||
if !edit_errors.is_empty() {
|
||||
let shown = edit_errors.len().min(4);
|
||||
let mut details = edit_errors[..shown].to_vec();
|
||||
if shown < edit_errors.len() {
|
||||
details.push(format!(
|
||||
"另有 {} 个 edit 校验失败(详情省略)",
|
||||
edit_errors.len() - shown
|
||||
));
|
||||
}
|
||||
if valid_edits > 0 {
|
||||
details.push(format!(
|
||||
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
|
||||
));
|
||||
} else {
|
||||
details.push("本次未写入文件".to_string());
|
||||
}
|
||||
return Err(details.join("\n"));
|
||||
}
|
||||
let mut updated = content.clone();
|
||||
for (index, start, end) in matches.into_iter().rev() {
|
||||
let (_, new) = &normalized[index];
|
||||
@@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
}
|
||||
|
||||
fn design_patch_line_number(content: &str, start: usize) -> usize {
|
||||
1 + content[..start]
|
||||
.bytes()
|
||||
.filter(|byte| *byte == b'\n')
|
||||
.count()
|
||||
}
|
||||
|
||||
fn design_patch_visible_line(line: &str) -> String {
|
||||
line.replace('\t', "\\t").chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn design_patch_location_hint(content: &str, old: &str) -> String {
|
||||
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut candidates = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| line.trim() == anchor)
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
|
||||
if let Some(token) = token {
|
||||
candidates = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| line.trim().contains(token))
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
return format!(
|
||||
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
|
||||
content.lines().count()
|
||||
);
|
||||
}
|
||||
|
||||
let details = candidates
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let suffix = if candidates.len() > 2 {
|
||||
format!("等 {} 处", candidates.len())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)")
|
||||
}
|
||||
|
||||
pub(crate) fn list_design_workspace_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||
@@ -693,6 +783,22 @@ mod tests {
|
||||
)
|
||||
.expect_err("escape");
|
||||
assert!(escaped.contains("路径"));
|
||||
let mismatch = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
&json!({
|
||||
"path":"notes/design.md",
|
||||
"edits":[
|
||||
{"old_text":" 游戏设计","new_text":"游戏概念"},
|
||||
{"old_text":"设计","new_text":"方案"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect_err("report all patch failures");
|
||||
assert!(mismatch.contains("edits[0] 原文未找到"));
|
||||
assert!(mismatch.contains("第 1 行:游戏设计"));
|
||||
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
|
||||
assert!(mismatch.contains("本次未写入文件"));
|
||||
let patched = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||
use crate::ui_editor::persistence::{
|
||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -111,29 +115,165 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<String, String> {
|
||||
let wire = direct_codex_user_item_to_wire_input(root, item)?;
|
||||
wire.as_array()
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut prompt = wire
|
||||
.as_array()
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||
.map(|parts| {
|
||||
.and_then(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<String>()
|
||||
})
|
||||
.and_then(|prompt| {
|
||||
if prompt.trim().is_empty() {
|
||||
Err("DirectProject user item 不能转换为空 prompt".to_string())
|
||||
} else {
|
||||
Ok(prompt)
|
||||
.map(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
||||
})
|
||||
.collect::<Result<String, String>>()
|
||||
})?;
|
||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||
prompt.push('\n');
|
||||
prompt.push_str(&code_context);
|
||||
}
|
||||
if prompt.trim().is_empty() {
|
||||
return Err("DirectProject user item 不能转换为空 prompt".to_string());
|
||||
}
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的
|
||||
/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。
|
||||
///
|
||||
/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item`
|
||||
/// 的纯投影,不得在这里产生项目写副作用。
|
||||
fn render_ui_design_code_context(
|
||||
root: &Path,
|
||||
message: &DirectCodexUserMessageItem,
|
||||
) -> Result<Option<String>, String> {
|
||||
let referenced_ids = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
Some(resource_id.trim())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if referenced_ids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut lines = Vec::new();
|
||||
for resource_id in referenced_ids {
|
||||
let is_ui_design_doc = manifest.assets.iter().any(|asset| {
|
||||
asset.id == resource_id
|
||||
&& asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
});
|
||||
if !is_ui_design_doc {
|
||||
continue;
|
||||
}
|
||||
lines.push(
|
||||
match generate_ui_design_code_at(GenerateUiDesignCodeInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
asset_id: resource_id.to_string(),
|
||||
}) {
|
||||
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
|
||||
Err(error) => format!("生成代码遇到错误{error}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(lines.join("\n")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::direct_codex_user_item_to_response_item;
|
||||
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
use serde_json::json;
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project";
|
||||
|
||||
fn prompt_context_project() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(
|
||||
directory.path(),
|
||||
PROMPT_CONTEXT_PROJECT_ID,
|
||||
"Direct prompt 上下文测试",
|
||||
)
|
||||
.expect("init project");
|
||||
directory
|
||||
}
|
||||
|
||||
fn register_fixture_asset(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
media_type: &str,
|
||||
) -> String {
|
||||
let absolute_path = crate::resolve_local_project_path(root, relative_path)
|
||||
.expect("resolve fixture asset path");
|
||||
std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent"))
|
||||
.expect("create fixture asset parent");
|
||||
std::fs::write(&absolute_path, b"{}").expect("write fixture asset file");
|
||||
crate::assets::register_local_asset_at(
|
||||
root,
|
||||
relative_path,
|
||||
kind,
|
||||
media_type,
|
||||
"prompt-context-test",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: Some("prompt-context".to_string()),
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("register fixture asset")
|
||||
.id
|
||||
}
|
||||
|
||||
fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) {
|
||||
let directory = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
directory.path(),
|
||||
"ui/design.json",
|
||||
GameCreationAppAssetKind::UiDesignDoc,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
);
|
||||
if initialize_state {
|
||||
crate::ui_editor::persistence::initialize_ui_design_state_at(
|
||||
directory.path(),
|
||||
PROMPT_CONTEXT_PROJECT_ID,
|
||||
&asset_id,
|
||||
)
|
||||
.expect("initialize UI design state");
|
||||
}
|
||||
(directory, asset_id)
|
||||
}
|
||||
|
||||
fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{"type": "agc_resource_reference", "resourceId": asset_id}],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
@@ -174,4 +314,81 @@ mod tests {
|
||||
.expect_err("history item without type must fail");
|
||||
assert!(error.contains("缺少 type"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(
|
||||
prompt.contains(&format!(
|
||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||
)),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||
"{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_projection_never_writes_generated_ui_design_code() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item = user_item_with_resource_reference(&asset_id);
|
||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||
let generated_root =
|
||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||
let generated_files = std::fs::read_dir(generated_root)
|
||||
.expect("read ui directory")
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("generated-")
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
generated_files, 0,
|
||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||
let project = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
project.path(),
|
||||
"assets/hero.png",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(
|
||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1203,7 +1203,7 @@ fn bridge_registered_resource(
|
||||
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
|
||||
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
|
||||
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
|
||||
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
|
||||
"category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category),
|
||||
"tags": asset.tags,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
@@ -1244,7 +1244,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
arguments,
|
||||
"kind",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
|
||||
)?;
|
||||
)?
|
||||
.map(|kind| GameCreationAppAssetKind::parse_with_context(&kind, "asset.list.kind"));
|
||||
let asset_id = bridge_optional_bounded_string(
|
||||
arguments,
|
||||
"assetId",
|
||||
@@ -1264,7 +1265,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
let mut assets = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind))
|
||||
.filter(|asset| kind.is_none_or(|kind| asset.kind == kind))
|
||||
.filter(|asset| {
|
||||
asset_id
|
||||
.as_ref()
|
||||
@@ -1862,7 +1863,7 @@ async fn bridge_create_or_derive_resource(
|
||||
source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()),
|
||||
source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()),
|
||||
source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()),
|
||||
producer_task_id: source_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.source.task_id.clone()),
|
||||
@@ -3323,7 +3324,7 @@ mod tests {
|
||||
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "local-art-1".to_string(),
|
||||
kind: "art-spritesheet-slice".to_string(),
|
||||
kind: GameCreationAppAssetKind::Icon,
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3380,7 +3381,7 @@ mod tests {
|
||||
fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "animation-1".to_string(),
|
||||
kind: "character-animation".to_string(),
|
||||
kind: GameCreationAppAssetKind::CharacterAnimation,
|
||||
media_type: "video/mp4".to_string(),
|
||||
local_path: "assets/edits/animation.mp4".to_string(),
|
||||
image_sequence_frames: Some(vec![
|
||||
@@ -3431,7 +3432,7 @@ mod tests {
|
||||
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "spec-1".to_string(),
|
||||
kind: "spec".to_string(),
|
||||
kind: GameCreationAppAssetKind::Spec,
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/specs/hero.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3467,13 +3468,13 @@ mod tests {
|
||||
///
|
||||
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
|
||||
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
|
||||
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
|
||||
/// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`,两侧分类口径必须一致。
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "ui-1".to_string(),
|
||||
kind: "ui".to_string(),
|
||||
id: "ui-design-1".to_string(),
|
||||
kind: GameCreationAppAssetKind::UiDesign,
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/UI 设计 1.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3496,7 +3497,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "ui");
|
||||
assert_eq!(projection["kind"], "ui-design");
|
||||
assert_eq!(projection["category"], "ui-interaction");
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
||||
root: PathBuf,
|
||||
token: String,
|
||||
session_user_id: String,
|
||||
session_generation: u64,
|
||||
session_identity_generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||
@@ -1241,7 +1241,8 @@ fn external_mcp_session_id(root: &Path) -> String {
|
||||
material.push('\0');
|
||||
material.push_str(&session.user_id);
|
||||
material.push('\0');
|
||||
material.push_str(&session.generation.to_string());
|
||||
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
||||
material.push_str(&session.identity_generation.to_string());
|
||||
}
|
||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
@@ -1759,7 +1760,9 @@ async fn handle_external_mcp_http_request(
|
||||
let Some(session) = current_platform_session() else {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
||||
if session.user_id != state.session_user_id
|
||||
|| session.identity_generation != state.session_identity_generation
|
||||
{
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||
@@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
root,
|
||||
token: token.clone(),
|
||||
session_user_id: session.user_id,
|
||||
session_generation: session.generation,
|
||||
session_identity_generation: session.identity_generation,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(&route, post(handle_external_mcp_http_request))
|
||||
|
||||
@@ -11,6 +11,10 @@ use super::external_generation_state::{
|
||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
use crate::platform_session::{
|
||||
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
||||
PlatformSessionIdentity,
|
||||
};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||
@@ -469,6 +473,27 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option
|
||||
})
|
||||
}
|
||||
|
||||
/// 平台美术生成请求的 `assetKind` → manifest 资源 kind。
|
||||
///
|
||||
/// `PlatformArtAssetGenerationOptions::asset_kind` 是**平台请求词汇**(`ui-prototype` /
|
||||
/// `art-spritesheet` / `game-background` / `game-art` …),跟 manifest 的正式
|
||||
/// `GameCreationAppAssetKind` 不是同一套词表。生成结果登记进 manifest 之前必须在这里显式
|
||||
/// 转换,否则 manifest 会落进非 canonical 值,读侧只能靠 shared-contracts 的别名表兜底。
|
||||
///
|
||||
/// 词表外的值不猜:返回 `Unknown`,由调用方按既有未知输入口径处理。
|
||||
pub(crate) fn platform_art_asset_manifest_kind(asset_kind: &str) -> GameCreationAppAssetKind {
|
||||
match asset_kind.trim() {
|
||||
"image" | "game-art" => GameCreationAppAssetKind::Image,
|
||||
"character" | "character-art" => GameCreationAppAssetKind::Character,
|
||||
"spec" | "icon-spec" => GameCreationAppAssetKind::IconSpec,
|
||||
"ui-prototype" | "ui-design" => GameCreationAppAssetKind::UiDesign,
|
||||
"art-spritesheet" | "icon-spritesheet" => GameCreationAppAssetKind::IconSpritesheet,
|
||||
"publication-material" => GameCreationAppAssetKind::PublicationMaterial,
|
||||
"game-background" | "scene" => GameCreationAppAssetKind::Scene,
|
||||
_ => GameCreationAppAssetKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn recover_persisted_visual_generation_options(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
@@ -1510,10 +1535,7 @@ struct PreparedPlatformArtAssetSlice {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PreparedPlatformSessionFence {
|
||||
user_id: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
access_token_sha256: String,
|
||||
identity: PlatformSessionIdentity,
|
||||
}
|
||||
|
||||
impl PreparedPlatformSessionFence {
|
||||
@@ -1521,41 +1543,17 @@ impl PreparedPlatformSessionFence {
|
||||
access
|
||||
.frozen_platform_session()
|
||||
.map(|session| PreparedPlatformSessionFence {
|
||||
user_id: session.user_id.clone(),
|
||||
api_base_url: session.api_base_url.clone(),
|
||||
generation: session.generation,
|
||||
access_token_sha256: format!(
|
||||
"{:x}",
|
||||
Sha256::digest(session.access_token.as_bytes())
|
||||
),
|
||||
identity: session.identity(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
let matches = current_platform_session().is_some_and(|session| {
|
||||
session.user_id == self.user_id
|
||||
&& session.api_base_url == self.api_base_url
|
||||
&& session.generation == self.generation
|
||||
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
|
||||
== self.access_token_sha256
|
||||
});
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
||||
validate_platform_session_identity(&self.identity)
|
||||
}
|
||||
|
||||
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&self.user_id,
|
||||
&self.api_base_url,
|
||||
self.generation,
|
||||
&self.access_token_sha256,
|
||||
)
|
||||
acquire_platform_session_identity_lease(&self.identity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1637,7 +1635,7 @@ async fn canonical_art_spec_reference_at(
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH
|
||||
&& asset.kind == "icon-spec"
|
||||
&& asset.kind == GameCreationAppAssetKind::IconSpec
|
||||
&& asset.media_type.starts_with("image/")
|
||||
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
|
||||
})
|
||||
@@ -1661,7 +1659,7 @@ async fn canonical_art_spec_reference_at(
|
||||
&source.id,
|
||||
&format!("{:x}", Sha256::digest(&bytes)),
|
||||
&source.media_type,
|
||||
&source.kind,
|
||||
source.kind.as_str(),
|
||||
)?;
|
||||
if let Some(binding) = read_external_editor_resource_binding_at(
|
||||
root,
|
||||
@@ -6870,7 +6868,7 @@ fn register_platform_art_slice_manifest_entries_at(
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == registration.local_path)
|
||||
{
|
||||
existing.kind = "art-spritesheet-slice".to_string();
|
||||
existing.kind = GameCreationAppAssetKind::Icon;
|
||||
existing.media_type = registration.media_type.clone();
|
||||
existing.image_sequence_frames = None;
|
||||
existing.image_sequence_duration_ms = None;
|
||||
@@ -6884,12 +6882,14 @@ fn register_platform_art_slice_manifest_entries_at(
|
||||
);
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: id.clone(),
|
||||
kind: "art-spritesheet-slice".to_string(),
|
||||
kind: GameCreationAppAssetKind::Icon,
|
||||
media_type: registration.media_type.clone(),
|
||||
local_path: registration.local_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
category: game_creation_app_asset_category_for_kind(
|
||||
GameCreationAppAssetKind::Icon.as_str(),
|
||||
),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
@@ -6921,7 +6921,7 @@ fn existing_platform_art_slice_registrations_are_complete(
|
||||
return Ok(false);
|
||||
};
|
||||
if candidates.next().is_some()
|
||||
|| asset.kind != "art-spritesheet-slice"
|
||||
|| asset.kind != GameCreationAppAssetKind::Icon
|
||||
|| asset.media_type != "image/png"
|
||||
|| asset.source.kind != GameCreationAppAssetSourceKind::Canvas
|
||||
|| asset.source.generation_route.as_deref()
|
||||
@@ -7010,10 +7010,12 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at(
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?;
|
||||
let has_partial_or_invalid_registration = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.kind == "art-spritesheet-slice");
|
||||
let has_partial_or_invalid_registration = manifest.assets.iter().any(|asset| {
|
||||
asset.kind == GameCreationAppAssetKind::Icon
|
||||
&& asset
|
||||
.local_path
|
||||
.starts_with("assets/art-spritesheet-slices/")
|
||||
});
|
||||
|
||||
let receipt_path =
|
||||
resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?;
|
||||
@@ -7623,7 +7625,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
let registered = match register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
&options.asset_kind,
|
||||
platform_art_asset_manifest_kind(&options.asset_kind),
|
||||
&download.media_type,
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -10636,7 +10638,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
drop(owner_a_access);
|
||||
drop(frozen_owner_a);
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
||||
.expect("switch to owner B");
|
||||
|
||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||
@@ -10761,8 +10763,14 @@ mod canvas_generation_tests {
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("wait for accepted response");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
||||
.expect("switch platform account after accepted response");
|
||||
install_platform_session(
|
||||
"post-202-user-b",
|
||||
"post-202-token-b",
|
||||
&switch_base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch platform account after accepted response");
|
||||
});
|
||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "art-director".to_string(),
|
||||
@@ -11226,7 +11234,7 @@ mod canvas_generation_tests {
|
||||
register_local_asset_at(
|
||||
root,
|
||||
"assets/art-spec.png",
|
||||
"icon-spec",
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
"image/png",
|
||||
"changed-reference-test",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -12796,7 +12804,7 @@ mod canvas_generation_tests {
|
||||
let first_slice_ids = registered
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == "art-spritesheet-slice")
|
||||
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
|
||||
.map(|asset| {
|
||||
assert_eq!(asset.source.kind, GameCreationAppAssetSourceKind::Canvas);
|
||||
assert_eq!(
|
||||
@@ -12830,7 +12838,7 @@ mod canvas_generation_tests {
|
||||
.clone();
|
||||
mutate_manifest_at(root, |manifest| {
|
||||
manifest.assets.retain(|asset| {
|
||||
asset.kind != "art-spritesheet-slice" || asset.local_path == retained_path
|
||||
asset.kind != GameCreationAppAssetKind::Icon || asset.local_path == retained_path
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
@@ -12844,7 +12852,7 @@ mod canvas_generation_tests {
|
||||
let backfilled_slice_ids = backfilled
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == "art-spritesheet-slice")
|
||||
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
|
||||
.map(|asset| (asset.local_path.clone(), asset.id.clone()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(backfilled_slice_ids.len(), 4);
|
||||
@@ -12893,7 +12901,7 @@ mod canvas_generation_tests {
|
||||
let replaced_slice_ids = replaced
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == "art-spritesheet-slice")
|
||||
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
|
||||
.map(|asset| {
|
||||
assert_eq!(
|
||||
asset.source.reference_resource_ids,
|
||||
@@ -12926,7 +12934,7 @@ mod canvas_generation_tests {
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
AGENT_RUNTIME_ART_SPRITESHEET_PATH,
|
||||
"art-spritesheet",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
"image/png",
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -12987,12 +12995,12 @@ mod canvas_generation_tests {
|
||||
assert!(after
|
||||
.assets
|
||||
.iter()
|
||||
.all(|asset| asset.kind != "art-spritesheet-slice"));
|
||||
.all(|asset| asset.kind != GameCreationAppAssetKind::Icon));
|
||||
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
"assets/art-spritesheet-slices/player.png",
|
||||
"art-spritesheet-slice",
|
||||
GameCreationAppAssetKind::Icon,
|
||||
"image/png",
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -14548,7 +14556,10 @@ mod canvas_generation_tests {
|
||||
let manifest = read_manifest_for_project(root).expect("read replacement manifest");
|
||||
assert_eq!(manifest.assets.len(), 1);
|
||||
assert_eq!(manifest.assets[0].local_path, "assets/art-spritesheet.png");
|
||||
assert_eq!(manifest.assets[0].kind, "art-spritesheet");
|
||||
assert_eq!(
|
||||
manifest.assets[0].kind,
|
||||
GameCreationAppAssetKind::IconSpritesheet
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(root.join("assets"))
|
||||
.expect("read assets directory")
|
||||
|
||||
+2
-1
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
|
||||
base_url,
|
||||
);
|
||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
||||
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"fingerprint-owner-b",
|
||||
"fingerprint-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global session to owner B");
|
||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||
|
||||
@@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
|
||||
output.push_str("- ");
|
||||
output.push_str(&asset.id);
|
||||
output.push_str(": ");
|
||||
output.push_str(&asset.kind);
|
||||
output.push_str(asset.kind.as_str());
|
||||
output.push_str(" / ");
|
||||
output.push_str(&asset.media_type);
|
||||
output.push_str(" / ");
|
||||
|
||||
+12
-6
@@ -2061,17 +2061,23 @@ mod tests {
|
||||
.expect("resume first manifest task fixture");
|
||||
|
||||
for (local_path, kind) in [
|
||||
("assets/art-spec.png", "icon-spec"),
|
||||
("assets/ui-prototype.png", "ui-prototype"),
|
||||
("assets/art-spritesheet.png", "art-spritesheet"),
|
||||
("assets/art-spec.png", GameCreationAppAssetKind::IconSpec),
|
||||
(
|
||||
"assets/ui-prototype.png",
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
),
|
||||
(
|
||||
"assets/art-spritesheet.png",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
),
|
||||
] {
|
||||
let (generation_route, generation_kind, reference_resource_ids) = match kind {
|
||||
"icon-spec" => (
|
||||
GameCreationAppAssetKind::IconSpec => (
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Vec::new(),
|
||||
),
|
||||
"ui-prototype" => (
|
||||
GameCreationAppAssetKind::UiDesign => (
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"ui-design",
|
||||
vec!["resource-icon-spec".to_string()],
|
||||
@@ -2082,7 +2088,7 @@ mod tests {
|
||||
vec!["resource-icon-spec".to_string()],
|
||||
),
|
||||
};
|
||||
let bytes = if kind == "art-spritesheet" {
|
||||
let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
use image::ImageEncoder;
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes)
|
||||
|
||||
@@ -1216,11 +1216,19 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
|
||||
return None;
|
||||
}
|
||||
let (expected_path, expected_kind, label) = match agent_id {
|
||||
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
|
||||
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
|
||||
"art-director" => (
|
||||
AGENT_RUNTIME_ART_SPEC_PATH,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
"统一视觉规范图",
|
||||
),
|
||||
"design-foundation" => (
|
||||
"assets/ui-prototype.png",
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
"策划界面原型图",
|
||||
),
|
||||
"art-asset-plan" => (
|
||||
"assets/art-spritesheet.png",
|
||||
"art-spritesheet",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
"首版美术素材图",
|
||||
),
|
||||
_ => return None,
|
||||
|
||||
+18
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
|
||||
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "finalization-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "finalization-tool-plan-model".to_string(),
|
||||
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-provider-model".to_string(),
|
||||
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-tool-plan-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-tool-plan-model".to_string(),
|
||||
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
||||
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
||||
let root = project.path();
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-generic-retry-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-generic-retry-model".to_string(),
|
||||
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
response_stream_fixture("tool-plan-capacity-preflight-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-capacity-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-capacity-model".to_string(),
|
||||
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "durable-control-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "durable-control-tool-plan-model".to_string(),
|
||||
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-cleanup-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-cleanup-model".to_string(),
|
||||
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "terminal-handoff-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "terminal-handoff-model".to_string(),
|
||||
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "provider-model".to_string(),
|
||||
|
||||
@@ -2,8 +2,9 @@ use super::*;
|
||||
|
||||
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
||||
OnceLock::new();
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
||||
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
|
||||
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
|
||||
> = OnceLock::new();
|
||||
#[cfg(test)]
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
|
||||
@@ -293,8 +294,8 @@ pub(crate) use entrypoints::{
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
||||
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
|
||||
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
|
||||
fn lock_game_creator_manifest_invalidation_event_sink(
|
||||
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS
|
||||
.get_or_init(|| Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
@@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
|
||||
install_game_creator_manifest_invalidation_event_sink(sink);
|
||||
register_game_creator_manifest_invalidation_event_sink(sink);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
|
||||
/// 登记一个界面窗口的事件接收端。
|
||||
///
|
||||
/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有
|
||||
/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。
|
||||
pub(crate) fn register_game_creator_manifest_invalidation_event_sink(
|
||||
sink: GameCreatorManifestInvalidationEventSink,
|
||||
) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
|
||||
let mut sinks = lock_game_creator_manifest_invalidation_event_sinks();
|
||||
if let Some(existing) = sinks
|
||||
.iter_mut()
|
||||
.find(|existing| existing.token == sink.token)
|
||||
{
|
||||
*existing = sink;
|
||||
return;
|
||||
}
|
||||
if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX {
|
||||
sinks.remove(0);
|
||||
}
|
||||
sinks.push(sink);
|
||||
}
|
||||
|
||||
fn remove_game_creator_manifest_invalidation_event_sink(token: &str) {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sink().clone()
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.first()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
fn drop(&mut self) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = None;
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
|
||||
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
|
||||
let Some(sink) = sink else {
|
||||
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone();
|
||||
if sinks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let event = GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
};
|
||||
let mut failed_tokens = Vec::new();
|
||||
let mut last_error = None;
|
||||
for sink in &sinks {
|
||||
match relay_game_creator_manifest_invalidation_to_sink(sink, &event) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
// 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。
|
||||
failed_tokens.push(sink.token.clone());
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failed_tokens.is_empty() {
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.retain(|sink| !failed_tokens.contains(&sink.token));
|
||||
}
|
||||
match last_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation_to_sink(
|
||||
sink: &GameCreatorManifestInvalidationEventSink,
|
||||
event: &GameCreatorManifestInvalidatedEvent,
|
||||
) -> Result<(), String> {
|
||||
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
|
||||
token: sink.token,
|
||||
event: GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
token: sink.token.clone(),
|
||||
event: event.clone(),
|
||||
};
|
||||
let payload = serde_json::to_vec(&envelope)
|
||||
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
|
||||
|
||||
@@ -1561,10 +1561,14 @@ mod orphaned_external_generation_recovery_tests {
|
||||
fs::create_dir_all(workflow_path.parent().expect("workflow parent"))
|
||||
.expect("workflow directory");
|
||||
let rollback_assets = [
|
||||
("assets/art-spec.png", "icon-spec", b"old-spec".as_slice()),
|
||||
(
|
||||
"assets/art-spec.png",
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
b"old-spec".as_slice(),
|
||||
),
|
||||
(
|
||||
"assets/direct-game-background.png",
|
||||
"game-background",
|
||||
GameCreationAppAssetKind::Scene,
|
||||
b"old-background".as_slice(),
|
||||
),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user