Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4296a4bdd | |||
| cec971c438 | |||
| 7eee89ded3 | |||
| f9bc2d917c | |||
| 70157673b6 | |||
| b96cd4b7c2 | |||
| f185d21f55 | |||
| a61b0cce81 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 0594a90bdd | |||
| aee862532c | |||
| 42be8ea060 | |||
| 2a75a2d2c8 | |||
| 512f669f57 | |||
| 737a2266b9 | |||
| 21dbbb4c3c | |||
| 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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.45",
|
||||
"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.45"
|
||||
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.45"
|
||||
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}}},
|
||||
|
||||
@@ -195,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,
|
||||
@@ -256,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",
|
||||
@@ -3566,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 {
|
||||
@@ -3593,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
|
||||
@@ -4489,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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -473,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,
|
||||
@@ -1614,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
|
||||
})
|
||||
@@ -1638,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,
|
||||
@@ -6847,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;
|
||||
@@ -6861,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,
|
||||
});
|
||||
@@ -6898,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()
|
||||
@@ -6987,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")?;
|
||||
@@ -7600,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 {
|
||||
@@ -11209,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 {
|
||||
@@ -12779,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!(
|
||||
@@ -12813,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(())
|
||||
})
|
||||
@@ -12827,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);
|
||||
@@ -12876,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,
|
||||
@@ -12909,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 {
|
||||
@@ -12970,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 {
|
||||
@@ -14531,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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -451,7 +451,7 @@ mod asset_generation_task_tests {
|
||||
init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at,
|
||||
};
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||
};
|
||||
|
||||
fn temp_project_root(label: &str) -> PathBuf {
|
||||
@@ -484,7 +484,7 @@ mod asset_generation_task_tests {
|
||||
register_local_asset_at(
|
||||
root,
|
||||
local_path,
|
||||
"icon-spec",
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
"image/png",
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
|
||||
@@ -478,7 +478,7 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
|
||||
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
|
||||
///
|
||||
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
|
||||
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind {
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
let extension = Path::new(file_name)
|
||||
.extension()
|
||||
@@ -492,20 +492,20 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
|
||||
)
|
||||
{
|
||||
"audio"
|
||||
GameCreationAppAssetKind::Audio
|
||||
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
|
||||
"video"
|
||||
GameCreationAppAssetKind::Video
|
||||
} else if media_type.starts_with("font/")
|
||||
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
|
||||
{
|
||||
"document"
|
||||
GameCreationAppAssetKind::Font
|
||||
} else if media_type.starts_with("image/")
|
||||
|| matches!(
|
||||
extension,
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
|
||||
)
|
||||
{
|
||||
"image"
|
||||
GameCreationAppAssetKind::Image
|
||||
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|
||||
|| media_type.contains("javascript")
|
||||
|| media_type.contains("typescript")
|
||||
@@ -514,7 +514,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
|
||||
)
|
||||
{
|
||||
"code"
|
||||
GameCreationAppAssetKind::Code
|
||||
} else if media_type.starts_with("text/")
|
||||
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|
||||
|| matches!(
|
||||
@@ -532,9 +532,9 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
| "xml"
|
||||
)
|
||||
{
|
||||
"document"
|
||||
GameCreationAppAssetKind::Document
|
||||
} else {
|
||||
"asset"
|
||||
GameCreationAppAssetKind::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@ pub(crate) fn upload_local_asset_at(
|
||||
pub(crate) fn register_local_asset_at(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
media_type: &str,
|
||||
source_kind: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
@@ -647,7 +647,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
let (_, asset_changed) = register_local_asset_entry_with_change(
|
||||
root,
|
||||
&relative,
|
||||
"document",
|
||||
GameCreationAppAssetKind::Document,
|
||||
media_type,
|
||||
"document",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -686,6 +686,7 @@ pub(crate) fn import_canvas_asset_at(
|
||||
if resource_id.is_none() && asset_object_id.is_none() {
|
||||
return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string());
|
||||
}
|
||||
let kind = GameCreationAppAssetKind::parse_with_context(kind.trim(), "canvas.asset_import");
|
||||
|
||||
register_local_asset_at(
|
||||
root,
|
||||
@@ -918,7 +919,12 @@ pub(crate) async fn sync_canvas_project_assets_at(
|
||||
&local_path,
|
||||
json_string_field(resource, "assetKind")
|
||||
.as_deref()
|
||||
.unwrap_or("canvas-resource"),
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
GameCreationAppAssetKind::parse_with_context(value, "canvas.asset_sync")
|
||||
})
|
||||
.unwrap_or(GameCreationAppAssetKind::Image),
|
||||
&download.media_type,
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -1772,33 +1778,28 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
|
||||
|
||||
/// 画板导出图层推断出的资源 kind。
|
||||
///
|
||||
/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入
|
||||
/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的
|
||||
/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于
|
||||
/// 兼容存量数据,不作为新写入值的来源。
|
||||
/// 直接返回正式枚举成员:这个值会被 `register_local_asset_entry` 原样写入 manifest,
|
||||
/// 并据以派生落盘 `category`。分支字面量写成非 canonical 值在这里已经无法表达,
|
||||
/// 不需要别名表兜底。
|
||||
pub(crate) fn infer_canvas_export_asset_kind(
|
||||
layer: &CanvasExportLayerMetadata,
|
||||
file: &str,
|
||||
) -> &'static str {
|
||||
) -> GameCreationAppAssetKind {
|
||||
let layer_type = layer.visible.layer_type.as_str();
|
||||
let inferred = if file.starts_with("sequences/")
|
||||
|| contains_any(layer_type, &["序列", "动画", "动作"])
|
||||
{
|
||||
"character-animation"
|
||||
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
|
||||
GameCreationAppAssetKind::CharacterAnimation
|
||||
} else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"])
|
||||
{
|
||||
"audio"
|
||||
GameCreationAppAssetKind::Audio
|
||||
} else if contains_any(layer_type, &["角色"]) {
|
||||
"character"
|
||||
GameCreationAppAssetKind::Character
|
||||
} else if contains_any(layer_type, &["场景", "背景"]) {
|
||||
"scene"
|
||||
GameCreationAppAssetKind::Scene
|
||||
} else if contains_any(layer_type, &["UI", "界面", "图标"]) {
|
||||
"ui-design"
|
||||
GameCreationAppAssetKind::UiDesign
|
||||
} else {
|
||||
"image"
|
||||
};
|
||||
// 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。
|
||||
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred)
|
||||
GameCreationAppAssetKind::Image
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
|
||||
@@ -1871,7 +1872,7 @@ pub(crate) fn build_canvas_project_url(
|
||||
pub(crate) fn register_local_asset_entry(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
@@ -1883,7 +1884,7 @@ pub(crate) fn register_local_asset_entry(
|
||||
fn register_local_asset_entry_with_change(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
@@ -1891,7 +1892,6 @@ fn register_local_asset_entry_with_change(
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let kind = if kind.is_empty() { "asset" } else { kind };
|
||||
let media_type = if media_type.is_empty() {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
@@ -1914,8 +1914,8 @@ fn register_local_asset_entry_with_change(
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
existing.kind = kind;
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind.as_str());
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
@@ -1928,12 +1928,12 @@ fn register_local_asset_entry_with_change(
|
||||
);
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: id.clone(),
|
||||
kind: kind.to_string(),
|
||||
kind,
|
||||
media_type: media_type.to_string(),
|
||||
local_path: normalized_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(kind),
|
||||
category: game_creation_app_asset_category_for_kind(kind.as_str()),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
@@ -1946,7 +1946,7 @@ fn register_local_asset_entry_with_change(
|
||||
"recordType": record_type,
|
||||
"assetId": id.clone(),
|
||||
"localPath": normalized_path.clone(),
|
||||
"kind": kind,
|
||||
"kind": kind.as_str(),
|
||||
"mediaType": media_type,
|
||||
"source": source_for_record,
|
||||
}),
|
||||
@@ -2199,13 +2199,21 @@ mod tests {
|
||||
}
|
||||
|
||||
// 每个分支的代表输入 → 期望的 canonical kind。
|
||||
let cases: [(&str, &str, &str); 6] = [
|
||||
("序列", "sequences/01.png", "character-animation"),
|
||||
("动画", "layer.png", "character-animation"),
|
||||
("音频", "layer.png", "audio"),
|
||||
("角色", "layer.png", "character"),
|
||||
("场景", "layer.png", "scene"),
|
||||
("UI", "layer.png", "ui-design"),
|
||||
let cases: [(&str, &str, GameCreationAppAssetKind); 6] = [
|
||||
(
|
||||
"序列",
|
||||
"sequences/01.png",
|
||||
GameCreationAppAssetKind::CharacterAnimation,
|
||||
),
|
||||
(
|
||||
"动画",
|
||||
"layer.png",
|
||||
GameCreationAppAssetKind::CharacterAnimation,
|
||||
),
|
||||
("音频", "layer.png", GameCreationAppAssetKind::Audio),
|
||||
("角色", "layer.png", GameCreationAppAssetKind::Character),
|
||||
("场景", "layer.png", GameCreationAppAssetKind::Scene),
|
||||
("UI", "layer.png", GameCreationAppAssetKind::UiDesign),
|
||||
// 无匹配时落到 image。
|
||||
];
|
||||
for (layer_type, file, expected) in cases {
|
||||
@@ -2215,20 +2223,20 @@ mod tests {
|
||||
// media/ 前缀同样走音频分支。
|
||||
assert_eq!(
|
||||
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
|
||||
"audio"
|
||||
GameCreationAppAssetKind::Audio
|
||||
);
|
||||
// 兜底分支是 image 而不是旧的 "asset"。
|
||||
assert_eq!(
|
||||
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
|
||||
"image"
|
||||
GameCreationAppAssetKind::Image
|
||||
);
|
||||
|
||||
// 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。
|
||||
// 所有分支的返回值都必须是正式枚举成员,且不能是旧的非 canonical 写法。
|
||||
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
|
||||
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
|
||||
assert!(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
|
||||
.contains(&kind),
|
||||
.contains(&kind.as_str()),
|
||||
"非 canonical kind: {kind}(layer_type={layer_type})"
|
||||
);
|
||||
for legacy in ["ui", "animation", "asset"] {
|
||||
@@ -2250,14 +2258,14 @@ mod tests {
|
||||
assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio");
|
||||
assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video");
|
||||
assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document");
|
||||
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document");
|
||||
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "font");
|
||||
assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code");
|
||||
// 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。
|
||||
// 判不出内容类型 → `unknown`(派生 `unclassified` → 「待归类」),不猜具体类型。
|
||||
assert_eq!(
|
||||
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
|
||||
"asset"
|
||||
"unknown"
|
||||
);
|
||||
assert_eq!(uploaded_asset_kind("no-extension", ""), "asset");
|
||||
assert_eq!(uploaded_asset_kind("no-extension", ""), "unknown");
|
||||
// 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。
|
||||
for (file_name, media_type) in [
|
||||
("hero.png", "image/png"),
|
||||
@@ -2269,8 +2277,11 @@ mod tests {
|
||||
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
|
||||
assert!(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
|
||||
.contains(&kind)
|
||||
|| kind == "asset",
|
||||
.contains(&kind.as_str())
|
||||
|| matches!(
|
||||
kind,
|
||||
GameCreationAppAssetKind::Font | GameCreationAppAssetKind::Unknown
|
||||
),
|
||||
"非 canonical kind: {kind}({file_name})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2167,7 +2167,7 @@ pub(crate) fn register_local_asset(
|
||||
register_local_asset_at(
|
||||
root,
|
||||
local_path.trim(),
|
||||
kind.trim(),
|
||||
GameCreationAppAssetKind::parse_with_context(kind.trim(), "asset.register"),
|
||||
media_type.trim(),
|
||||
source_kind.trim(),
|
||||
GameCreationAppAssetSource {
|
||||
@@ -2200,7 +2200,10 @@ pub(crate) fn create_ui_design_resource(
|
||||
let next_index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == "UI")
|
||||
.filter(|asset| {
|
||||
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
|
||||
})
|
||||
.count()
|
||||
+ 1;
|
||||
let resource_name = format!("UI 设计 {next_index}");
|
||||
@@ -2234,8 +2237,8 @@ pub(crate) fn create_ui_design_resource(
|
||||
let asset = match register_local_asset_at(
|
||||
root,
|
||||
&relative_path,
|
||||
"UI",
|
||||
"application/json",
|
||||
GameCreationAppAssetKind::UiDesignDoc,
|
||||
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
"generated",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
@@ -2725,7 +2728,7 @@ pub(crate) fn import_ui_editor_local_fonts(
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
&relative_path,
|
||||
"font",
|
||||
GameCreationAppAssetKind::Font,
|
||||
validated.metadata.format.media_type(),
|
||||
"font",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -2837,7 +2840,7 @@ mod ui_editor_font_tests {
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
relative_path,
|
||||
"font",
|
||||
GameCreationAppAssetKind::Font,
|
||||
"font/ttf",
|
||||
"font",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -2940,7 +2943,7 @@ mod ui_editor_font_tests {
|
||||
symlink(fixture_font_path(), &target).expect("create font symlink");
|
||||
let entry = GameCreationAppAssetManifestEntry {
|
||||
id: "font-linked".to_string(),
|
||||
kind: "font".to_string(),
|
||||
kind: GameCreationAppAssetKind::Font,
|
||||
media_type: "font/ttf".to_string(),
|
||||
local_path: relative_path.to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3255,24 +3258,34 @@ mod agent_asset_import_tests {
|
||||
/// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。
|
||||
#[test]
|
||||
fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() {
|
||||
assert_eq!(imported_platform_asset_kind(Some("character")), "character");
|
||||
assert_eq!(imported_platform_asset_kind(Some("scene")), "scene");
|
||||
use GameCreationAppAssetKind as Kind;
|
||||
assert_eq!(
|
||||
imported_platform_asset_kind(Some("character")),
|
||||
Kind::Character
|
||||
);
|
||||
assert_eq!(imported_platform_asset_kind(Some("scene")), Kind::Scene);
|
||||
assert_eq!(
|
||||
imported_platform_asset_kind(Some("character-animation")),
|
||||
"character-animation"
|
||||
Kind::CharacterAnimation
|
||||
);
|
||||
assert_eq!(
|
||||
imported_platform_asset_kind(Some("icon-spec")),
|
||||
Kind::IconSpec
|
||||
);
|
||||
assert_eq!(imported_platform_asset_kind(Some("icon-spec")), "icon-spec");
|
||||
// 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」);
|
||||
// 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。
|
||||
assert_eq!(imported_platform_asset_kind(Some("UI")), "ui-design");
|
||||
assert_eq!(imported_platform_asset_kind(Some("ui")), "ui-design");
|
||||
assert_eq!(imported_platform_asset_kind(Some("UI")), Kind::UiDesign);
|
||||
assert_eq!(imported_platform_asset_kind(Some("ui")), Kind::UiDesign);
|
||||
// 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。
|
||||
assert_eq!(imported_platform_asset_kind(None), "image");
|
||||
assert_eq!(imported_platform_asset_kind(Some(" ")), "image");
|
||||
assert_eq!(imported_platform_asset_kind(Some("mystery")), "image");
|
||||
assert_eq!(imported_platform_asset_kind(None), Kind::Image);
|
||||
assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image);
|
||||
assert_eq!(imported_platform_asset_kind(Some("mystery")), Kind::Image);
|
||||
// `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。
|
||||
assert_eq!(imported_platform_asset_kind(Some("__proto__")), "image");
|
||||
assert_eq!(imported_platform_asset_kind(Some("constructor")), "image");
|
||||
assert_eq!(imported_platform_asset_kind(Some("__proto__")), Kind::Image);
|
||||
assert_eq!(
|
||||
imported_platform_asset_kind(Some("constructor")),
|
||||
Kind::Image
|
||||
);
|
||||
}
|
||||
|
||||
/// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`,
|
||||
@@ -3801,7 +3814,7 @@ pub(crate) async fn list_editor_assets_for_agent_at(
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct AgentLocalProjectFileType {
|
||||
category: &'static str,
|
||||
asset_kind: &'static str,
|
||||
asset_kind: GameCreationAppAssetKind,
|
||||
media_type: &'static str,
|
||||
max_file_size: u64,
|
||||
}
|
||||
@@ -3856,14 +3869,14 @@ fn agent_local_project_file_type(
|
||||
// 错值放大成显式的 `ui-interaction`。
|
||||
media_type.map(|media_type| AgentLocalProjectFileType {
|
||||
category: "image",
|
||||
asset_kind: "image",
|
||||
asset_kind: GameCreationAppAssetKind::Image,
|
||||
media_type,
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
})
|
||||
}
|
||||
"ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType {
|
||||
category: "font",
|
||||
asset_kind: "font",
|
||||
asset_kind: GameCreationAppAssetKind::Font,
|
||||
media_type: match extension.as_str() {
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
@@ -3874,68 +3887,68 @@ fn agent_local_project_file_type(
|
||||
}),
|
||||
"mp3" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/mpeg",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"wav" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/wav",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"ogg" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/ogg",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"flac" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/flac",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"m4a" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/mp4",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"aac" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/aac",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"opus" => Some(AgentLocalProjectFileType {
|
||||
category: "audio",
|
||||
asset_kind: "audio",
|
||||
asset_kind: GameCreationAppAssetKind::Audio,
|
||||
media_type: "audio/opus",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"mp4" => Some(AgentLocalProjectFileType {
|
||||
category: "video",
|
||||
asset_kind: "video",
|
||||
asset_kind: GameCreationAppAssetKind::Video,
|
||||
media_type: "video/mp4",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"webm" => Some(AgentLocalProjectFileType {
|
||||
category: "video",
|
||||
asset_kind: "video",
|
||||
asset_kind: GameCreationAppAssetKind::Video,
|
||||
media_type: "video/webm",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"mov" => Some(AgentLocalProjectFileType {
|
||||
category: "video",
|
||||
asset_kind: "video",
|
||||
asset_kind: GameCreationAppAssetKind::Video,
|
||||
media_type: "video/quicktime",
|
||||
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
|
||||
}),
|
||||
"md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" | "csv" | "ini"
|
||||
| "conf" | "xml" => Some(AgentLocalProjectFileType {
|
||||
category: "document",
|
||||
asset_kind: "document",
|
||||
asset_kind: GameCreationAppAssetKind::Document,
|
||||
media_type: if extension == "json" {
|
||||
"application/json"
|
||||
} else if matches!(extension.as_str(), "yaml" | "yml") {
|
||||
@@ -3952,7 +3965,7 @@ fn agent_local_project_file_type(
|
||||
| "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql"
|
||||
| "vue" | "svelte" => Some(AgentLocalProjectFileType {
|
||||
category: "code",
|
||||
asset_kind: "code",
|
||||
asset_kind: GameCreationAppAssetKind::Code,
|
||||
media_type: if matches!(extension.as_str(), "html" | "htm") {
|
||||
"text/html"
|
||||
} else if matches!(extension.as_str(), "css" | "scss" | "less") {
|
||||
@@ -4070,7 +4083,7 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
}
|
||||
inputs.push((
|
||||
local_path,
|
||||
file_type.asset_kind.to_string(),
|
||||
file_type.asset_kind,
|
||||
file_type.media_type.to_string(),
|
||||
bytes,
|
||||
));
|
||||
@@ -4089,7 +4102,7 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
imported.push(ImportedAsset {
|
||||
id: existing.id.clone(),
|
||||
local_path: existing.local_path.clone(),
|
||||
asset_kind: Some(existing.kind.clone()),
|
||||
asset_kind: Some(existing.kind.to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -4128,7 +4141,7 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
&asset_kind,
|
||||
asset_kind,
|
||||
&media_type,
|
||||
"local",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -4147,7 +4160,7 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
imported.push(ImportedAsset {
|
||||
id: registered.id,
|
||||
local_path: registered.local_path,
|
||||
asset_kind: Some(asset_kind),
|
||||
asset_kind: Some(asset_kind.to_string()),
|
||||
});
|
||||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||||
format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}")
|
||||
@@ -4164,15 +4177,14 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏
|
||||
/// (`ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值,
|
||||
/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。
|
||||
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> String {
|
||||
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind {
|
||||
match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) {
|
||||
// 平台侧词汇可能与 canonical 目录不完全一致(例如大写 `UI`),统一过 canonical 归一,
|
||||
// 保证写入侧只产出 canonical `kind`。
|
||||
Some(kind) => {
|
||||
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind)
|
||||
.to_string()
|
||||
}
|
||||
None => "image".to_string(),
|
||||
// 平台是外部系统,它的 `assetKind` 词汇(含大写 `UI` 这类变体)由共享契约统一收口到
|
||||
// canonical 值;这里只把收口结果转成正式枚举,写入侧不再产出裸字符串。
|
||||
Some(kind) => GameCreationAppAssetKind::from_str_lossy(
|
||||
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind),
|
||||
),
|
||||
None => GameCreationAppAssetKind::Image,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4280,7 +4292,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
|
||||
imported.push(ImportedAsset {
|
||||
id: existing.id.clone(),
|
||||
local_path: existing.local_path.clone(),
|
||||
asset_kind: Some(existing.kind.clone()),
|
||||
asset_kind: Some(existing.kind.to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -4330,7 +4342,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
&asset_kind,
|
||||
asset_kind,
|
||||
&media_type,
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -4448,7 +4460,7 @@ pub(crate) async fn import_ui_editor_remote_assets(
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
&asset_kind,
|
||||
asset_kind,
|
||||
&media_type,
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
|
||||
@@ -33,8 +33,8 @@ use shared_contracts::game_creation_app::{
|
||||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry,
|
||||
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
|
||||
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
|
||||
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
|
||||
@@ -2219,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent)
|
||||
enum GameCreatorGuiRunnerShutdownOutcome {
|
||||
NotRequested,
|
||||
Requested,
|
||||
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
|
||||
Retained,
|
||||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||||
}
|
||||
|
||||
@@ -2256,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error(
|
||||
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
||||
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
||||
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
||||
} else if error.contains("实例锁") || error.contains("owner 锁") {
|
||||
} else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁")
|
||||
{
|
||||
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
||||
} else if error.contains("endpoint") {
|
||||
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
||||
@@ -2272,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown<F>(
|
||||
shutdown: F,
|
||||
) -> GameCreatorGuiRunnerShutdownOutcome
|
||||
where
|
||||
F: FnOnce() -> Result<(), String>,
|
||||
F: FnOnce() -> Result<bool, String>,
|
||||
{
|
||||
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
||||
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
||||
}
|
||||
match shutdown() {
|
||||
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained,
|
||||
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
||||
classify_game_creator_gui_runner_shutdown_error(&error),
|
||||
),
|
||||
@@ -2298,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
}
|
||||
}
|
||||
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
|
||||
match resolve_game_creator_gui_runner_shutdown(
|
||||
event,
|
||||
shutdown_external_agent_runner_for_gui_exit,
|
||||
) {
|
||||
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_requested")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Retained => {
|
||||
app_log!("agent.runner.gui_exit.retained_for_other_windows")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||
}
|
||||
@@ -2611,27 +2621,25 @@ fn main() {
|
||||
)
|
||||
})?;
|
||||
setup_log.append("startup.runner.configure.complete");
|
||||
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
||||
hold_external_agent_runner_gui_participant_lock(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.owner-lock.failed details={details}"
|
||||
"startup.runner.participant-lock.failed details={details}"
|
||||
));
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
format!("获取 GUI owner 锁失败:{error}"),
|
||||
format!("建立 AGC 界面参与锁失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||||
app.manage(gui_owner_lock);
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
|
||||
@@ -1069,9 +1069,15 @@ pub(crate) fn validate_manifest_required_visual_asset(
|
||||
task_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let (expected_path, expected_kind) = match task_id {
|
||||
"art-director" => ("assets/art-spec.png", "icon-spec"),
|
||||
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype"),
|
||||
"art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"),
|
||||
"art-director" => ("assets/art-spec.png", GameCreationAppAssetKind::IconSpec),
|
||||
"design-foundation" => (
|
||||
"assets/ui-prototype.png",
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
),
|
||||
"art-asset-plan" => (
|
||||
"assets/art-spritesheet.png",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let asset = manifest
|
||||
@@ -1141,7 +1147,10 @@ pub(crate) fn validate_manifest_required_visual_asset(
|
||||
let art_spec = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.local_path == "assets/art-spec.png" && asset.kind == "icon-spec")
|
||||
.find(|asset| {
|
||||
asset.local_path == "assets/art-spec.png"
|
||||
&& asset.kind == GameCreationAppAssetKind::IconSpec
|
||||
})
|
||||
.ok_or_else(|| "缺少当前统一视觉规范图".to_string())?;
|
||||
let art_spec_project_id = art_spec
|
||||
.source
|
||||
|
||||
@@ -19,7 +19,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) {
|
||||
let first = register_local_asset_entry(
|
||||
&root,
|
||||
"assets/hero.png",
|
||||
"character",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
"asset",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -39,7 +39,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) {
|
||||
register_local_asset_entry(
|
||||
&root,
|
||||
"assets/theme.mp3",
|
||||
"background-music",
|
||||
GameCreationAppAssetKind::BackgroundMusic,
|
||||
"audio/mpeg",
|
||||
"asset",
|
||||
GameCreationAppAssetSource {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user