完善游戏创作工作台与智能体恢复闭环

实现横屏项目开发工作台、资源画布、Supervisor 对话与专业 Agent 状态展示

修复总控和专业 Agent 重试、Runner 接管、历史成果跨 Run 持久展示及确认流程

补齐画板图片产物合同、客户端内预览、共享契约与安全边界

新增完整回归测试、工作台 PRD、技术方案与待解决事项
This commit is contained in:
AIGameCreator App
2026-07-20 19:48:05 +08:00
parent 29228afb9e
commit 3a8dff1404
35 changed files with 10982 additions and 614 deletions
+2
View File
@@ -28,6 +28,8 @@
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
},
@@ -152,6 +152,14 @@ function assertNoNativeBrowserConfirm(paths) {
}
}
function assertNoBlockingNativeFilePicker(source) {
if (/\.blocking_pick_(?:file|files|folder|folders)\s*\(/.test(source)) {
throw new Error(
'AI game creator shell native file pickers must not block the Tauri event loop',
);
}
}
function extractConstArrayBlock(source, name) {
const start = source.indexOf(`const ${name}`);
if (start === -1) {
@@ -167,9 +175,7 @@ function extractConstArrayBlock(source, name) {
function parseTsCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
return Array.from(
block.matchAll(
/\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g,
),
block.matchAll(/\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g),
([, id, permission]) => ({ id, permission }),
);
}
@@ -313,6 +319,7 @@ assertNoEnvironmentConfigFallbacks([
]);
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
assertNoBlockingNativeFilePicker(tauriRustSource);
assertContractRecordsMatch(
'AI game creator shell command contract',
@@ -512,12 +519,14 @@ if (
const clientWindow = windows[0];
if (
clientWindow.width !== 820 ||
clientWindow.height !== 640 ||
clientWindow.minWidth !== 720 ||
clientWindow.minHeight !== 520
clientWindow.width !== 1280 ||
clientWindow.height !== 800 ||
clientWindow.minWidth !== 1280 ||
clientWindow.minHeight !== 800
) {
throw new Error('AI game creator shell client window must stay compact');
throw new Error(
'AI game creator shell client window must keep the landscape workbench size',
);
}
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
@@ -683,10 +692,9 @@ for (const snippet of [
'LLM API Key',
'画板 API Key',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并交给外部浏览器'",
'async function openPreviewInExternalBrowser',
"'open_local_game_preview'",
'已交给外部浏览器打开。',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
'已切换到客户端运行视图',
'async function executeRunLocal',
'function needsInitializedChatProject',
'function resolvePendingCommandProjectPath',
+223 -5
View File
@@ -509,6 +509,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chromiumoxide"
version = "0.9.1"
@@ -676,6 +687,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1500,8 +1520,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1523,8 +1545,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1851,6 +1876,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.2",
"hyper 1.10.1",
"hyper-util",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
@@ -2351,6 +2392,12 @@ dependencies = [
"weezl",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -3223,6 +3270,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.46"
@@ -3251,7 +3354,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -3261,7 +3375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -3273,6 +3387,21 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rangemap"
version = "1.7.1"
@@ -3411,6 +3540,7 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper-rustls",
"hyper-tls 0.6.0",
"hyper-util",
"js-sys",
@@ -3418,6 +3548,9 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3425,6 +3558,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3498,6 +3632,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "2.2.0"
@@ -3549,6 +3697,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3564,9 +3738,21 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3907,7 +4093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3918,7 +4104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -4109,6 +4295,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4707,6 +4899,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -4966,7 +5168,7 @@ dependencies = [
"http 1.4.2",
"httparse",
"log",
"rand",
"rand 0.9.5",
"sha1",
"thiserror 2.0.18",
"utf-8",
@@ -5078,6 +5280,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5290,6 +5498,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
File diff suppressed because one or more lines are too long
@@ -682,7 +682,9 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"preview.start" => "启动当前项目的 loopback HTTP 预览。",
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate" => "通过已配置平台生成并登记首版美术素材。",
"canvas.asset_generate" => {
"通过已配置平台生成图片,写入确定的项目 assets 路径并登记素材。"
}
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => "用持久验收合同把边界清晰的后台任务委派给另一个 Agent。",
@@ -892,7 +894,19 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"question": { "type": ["string", "null"], "maxLength": 1000 }
}
}),
"canvas.asset_generate" => one_string_input_schema("prompt"),
"canvas.asset_generate" => json!({
"type": "object",
"required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel"],
"additionalProperties": false,
"properties": {
"prompt": { "type": "string", "minLength": 1, "maxLength": 4000 },
"outputPath": { "type": ["string", "null"], "maxLength": 240 },
"aspectRatio": { "type": ["string", "null"], "enum": ["1:1", "2:3", "3:2", "9:16", "16:9", null] },
"imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] },
"assetKind": { "type": ["string", "null"], "enum": ["game-art", "ui-prototype", "art-spritesheet", null] },
"assetLabel": { "type": ["string", "null"], "maxLength": 80 }
}
}),
"blackboard.write" => two_string_input_schema("title", "content"),
"agent.message" => two_string_input_schema("agentId", "content"),
"agent.delegate" => json!({
@@ -81,6 +81,17 @@ pub(crate) enum CliCommand {
run_id: String,
action_id: String,
},
AgentCancel {
project_path: PathBuf,
agent_id: String,
run_id: String,
},
AgentRetry {
project_path: PathBuf,
agent_id: String,
run_id: String,
next_run_id: String,
},
AgentSteer {
project_path: PathBuf,
agent_id: String,
@@ -116,6 +127,8 @@ impl CliCommand {
| Self::AgentEnqueue { .. }
| Self::AgentContextCompact { .. }
| Self::AgentConfirm { .. }
| Self::AgentCancel { .. }
| Self::AgentRetry { .. }
| Self::AgentSteer { .. }
| Self::AgentGoalStart { .. }
| Self::AgentGoalEdit { .. }
@@ -133,6 +146,10 @@ impl CliCommand {
)
}
pub(crate) fn requires_started_external_agent_runner(&self) -> bool {
self.requires_external_agent_runner() && !matches!(self, Self::AgentCancel { .. })
}
fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> {
match self {
Self::AgentTask {
@@ -164,6 +181,8 @@ impl CliCommand {
| Self::AgentGoalResume { project_path, .. }
| Self::AgentGoalClear { project_path, .. }
| Self::AgentConfirm { project_path, .. }
| Self::AgentCancel { project_path, .. }
| Self::AgentRetry { project_path, .. }
| Self::AgentSteer { project_path, .. }
| Self::AgentResume { project_path }
| Self::AgentRun { project_path, .. } => Some((project_path, false)),
@@ -529,6 +548,29 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, S
action_id: args[4].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-cancel") {
const USAGE: &str = "用法:--agent-cancel <本地项目绝对路径> <agentId> <runId>";
if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentCancel {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
run_id: args[3].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-retry") {
const USAGE: &str = "用法:--agent-retry <本地项目绝对路径> <agentId> <runId> <nextRunId>";
if args.len() != 5 || args[1..].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentRetry {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
run_id: args[3].trim().to_string(),
next_run_id: args[4].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-steer") {
const USAGE: &str = "用法:--agent-steer <本地项目绝对路径> <agentId> <sessionId> <runId> <steerId> --stdin";
if args.len() != 7 || args.last().map(String::as_str) != Some("--stdin") {
@@ -1070,6 +1112,46 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
);
Ok(())
}
CliCommand::AgentCancel {
project_path,
agent_id,
run_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
// Cancellation must remain available when a busy older Runner blocks a build
// handover. It writes the durable cancel tombstone/state locally; retry still
// requires the current executable's Runner after the old run becomes idle.
require_external_agent_runner_configured_for_cli_runtime_write(&project_path)?;
let runtime =
cancel_game_creator_agent_runtime_task_at(&project_path, &agent_id, &run_id)?;
println!("agent.cancel.accepted");
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentRetry {
project_path,
agent_id,
run_id,
next_run_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let runtime = retry_game_creator_agent_runtime_task_at(
&project_path,
&agent_id,
&run_id,
&next_run_id,
)?;
println!("agent.retry.accepted");
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentSteer {
project_path,
agent_id,
@@ -1252,6 +1334,84 @@ mod tests {
assert!(command.requires_external_agent_runner());
}
#[test]
fn parses_agent_cancel_and_retry_with_explicit_run_identity() {
let project_path = PathBuf::from("/tmp/game-project");
let cancel = parse_cli_command(&[
"--agent-cancel".to_string(),
project_path.display().to_string(),
" project-supervisor ".to_string(),
" run-9 ".to_string(),
])
.expect("parse agent cancel")
.expect("agent cancel command");
assert_eq!(
cancel,
CliCommand::AgentCancel {
project_path: project_path.clone(),
agent_id: "project-supervisor".to_string(),
run_id: "run-9".to_string(),
}
);
assert!(cancel.requires_external_agent_runner());
assert!(!cancel.requires_started_external_agent_runner());
assert!(!cancel.is_read_only_status());
let retry = parse_cli_command(&[
"--agent-retry".to_string(),
project_path.display().to_string(),
" project-supervisor ".to_string(),
" run-9 ".to_string(),
" run-10 ".to_string(),
])
.expect("parse agent retry")
.expect("agent retry command");
assert_eq!(
retry,
CliCommand::AgentRetry {
project_path,
agent_id: "project-supervisor".to_string(),
run_id: "run-9".to_string(),
next_run_id: "run-10".to_string(),
}
);
assert!(retry.requires_external_agent_runner());
assert!(retry.requires_started_external_agent_runner());
assert!(!retry.is_read_only_status());
}
#[test]
fn agent_cancel_and_retry_reject_missing_or_blank_identity() {
for args in [
vec!["--agent-cancel"],
vec![
"--agent-cancel",
"/tmp/game-project",
"project-supervisor",
" ",
],
vec![
"--agent-retry",
"/tmp/game-project",
"project-supervisor",
"run-9",
],
vec![
"--agent-retry",
"/tmp/game-project",
"project-supervisor",
"run-9",
"\t",
],
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(
parse_cli_command(&args).is_err(),
"args should fail: {args:?}"
);
}
}
#[test]
fn agent_steer_rejects_missing_params_and_argv_instruction() {
assert!(parse_cli_command(&["--agent-steer".to_string()]).is_err());
@@ -102,10 +102,21 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationA
}
#[tauri::command]
pub(crate) fn pick_local_project_directory(
pub(crate) async fn pick_local_project_directory(
app: tauri::AppHandle,
) -> Result<Option<String>, String> {
let Some(path) = app.dialog().file().blocking_pick_folder() else {
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_folder(move |path| {
let _ = sender.send(path);
});
let Some(path) = receiver
.await
.map_err(|_| "项目目录选择器意外关闭".to_string())?
else {
return Ok(None);
};
path.into_path()
@@ -114,8 +125,19 @@ pub(crate) fn pick_local_project_directory(
}
#[tauri::command]
pub(crate) fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
let Some(path) = app.dialog().file().blocking_pick_file() else {
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app.dialog().file().set_title("选择本地文件");
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_file(move |path| {
let _ = sender.send(path);
});
let Some(path) = receiver
.await
.map_err(|_| "本地文件选择器意外关闭".to_string())?
else {
return Ok(None);
};
path.into_path()
@@ -645,6 +667,27 @@ pub(crate) fn retry_game_creator_agent_runtime_task(
)
}
#[tauri::command]
pub(crate) fn confirm_retry_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
run_id: String,
next_run_id: String,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
// 正式工作台的“在当前项目重试”按钮本身就是用户对本次 agent.resume 的明确确认。
enforce_project_permission_policy(root, "agent.resume")?;
retry_game_creator_agent_runtime_task_at(
root,
agent_id.trim(),
run_id.trim(),
next_run_id.trim(),
)
}
#[tauri::command]
pub(crate) fn confirm_game_creator_agent_runtime_task(
project_path: String,
@@ -566,6 +566,8 @@ struct AgentGoalMutationResult {
#[serde(rename_all = "camelCase")]
struct AgentRuntimeResult {
state: AgentRuntimeState,
#[serde(skip_serializing_if = "Option::is_none")]
accepted_run_id: Option<String>,
session_path: String,
event_path: String,
task_path: String,
@@ -960,6 +962,7 @@ struct LocalConversationMessageRecord {
role: String,
content: String,
agent_id: Option<String>,
message_id: Option<String>,
updated_at: u64,
}
@@ -1123,6 +1126,7 @@ const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2;
fn default_game_creator_llm_context_window_tokens() -> u64 {
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
@@ -1210,7 +1214,7 @@ impl Default for GameCreatorLlmConfig {
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
max_retries: 0,
max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
}
}
@@ -1626,7 +1630,7 @@ fn main() {
}
set_game_creator_runtime_config_dir(config_dir);
}
if command.requires_external_agent_runner() {
if command.requires_started_external_agent_runner() {
if let Err(error) = ensure_external_agent_runner_started() {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
@@ -1697,6 +1701,7 @@ fn main() {
steer_game_creator_agent_runtime_task,
cancel_game_creator_agent_runtime_task,
retry_game_creator_agent_runtime_task,
confirm_retry_game_creator_agent_runtime_task,
confirm_game_creator_agent_runtime_task,
reject_game_creator_agent_runtime_task,
answer_game_creator_agent_runtime_user_input,
@@ -1747,7 +1752,7 @@ fn main() {
open_game_creator_workspace_window,
open_game_creator_launcher_window,
start_local_game_preview,
open_local_game_preview,
activate_local_game_preview,
stop_local_game_preview,
get_local_game_preview_status,
get_local_game_manifest
@@ -254,17 +254,13 @@ pub(crate) fn get_local_game_preview_status_at(
}
#[tauri::command]
pub(crate) fn open_local_game_preview(
app: tauri::AppHandle,
pub(crate) fn activate_local_game_preview(
registry: tauri::State<'_, PreviewRegistry>,
project_path: Option<String>,
) -> Result<LocalPreviewStatus, String> {
let status = registry.status();
validate_preview_open_project(&status, project_path.as_deref())?;
let url = preview_open_url(&status)?;
app.opener()
.open_url(&url, None::<&str>)
.map_err(|error| format!("preview open failed: {error}"))?;
preview_open_url(&status)?;
Ok(status)
}
@@ -4914,6 +4914,7 @@ impl PersistedLocalConversationMessageRecord {
role: self.role.clone(),
content: self.content.clone(),
agent_id: self.agent_id.clone(),
message_id: self.message_id.clone(),
updated_at: self.updated_at,
}
}
@@ -7838,7 +7839,7 @@ pub(crate) fn record_preview_state(
port: Option<u16>,
) -> Result<(), String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
let is_running = status == GameCreationAppPreviewStatus::Running;
manifest.preview = Some(GameCreationAppPreviewState { status, url, port });
if is_running {
@@ -7856,7 +7857,7 @@ pub(crate) fn record_command_run(
run: GameCreationAppCommandRunState,
) -> Result<(), String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
if run.command_id == "game.static_smoke"
&& run.status == GameCreationAppCommandRunStatus::Completed
{
@@ -7877,7 +7878,7 @@ pub(crate) fn record_command_run(
pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
write_manifest(&manifest_path, &manifest)?;
Ok(manifest)
}
@@ -7887,7 +7888,7 @@ pub(crate) fn ensure_manifest_has_seed_tasks(
goal: Option<&str>,
) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
if let Some(goal) = goal.map(str::trim).filter(|goal| !goal.is_empty()) {
manifest.goal = Some(goal.to_string());
}
@@ -7902,15 +7903,13 @@ pub(crate) fn record_draft_task_progress(
agent_log_path: &Path,
) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
manifest.goal = Some(goal.to_string());
for completed_task_id in [
"design-director",
"design-foundation",
"balance-director",
"balance-seed",
"art-director",
"art-asset-plan",
"art-polish",
"audio-director",
"audio-asset-plan",
@@ -7940,7 +7939,7 @@ pub(crate) fn record_draft_task_progress(
Ok(manifest)
}
pub(crate) fn ensure_manifest_seed_tasks(manifest: &mut GameCreationAppManifest) {
pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreationAppManifest) {
let seed_tasks = new_game_creation_app_seed_tasks();
if manifest.tasks.is_empty() {
manifest.tasks = seed_tasks;
@@ -7948,12 +7947,23 @@ pub(crate) fn ensure_manifest_seed_tasks(manifest: &mut GameCreationAppManifest)
}
for seed_task in seed_tasks {
let visual_asset_ready = manifest_has_required_visual_asset(root, manifest, &seed_task.id);
if let Some(existing_task) = manifest
.tasks
.iter_mut()
.find(|task| task.id == seed_task.id)
{
let status = existing_task.status.clone();
let status = if existing_task.status == GameCreationAppTaskStatus::Completed
&& matches!(
seed_task.id.as_str(),
"design-foundation" | "art-asset-plan"
)
&& !visual_asset_ready
{
GameCreationAppTaskStatus::Pending
} else {
existing_task.status.clone()
};
*existing_task = seed_task;
existing_task.status = status;
} else {
@@ -7962,6 +7972,27 @@ pub(crate) fn ensure_manifest_seed_tasks(manifest: &mut GameCreationAppManifest)
}
}
fn manifest_has_required_visual_asset(
root: &Path,
manifest: &GameCreationAppManifest,
task_id: &str,
) -> bool {
let (expected_path, expected_kind) = match task_id {
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype"),
"art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"),
_ => return true,
};
manifest.assets.iter().any(|asset| {
asset.local_path == expected_path
&& asset.kind == expected_kind
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& resolve_local_project_path(root, &asset.local_path)
.ok()
.is_some_and(|path| path.is_file())
})
}
pub(crate) fn set_task_status(
manifest: &mut GameCreationAppManifest,
task_id: &str,
@@ -7982,7 +8013,7 @@ pub(crate) fn update_manifest_task_status_at(
return Err("任务 ID 不能为空".to_string());
}
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else {
return Err(format!("项目任务不存在:{task_id}"));
};
@@ -8004,7 +8035,7 @@ pub(crate) fn create_manifest_task_at(
acceptance_criteria: Vec<String>,
) -> Result<GameCreationAppTaskState, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
ensure_manifest_seed_tasks(root, &mut manifest);
let fallback_id = format!(
"agent-task-{}-{}",
unix_timestamp(),
@@ -52,6 +52,7 @@ static EXTERNAL_AGENT_RUNNER_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = Once
static EXTERNAL_AGENT_RUNNER_CONFIGURE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static EXTERNAL_AGENT_RUNNER_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
static EXTERNAL_AGENT_RUNNER_SERVER_PROCESS: AtomicBool = AtomicBool::new(false);
static EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT: OnceLock<String> = OnceLock::new();
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -62,6 +63,14 @@ struct ExternalAgentRunnerEndpoint {
port: u16,
token: String,
heartbeat_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
executable_fingerprint: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ExternalAgentRunnerReuseDecision {
Reuse,
Retire,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -108,10 +117,66 @@ impl ExternalAgentRunnerEndpoint {
if self.token.len() < 32 || self.token.len() > 256 {
return Err("Agent Runner endpoint token 无效".to_string());
}
if self.executable_fingerprint.as_deref().is_some_and(|value| {
value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
}) {
return Err("Agent Runner endpoint executableFingerprint 无效".to_string());
}
Ok(())
}
}
fn external_agent_runner_endpoint_reuse_decision(
endpoint: &ExternalAgentRunnerEndpoint,
executable_fingerprint: &str,
) -> ExternalAgentRunnerReuseDecision {
if endpoint.protocol_version == EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION
&& endpoint.executable_fingerprint.as_deref() == Some(executable_fingerprint)
{
ExternalAgentRunnerReuseDecision::Reuse
} else {
ExternalAgentRunnerReuseDecision::Retire
}
}
fn external_agent_runner_executable_fingerprint_at(path: &Path) -> Result<String, String> {
let mut file = File::open(path)
.map_err(|error| format!("打开当前 Agent Runner 可执行文件失败:{error}"))?;
let metadata = file
.metadata()
.map_err(|error| format!("读取当前 Agent Runner 可执行文件元数据失败:{error}"))?;
if !metadata.is_file() {
return Err("当前 Agent Runner 可执行文件不是普通文件".to_string());
}
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("读取当前 Agent Runner 可执行文件失败:{error}"))?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(format!("{:x}", digest.finalize()))
}
fn current_external_agent_runner_executable_fingerprint() -> Result<String, String> {
if let Some(fingerprint) = EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT.get() {
return Ok(fingerprint.clone());
}
let executable = std::env::current_exe()
.map_err(|error| format!("定位当前 Agent Runner 可执行文件失败:{error}"))?;
let fingerprint = external_agent_runner_executable_fingerprint_at(&executable)?;
let _ = EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT.set(fingerprint.clone());
Ok(EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT
.get()
.cloned()
.unwrap_or(fingerprint))
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalAgentRunnerStatus {
@@ -887,10 +952,16 @@ fn read_external_agent_runner_endpoint(path: &Path) -> Result<ExternalAgentRunne
Ok(endpoint)
}
fn read_current_external_agent_runner_endpoint(path: &Path) -> Option<ExternalAgentRunnerEndpoint> {
fn read_current_external_agent_runner_endpoint(
path: &Path,
executable_fingerprint: &str,
) -> Option<ExternalAgentRunnerEndpoint> {
read_external_agent_runner_endpoint(path)
.ok()
.filter(|endpoint| endpoint.protocol_version == EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION)
.filter(|endpoint| {
external_agent_runner_endpoint_reuse_decision(endpoint, executable_fingerprint)
== ExternalAgentRunnerReuseDecision::Reuse
})
}
#[cfg(unix)]
@@ -3027,6 +3098,7 @@ pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Resu
pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) -> Result<(), String> {
let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?;
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release);
crate::set_game_creator_runtime_config_dir(config_dir.clone());
set_external_agent_runner_config_dir(config_dir.clone());
@@ -3054,6 +3126,7 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef<Path>) ->
port,
token,
heartbeat_at: unix_millis(),
executable_fingerprint: Some(executable_fingerprint),
};
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
write_external_agent_runner_endpoint_atomic(&endpoint_path, &endpoint)?;
@@ -3277,10 +3350,9 @@ fn retire_incompatible_external_agent_runner(
ExternalAgentRunnerRequestParams::default(),
)?;
if result.get("idle").and_then(Value::as_bool) != Some(true) {
return Err(format!(
"Agent Runner 协议需要从 {} 升级到 {},但旧 Runner 仍有任务,暂不能重启",
endpoint.protocol_version, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION
));
return Err(
"Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(),
);
}
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT;
@@ -3290,7 +3362,7 @@ fn retire_incompatible_external_agent_runner(
_ => return Ok(()),
}
if Instant::now() >= deadline {
return Err(" Agent Runner 未在协议升级期限内退出".to_string());
return Err("旧 Agent Runner 未在版本切换期限内退出".to_string());
}
thread::sleep(Duration::from_millis(50));
}
@@ -3299,12 +3371,15 @@ fn retire_incompatible_external_agent_runner(
fn wait_for_external_agent_runner(
config_dir: &Path,
child: &mut Child,
executable_fingerprint: &str,
) -> Result<ExternalAgentRunnerEndpoint, String> {
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT;
let mut child_exit_status = None;
loop {
if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path) {
if let Some(endpoint) =
read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint)
{
if ping_external_agent_runner(&endpoint).is_ok() {
return Ok(endpoint);
}
@@ -3327,26 +3402,30 @@ fn wait_for_external_agent_runner(
fn ensure_external_agent_runner(config_dir: &Path) -> Result<ExternalAgentRunnerEndpoint, String> {
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) {
if endpoint.protocol_version == EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION {
match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) {
ExternalAgentRunnerReuseDecision::Reuse => {
if ping_external_agent_runner(&endpoint).is_ok() {
return Ok(endpoint);
}
} else {
let legacy_ping = send_external_agent_runner_request_with_protocol_and_id(
}
ExternalAgentRunnerReuseDecision::Retire => {
let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id(
&endpoint,
endpoint.protocol_version,
random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?,
"runner.ping",
ExternalAgentRunnerRequestParams::default(),
);
if legacy_ping.is_ok() {
if incompatible_ping.is_ok() {
retire_incompatible_external_agent_runner(&endpoint_path, &endpoint)?;
}
}
}
}
let mut child = launch_external_agent_runner(config_dir)?;
match wait_for_external_agent_runner(config_dir, &mut child) {
match wait_for_external_agent_runner(config_dir, &mut child, &executable_fingerprint) {
Ok(endpoint) => {
thread::Builder::new()
.name("agent-runner-reaper".to_string())
@@ -3389,6 +3468,13 @@ pub(crate) fn ensure_external_agent_runner_started() -> Result<(), String> {
pub(crate) fn require_external_agent_runner_for_cli_runtime_write(
root: &Path,
) -> Result<(), String> {
require_external_agent_runner_configured_for_cli_runtime_write(root)?;
ensure_external_agent_runner_started()
}
pub(crate) fn require_external_agent_runner_configured_for_cli_runtime_write(
root: &Path,
) -> Result<(), String> {
if external_agent_runner_is_server_process() {
return Err("Agent Runner 进程不能作为普通 CLI 执行 Runtime 写命令".to_string());
@@ -3399,8 +3485,7 @@ pub(crate) fn require_external_agent_runner_for_cli_runtime_write(
if !root.is_absolute() {
return Err("Agent Runtime 写命令的项目路径必须是绝对路径".to_string());
}
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, root)?;
ensure_external_agent_runner_started()
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, root)
}
fn parse_external_agent_runner_notification_kind(
@@ -3814,9 +3899,84 @@ mod tests {
port,
token: token.to_string(),
heartbeat_at: 1_725_000_000_000,
executable_fingerprint: Some("a".repeat(64)),
}
}
#[test]
fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() {
let endpoint = test_endpoint(
"shape-private-token-shape-private-token",
"shape-boot-id",
12001,
);
let mut legacy_value = serde_json::to_value(&endpoint).expect("serialize endpoint");
legacy_value
.as_object_mut()
.expect("endpoint object")
.remove("executableFingerprint");
let mut endpoint = serde_json::from_value::<ExternalAgentRunnerEndpoint>(legacy_value)
.expect("deserialize legacy endpoint without fingerprint");
assert_eq!(endpoint.executable_fingerprint, None);
endpoint
.validate_shape()
.expect("legacy endpoint remains readable for orderly retirement");
endpoint.executable_fingerprint = Some("f".repeat(63));
assert!(endpoint.validate_shape().is_err());
endpoint.executable_fingerprint = Some(format!("{}g", "f".repeat(63)));
assert!(endpoint.validate_shape().is_err());
endpoint.executable_fingerprint = Some("ABCDEF0123456789".repeat(4));
endpoint
.validate_shape()
.expect("64 hexadecimal digits are valid");
}
#[test]
fn executable_fingerprint_hashes_file_contents_with_sha256() {
let directory = unique_test_directory();
let executable = directory.0.join("runner-binary");
fs::write(&executable, b"abc").expect("write executable fixture");
assert_eq!(
external_agent_runner_executable_fingerprint_at(&executable)
.expect("fingerprint executable fixture"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn endpoint_reuse_requires_current_protocol_and_executable_identity() {
let current_fingerprint = "b".repeat(64);
let mut endpoint = test_endpoint(
"reuse-private-token-reuse-private-token",
"reuse-boot-id",
12002,
);
endpoint.executable_fingerprint = Some(current_fingerprint.clone());
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Reuse
);
endpoint.executable_fingerprint = None;
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Retire
);
endpoint.executable_fingerprint = Some("c".repeat(64));
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Retire
);
endpoint.executable_fingerprint = Some(current_fingerprint.clone());
endpoint.protocol_version += 1;
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Retire
);
}
#[test]
fn framing_round_trips_length_prefixed_json() {
let payload = br#"{"method":"runner.ping","requestId":"request-1"}"#;
@@ -4463,7 +4623,14 @@ mod tests {
write_external_agent_runner_endpoint_atomic(&endpoint_path, &stale)
.expect("write stale endpoint");
assert!(read_current_external_agent_runner_endpoint(&endpoint_path).is_none());
assert!(read_current_external_agent_runner_endpoint(
&endpoint_path,
stale
.executable_fingerprint
.as_deref()
.expect("test fingerprint"),
)
.is_none());
let boot_id = "current-lock-owner";
let lock = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&directory.0),
@@ -2107,6 +2107,7 @@ mod tests {
task_queue.pending = pending;
AgentRuntimeResult {
state,
accepted_run_id: None,
session_path: String::new(),
event_path: String::new(),
task_path: String::new(),
File diff suppressed because it is too large Load Diff
@@ -16,14 +16,14 @@
"label": "client",
"title": "AI 游戏创作",
"url": "index.html",
"width": 820,
"height": 640,
"minWidth": 720,
"minHeight": 520
"width": 1280,
"height": 800,
"minWidth": 1280,
"minHeight": 800
}
],
"security": {
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'",
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -92,7 +92,9 @@ describe('AI 游戏创作聊天记忆命令', () => {
'/tmp/game',
);
expect(resolveChatProjectPath({ projectPath: 'relative-game' })).toBeNull();
expect(resolveChatProjectPath({ projectPath: '/tmp/bad\u0007game' })).toBeNull();
expect(
resolveChatProjectPath({ projectPath: '/tmp/bad\u0007game' }),
).toBeNull();
expect(parseRememberInput('long')).toEqual({
scope: 'long',
content: '',
@@ -131,13 +133,13 @@ describe('AI 游戏创作聊天记忆命令', () => {
'/tmp/game',
),
).toBe(
'调用 LLM Planner / Generator,编排 6 组角色 brief,写入 /tmp/game/game、assets、memory、exports,通过 Evaluator 和自检后启动本地 HTTP 预览并交给外部浏览器',
'调用 LLM Planner / Generator,编排 6 组角色 brief,写入 /tmp/game/game、assets、memory、exports,通过 Evaluator 和自检后启动本地 HTTP 预览并载入客户端运行视图',
);
});
it('describes preview side effects before confirmation', () => {
expect(pendingCommandDetail({ id: 'preview.start' }, '/tmp/game')).toBe(
'启动 /tmp/game/game/ 并交给外部浏览器',
'启动 /tmp/game/game/ 并载入客户端运行视图',
);
expect(pendingCommandDetail({ id: 'preview.open' }, '/tmp/game')).toBe(
'打开 /tmp/game 的当前本地预览',
+1
View File
@@ -4,6 +4,7 @@
## 快速入口
- [AI 游戏创作项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):四区工作台、资源/运行状态机、不可变迭代版本、双布局坐标、测试切片、数值微调、六专业组与审批分期合同。
- [Agent 工作入口与执行准则](./%E3%80%90%E5%8D%8F%E4%BD%9C%E8%A7%84%E8%8C%83%E3%80%91Agent%E5%B7%A5%E4%BD%9C%E5%85%A5%E5%8F%A3%E4%B8%8E%E6%89%A7%E8%A1%8C%E5%87%86%E5%88%99-2026-06-22.md):复杂任务前的 Agent 阅读顺序、执行边界、技能路由、文档规则和验证口径。
- [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md):首页基础 head、robots/sitemap、唯一 H1、精确 SPA 路由与未知路径 404 的长期技术边界。
- [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验。
@@ -0,0 +1,304 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-07-20`
## 1. 产品定位
项目开发工作台是 AI 游戏创作独立客户端中承接“做游戏”后的唯一项目级工作区,不是单独的新页面,也不新建第二套项目、资产、Agent 或预览系统。
工作台固定由四个区域组成:
1. 左侧平台导航。
2. 中央主视窗。
3. 右侧 Project Supervisor 对话与确认区。
4. 底部专业 Agent 状态栏。
中央主视窗在“资源管理”和“运行测试”之间切换。正式预览始终在客户端当前窗口内展开,只允许载入当前项目启动的 `127.0.0.1:<port>` 本地 HTTP 预览,不调用系统外部浏览器。
## 2. 创作工具平台接入声明
- 工作台模式:对话式 Project Supervisor 项目工作台,属于 Agent 原生创作例外。
- 例外原因:该工作台负责跨策划、美术、程序、数值、音频和发布专业组的持续协作,结构化表单不能覆盖多轮项目开发与确认恢复。
- 复用边界:图片、音频、上传、素材库、画板和外部生成继续复用现有平台能力,不在工作台内新建平行资产系统。
- 创作链路:做游戏入口 -> 本地项目工作台 -> 资源/运行迭代 -> 导出或后续发布链路。
- 业务真相:项目 manifest、Agent Runtime、持久对话、本地预览状态和后端计费投影;前端只保存短生命周期展示态。
- 当前切片不新增玩法 `playId`、公开作品 read model、发布路由或 SpacetimeDB schema。
## 3. 已确认产品决策
### 3.1 预览与窗口
- 游戏预览直接在当前客户端窗口内展开。
- 客户端仅交付横屏,默认与最小窗口均为 `1280×800`
- 右侧 Supervisor 和底部 Agent 状态栏常驻;窗口不得缩小到破坏该结构。
- 浏览器窄屏样式只作为开发兼容,不属于本版本产品合同。
### 3.2 版本与资源替换
- 可运行版本不可变。
- 替换版本引用资源时创建下一迭代版本,不原地修改既有版本。
- 新版本必须记录 `parentVersionId`、替换前后资源身份和创建原因。
- 当前运行中的版本不消费尚未生成的新版本变更。
### 3.3 资源布局
- “按依赖”和“按类型”分别保存画布位置。
- 切换布局模式后恢复该模式最后一次用户手动拖动结果。
- 新资源首次进入某个布局时才执行默认不重叠排版;已有坐标不得被自动排序覆盖。
- 依赖布局使用资源生成/引用关系;类型布局按资源大类、子类型、尺寸规格排序。
- 不同资源分区不可互相拖入。
### 3.4 数值微调
- 数值修改立即写入当前项目的编辑态配置。
- 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。
- 自然语言新增数值项只能映射到预定义参数注册表,不允许生成或修改代码。
### 3.5 专业 Agent
现有六个专业组为:
| group | 普通用户名称 | 当前职责 |
| --- | --- | --- |
| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 |
| `art` | 美术组 | 角色、场景、UI、动画和美术素材 |
| `code` | 程序组 | 可运行原型、模块实现和工程验证 |
| `balance` | 数值组 | 速度、生命、得分和难度参数 |
| `audio` | 音频组 | 背景音乐、音效和音频资源 |
| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 |
- 底栏默认突出策划、美术、程序三组。
- 允许在同一底栏展开数值、音频、发布组,不删除既有专业组。
- 状态、当前任务和完成进度来自真实 manifest/Runtime。
- 泥点消耗必须来自后端计费归因投影;无数据时显示“未统计”,不得用前端估算。
### 3.6 审批与扩展能力
- 默认档位为严格审批。
- P0 只有严格审批是有效运行合同。
- 高风险审批依赖未确定的 Rank 算法,作为低优先级待解决事项。
- 无需审批只有在 Runtime、计费、副作用、sandbox 和 reconciliation 均支持对应策略后才能开放。
- 未开放选项使用“视觉不可用但可点击说明原因”,不使用无法触发说明的原生 `disabled`
- 普通用户暂不开放 Agent.md 编辑和自定义 Skill 安装;后续必须先定义来源审核、版本、权限、沙箱和回滚合同。
## 4. 工作台状态机
### 4.1 主视窗
```text
resources
-> run(存在 runnableVersion 且 loopback preview 可启动)
run.playing
-> run.paused(用户暂停或切片结束)
-> resources(先暂停当前预览表现,再切换视图)
run.paused
-> run.playing(继续当前切片)
-> run.relaunching(数值或版本编辑态发生变化)
-> resources
```
运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。
### 4.2 测试切片
```text
idle -> starting -> playing -> paused -> completed
| |
+-> failed +-> playing
completed -> starting(nextSlice)
```
- 单个切片完成后自动进入 `paused`
- 上一项/下一项会停止当前切片并启动目标切片。
- 数值编辑态变化后,当前切片标记 `stale`;重新拉起前不消费新值。
### 4.3 资源聚焦
```text
idle -> focused(document|art|audio|version) -> idle
```
- 文档:在中央画布展开并独立滚动。
- 美术/音频:进入对应媒体聚焦状态,工具能力复用现有编辑器。
- 版本:高亮版本引用资源;替换动作只创建下一迭代版本。
### 4.4 历史成果与当前状态
- “当前工作状态”只展示当前 Supervisor run 下的专业 Runtime。
- “项目已有成果”按项目持久保存,不随 Supervisor run 切换而清空。
- Agent 文本成果只认可带合法 `agent-finalization-<32 lower hex>` messageId 的 assistant 消息。
- 新 run 失败、待确认或未完成时继续展示最近一次成功成果;新的成功 finalization 才替换同 Agent 的旧成果。
- 文本回执不得冒充图片、音频、项目文件或 manifest asset。
## 5. 数据合同
以下合同先冻结字段语义;P0 只实现标注为 P0 的部分。
### 5.1 工作台视图状态(P0)
```ts
type ProjectWorkbenchViewState = {
schemaVersion: 'game-creator-workbench-view.v1';
projectId: string;
mode: 'resources' | 'run';
approvalMode: 'strict' | 'risk' | 'none';
expandedAgentGroups: Array<'balance' | 'audio' | 'publishing'>;
};
```
P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可用选项展示。
### 5.2 资源画布布局(P1
```ts
type ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1';
projectId: string;
mode: 'dependency' | 'type';
revision: number;
positions: Array<{
resourceId: string;
section: 'document' | 'version' | 'art' | 'audio';
x: number;
y: number;
manuallyPlaced: boolean;
}>;
updatedAt: number;
};
```
两个 mode 是两份独立坐标集合;服务端或本地项目持久层以 `projectId + mode` 做 CAS 更新。
### 5.3 资源类型与替换兼容性(P1)
```ts
type ProjectResourceDescriptor = {
resourceId: string;
category: 'document' | 'version' | 'art' | 'audio';
subtype: string;
width?: number;
height?: number;
durationMs?: number;
format: string;
};
type ProjectVersionResourceReplacement = {
sourceVersionId: string;
sourceResourceId: string;
replacementResourceId: string;
compatibility: {
categoryEqual: boolean;
subtypeEqual: boolean;
sizeSpecEqual: boolean;
};
};
```
三项兼容性必须同时为 true 才能创建下一版本。
### 5.4 游戏迭代版本(P1
```ts
type GameIterationVersion = {
versionId: string;
parentVersionId: string | null;
projectRevision: number;
resourceBindings: Array<{ slotId: string; resourceId: string }>;
parameterSnapshotId: string;
createdReason: 'initial' | 'resource-replacement' | 'agent-revision';
createdAt: number;
};
```
版本写入后不可修改。
### 5.5 测试切片与数值参数(P2)
```ts
type GameTestSlice = {
sliceId: string;
versionId: string;
title: string;
order: number;
startCondition: string;
endCondition: string;
status: 'idle' | 'starting' | 'playing' | 'paused' | 'completed' | 'failed';
};
type GameTunableParameterDefinition = {
parameterId: string;
label: string;
valueType: 'integer' | 'number' | 'boolean' | 'enum';
min?: number;
max?: number;
step?: number;
enumValues?: string[];
writePath: string;
codeMutationAllowed: false;
};
```
参数写入立即增加编辑态 revision;当前 preview/slice 保持旧 revision,并显示“需要重新拉起”。
### 5.6 Agent 泥点归因(P2
```ts
type ProjectAgentMudPointAttribution = {
projectId: string;
agentGroup: 'design' | 'art' | 'code' | 'balance' | 'audio' | 'publishing';
chargedMudPoints: number;
refundedMudPoints: number;
netMudPoints: number;
asOf: number;
};
```
该投影只能由后端账本聚合产生。
## 6. 分阶段范围
### P0:当前实施切片
- 复用现有四区工作台壳。
- 资源/运行切换与客户端内 loopback 预览。
- 只读资源画布、文档展开、美术/音频聚焦入口。
- Supervisor 正式会话、上传、Runtime 确认与安全错误。
- 当前 run 专业状态与项目历史成果分离。
- 默认三专业组,并可展开另外三组。
- 严格审批有效;风险/无需审批可点击查看未开放原因。
- 橙色低保真视觉与 `1280×800` 横屏边界。
### P1
- 依赖/类型两套坐标持久化。
- 资源关系线与首次自动布局。
- 版本资源高亮、兼容性判断和不可变下一迭代版本。
- 美术/音频编辑状态接线。
### P2
- 测试切片正式协议与恢复。
- 参数注册表、立即写编辑态和预览重新拉起。
- 泥点归因 read model。
- Agent.md/Skill 安全合同。
- 高风险审批 Rank 与无需审批运行合同。
## 7. P0 验收
1. `1280×800` 下页面无横向或纵向溢出,输入框与 Agent Dock 始终可见。
2. 运行入口不可用时点击给出原因;可用时只在客户端内打开 loopback 预览。
3. 当前 Supervisor run 变化后,旧成功 Agent 文本成果仍可查看;普通失败 assistant 不进入资源区。
4. 底栏默认显示策划、美术、程序,可展开数值、音频、发布;状态与任务来自真实 Runtime/manifest。
5. 风险审批和无需审批不能改变运行策略,点击后明确提示尚未开放;严格审批继续使用现有 Runtime 门禁。
6. 不显示伪造泥点、伪造资源完成度、伪造图片或外部浏览器成功提示。
## 8. 非目标
- 本切片不实现 P1/P2 持久合同。
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
- 不自动确认 Agent 动作,不自动触发可能扣费的生成。
- 不把当前项目工作台推广为其它玩法的默认创作模式。
@@ -19,9 +19,10 @@
## 2026-07-18 AI 游戏创作正式项目页升级为 GameAgent 工作台
- 背景:新的《陶泥儿GameAgent-V1.0 项目开发界面需求》要求正式项目开发页同时承载资源管理、运行表现层、陶泥儿对话和子 Agent 状态,旧的“正式用户页只有主聊天与只读专业 Agent 列表”已不足以支撑目标交互。
- 决策:在现有 `apps/ai-game-creator-shell` 项目开发入口内扩展单一工作台,不新建平行客户端。首版从当前 manifest、导入附件和 Agent 状态派生界面,提供资源 / 运行切换、资源排列与聚焦、审批弹层和底部状态栏;真实游戏通过现有 localhost 预览命令交给外部浏览器,不嵌入 iframe。未具备正式写回契约的拖拽布局、版本资源替换、数值微调、泥点累计、Agent.md 和 Skill 管理不得在前端伪造成功。
- 决策:在现有 `apps/ai-game-creator-shell` 项目开发入口内扩展单一工作台,不新建平行客户端。首版从当前 manifest、导入附件和 Agent 状态派生界面,提供资源 / 运行切换、资源排列与聚焦、审批弹层和底部状态栏;真实游戏通过现有 localhost 预览 server 直接载入客户端内受限运行容器,不再调用系统外部浏览器。未具备正式写回契约的拖拽布局、版本资源替换、数值微调、泥点累计、Agent.md 和 Skill 管理不得在前端伪造成功。
- 横屏窗口:当前独立 App 只交付横屏桌面工作台,`client` 默认与最小窗口固定为 `1280×800`。工作台按壳内剩余视口排布并收紧四周留白;消息区与 Runtime 区各自承担内部滚动,专业状态增长不得把输入区或底部 Agent 栏推到视口外。窄屏纵向布局不作为当前客户端验收目标。
- 影响范围:`apps/ai-game-creator-shell` 正式项目开发页、项目工作台前端测试、AI 游戏创作智能体 App 实施计划和原生壳预览门禁。
- 验证方式:运行 AI game creator shell 定向测试与 typecheck、`npm run ai-game-creator-shell:check``npm run check:encoding``git diff --check`,并用真实浏览器检查桌面与窄屏布局。
- 验证方式:运行 AI game creator shell 定向测试与 typecheck、`npm run ai-game-creator-shell:check``npm run check:encoding``git diff --check`,并用真实浏览器检查 `1280×800` 最小横屏与目标桌面视口布局。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-17 AI 游戏创作 V1.31 使用同一父 run 收束静态与隔离协作
@@ -261,6 +262,7 @@
- 补充:项目组页在同一窗口管理最近项目、打开项目、新建项目和显示目录。打开项目只进入已初始化且 `.agent/manifest.json` 可读的本地项目并切到项目开发占位;无效项目禁用,不自动重建历史路径。首页最近项目最多展示 3 个,空时隐藏。
- 补充:项目开发占位展示项目名、路径、创建模式、首条需求、附件导入结果、最近 run 状态和后续“项目开发画布”占位;“项目开发画布”是 GameAgent 项目的工作区概念,不等同于 `/editor` 图片画布工程。
- 补充:首页账户 / 泥点 / 精选素材只读取平台真实接口 `/api/profile/dashboard``/api/profile/wallet-ledger``/api/editor/showcase/resources`;失败时显示轻量空态,不伪造后端未下发字段。
- 补充:2026-07-18 修复首页“开启创作”打开目录选择器时的客户端冻结。目录和文件 picker 统一使用非阻塞 callback,并绑定到当前 `client` 窗口;不得把 `blocking_pick_folder` / `blocking_pick_file` 放回同步 Tauri command。首页直接创建项目仍是普通用户主路径,不要求先进入项目组。
- 影响范围:`apps/ai-game-creator-shell` 登录后渲染入口、首页 / 项目组 / 项目开发占位 UI、本地项目初始化与附件导入流程、AI 游戏创作 App 实施计划和 `CONTEXT.md`
- 验证方式:运行 `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts``npm --prefix apps/ai-game-creator-shell run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``CONTEXT.md`
@@ -4201,7 +4203,7 @@
- 2026-06-25 调整:`npm run ai-game-creator-shell:dev` 固定加载 `http://127.0.0.1:3080/`Vite 继续 `strictPort` 与 Tauri `devUrl` 对齐。`beforeDevCommand` 改为先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,避免上次 Tauri 退出后遗留的同 app Vite 进程导致二次启动失败;如果 3080 是其它服务,仍直接失败并要求释放端口,不做端口漂移。
- 2026-06-25 调整:`preview.start` / `preview.stop` 必须追加 `.agent/logs/preview.log`,并把该日志列入 Preview trace step 的输出路径和 artifact 清单;这样 `preview-playtest` 任务声明的日志产物与实际本地 HTTP 预览行为一致。
- 2026-06-25 调整:AI 游戏创作 App v1 仍只维护一个全局本地 HTTP 预览实例;启动新项目预览替换旧预览时,必须 best-effort 把旧项目的 manifest preview 状态、`.agent/logs/preview.log` 和 run trace 记录为 stopped,避免旧项目状态残留 `running`。旧项目目录已删除时不阻断新预览启动。
- 2026-06-25 调整:正式用户 App 不承载游戏预览画面release CSP 允许 `frame-src http://127.0.0.1:*`只有开发窗口 / dev CSP 可以嵌入本地预览 iframe。`/preview``/run` 和生成完成后的用户侧路径启动 `127.0.0.1` HTTP preview 并通过 `open_local_game_preview` 交给系统外部浏览器。
- 2026-06-25 调整2026-07-18 替代:正式用户 App 的项目运行工作台承载当前授权项目的本地游戏预览,release / dev CSP 都只允许 `frame-src http://127.0.0.1:*``/preview``/run` 和生成完成后的用户侧路径启动 `127.0.0.1` HTTP preview 后直接切换客户端运行视图,不再调用系统外部浏览器。
- 2026-06-25 调整:`project.create` 成功后的 durable 权限证据必须在聊天 `/project` 和开发窗口初始化两条入口统一写入 `.agent/logs/command.log`,避免同一能力因为入口不同导致 `/audit` 或开发排障证据不一致。
- 2026-06-25 调整:`.agent/run.latest.json``.agent/runs/<runId>.json` 必须记录 loop 的 `maxPasses``stopReason`,开发窗口直接展示该状态,避免只从 summary 文案推断 loop 是否跑满、通过、返工、写入产物或进入预览。本地 HTTP 预览的 `/` 映射到 `game/index.html`,路径解析必须 canonicalize 项目根目录和目标文件,只允许访问项目内 `game/``assets/`,拒绝 `memory/``.agent/``exports/``..`、反斜杠和符号链接越界;常见图片、音频、视频和 Web 资源必须返回对应 MIME。这样上传和画板回流资产能被生成游戏引用,但记忆、trace 和导出包不会被预览服务暴露。
- 2026-06-26 调整,2026-07-03 更新:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 `/agent-status``/agent-kill``/agent-retry``/agent-resume [说明]` 控制本地生命周期,写入 `.agent/activity.jsonl``.agent/output.jsonl``.agent/context.bundle.json`;聊天里的状态 / 控制结果可填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出,但不直接读取文件或绕过 `file.read` 策略。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。
@@ -4326,10 +4328,10 @@
- 2026-06-24 调整,2026-06-30 更新:终端测试入口使用同一个 Tauri Rust 二进制的 `--agent-run <本地项目绝对路径> <创作需求>`,只复用现有 `game.generate_draft``game.static_smoke` 和本地 HTTP 预览链路,不另建第二套 agent runtime;发布 App 的 LLM 配置从 Tauri 应用配置目录读取,不写入仓库默认配置或项目文件。需要自动验证时可追加 `--no-wait`,生成预览 trace 后立即停止本地预览,避免命令卡在回车等待。
- 2026-07-04 调整,2026-07-08 更新:`apps/ai-game-creator-shell/src-tauri/src/main.rs` 拆成薄入口,继续只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;CLI 参数解析与终端运行输出放入 `cli.rs`Tauri command 包装放入 `commands.rs`,运行时配置 / LLM 配置检查放入 `config.rs`Agent loop 与生成编排放入 `agent.rs`,上传 / 画板 / 平台美术生成接入放入 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放入 `project.rs`,本地 HTTP 预览 server、preview registry 和 preview Tauri command 放入 `preview.rs`,旧窗口 URL 与兼容 command 放入 `windows.rs`Rust 单测放入 `tests.rs`。拆分不得改变 Tauri command 名、JSON 字段、`.agent/*` 路径、项目权限策略或错误语义。
- 2026-06-24 调整,2026-07-08 更新:AI 游戏创作 App 的 release 配置只登记一个普通用户窗口,登录后在同一 WebView 中进入首页、项目组和项目开发占位;开发专用单 Agent 对话、任务、文件、记忆、预览、日志和能力面板只能通过 Vite dev 的 `?dev/#dev` 分支或 debug 构建自动打开的 `developer` 开发窗口查看,不进入普通用户窗口。旧工作区窗口切换 command 只保留兼容,用户主流程不得调用它。
- 2026-06-24 调整,2026-07-08 更新:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留一个普通用户窗口,用户侧预览只交给系统外部浏览器,开发面板只能在 `devMode` 分支或 debug-only `developer` 窗口渲染,`developer` 窗口当前使用 `index.html?agent-chat` 并复用 `.agent/conversations/agents/<agentId>.jsonl` 持久化单 Agent 对话;发布入口和普通用户窗口不得暴露 `Agent 聊天` 导航,也不得调用旧工作区窗口切换 command。
- 2026-06-24 调整,2026-07-18 更新:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留一个普通用户窗口,用户侧预览只在项目运行工作台嵌入当前 `127.0.0.1` 游戏,且 Tauri 激活命令不得调用 opener开发面板只能在 `devMode` 分支或 debug-only `developer` 窗口渲染,`developer` 窗口当前使用 `index.html?agent-chat` 并复用 `.agent/conversations/agents/<agentId>.jsonl` 持久化单 Agent 对话;发布入口和普通用户窗口不得暴露 `Agent 聊天` 导航,也不得调用旧工作区窗口切换 command。
- 2026-07-10 调整:AI 游戏创作 App 的 Runtime 实时状态依赖 Tauri event listen。`src-tauri/capabilities/events.json` 必须覆盖 `client``developer``main``launcher`,只授予 `core:event:allow-listen``core:event:allow-unlisten`,不得向前端授予 emit`check-config.mjs` 静态守住窗口和权限边界。Vite 开发服务器必须把仓库根目录加入 `server.fs.allow`,因为 App 直接加载 `packages/shared/src`;否则真实 WebView 会因共享源码 403 白屏,即使 TypeScript 检查仍通过。
- 2026-06-25 调整:`check:native-shells``ai-game-creator-shell:check` 之后必须追加 `ai-game-creator-shell:build -- --no-bundle`,让原生壳总门禁同时证明 AI 游戏创作独立 Tauri 壳能完成 release 编译,而不是只证明前端 / Rust 逻辑测试通过。
- 2026-06-24 调整:普通用户通过聊天输入 `/preview` 触发待确认 `preview.start`,完成 `/project` 初始化后可通过 `/open-preview` 触发待确认 `preview.open` 并只打开当前已授权项目对应的 `127.0.0.1` 本地预览,通过 `/preview-status` 查询当前项目预览,通过 `/preview-stop` 停止当前项目预览;预览 iframe 和状态面板仍只在开发窗口可见,不能把 `preview.open` 扩展成任意 URL 打开能力,也不能打开、展示或停止其它本地项目遗留的全局预览。
- 2026-06-24 调整2026-07-18 更新:普通用户通过聊天输入 `/preview` 触发待确认 `preview.start`,完成 `/project` 初始化后可通过 `/open-preview` 触发待确认 `preview.open` 并只激活当前已授权项目对应的 `127.0.0.1` 客户端运行视图,通过 `/preview-status` 查询当前项目预览,通过 `/preview-stop` 停止当前项目预览;用户工作台仅嵌入当前项目的 loopback 游戏,开发预览状态面板仍只在开发窗口可见,不能把 `preview.open` 扩展成任意 URL 打开能力,也不能展示或停止其它本地项目遗留的全局预览。
- 2026-06-25 调整:`/preview-status` 虽然是只读命令,也必须写入 `preview.status` 命令日志并向聊天返回错误,不得因查询失败产生未捕获异常或无审计记录。
- 2026-06-24 调整:普通用户通过聊天输入 `/memory [short]` 读取长期或短期记忆,通过 `/remember 内容` 待确认追加长期记忆,通过 `/forget-memory [short]` 待确认删除记忆;不得为了记忆查看或编辑暴露独立用户面板。
- 2026-06-25 调整:`/remember` 支持可选 scope`/remember short 内容` 追加短期记忆,`/remember long 内容` 或未写 scope 时追加长期记忆;仍统一走待确认 `memory.write`,不暴露独立用户面板。
@@ -4881,3 +4883,60 @@
- Scope 边界:只读 isolated task 也必须声明 expected artifact 的最小目录 scope,不得扩大到 sibling scope 或共同父目录;该约束写入通用边界提示,不为单个验收任务硬编码。
- 定向验收:`supervisor_collaboration_policy_` 23/23、`project_supervisor_` 47/47 通过,E2E self-test **PASS**Tauri/Rust 全量为 930 passed、4 个环境依赖用例按设计 ignored。
- 真实 Provider:第一次独立运行因模型初始 child scope 不符合 expected artifact 最小边界而 **FAIL**child / claim / project mutation 均为 `0` 且现场自动清理,不与后续证据拼接。补强通用边界提示后的第二次独立运行 **PASS**policy=`2`2 个 group / 3 个 child1 个 `observed` join claim 覆盖 2 个 groupRunner 强杀恢复身份稳定,Provider lifecycle `64/64` completed、failed=`0`,重复、残留、泄漏均为 `0`,最终回复唯一。
## 2026-07-18 AI 游戏创作项目工作台 Runtime 状态投影
- 事实源:正式项目工作台的总控与策划 / 美术 / 程序 Agent 状态必须来自当前 Supervisor 父 run 的真实 Runtime。专业 Agent 只有在 `parentRunId` 精确匹配该父 run 时才可进入当前列表;`manifest.tasks` 仅在没有匹配 Runtime 时回退,不得覆盖真实状态。
- 刷新与恢复:普通项目页在 Tauri event 之外保留只读 Runtime 轮询,兜底独立 Runner 缺失 App event 的情况。短暂读取失败保留最后可信快照,不清空、不倒退已知状态。
- 用户面投影:只展示真实运行阶段、计划完成数 / 总数、最近更新时间、失败、待确认和待回答等紧凑状态。专业 Agent 的确认与拒绝必须精确绑定 `agentId + runId + actionId`,不得只依赖卡片顺序或 Agent 类型。
- 隐私与真实性:正式面不展示内部 `currentAction``observation`、工具计划正文、Provider 错误原文、fingerprint 或字符计数;transport / timeout / 鉴权 / 限流等失败只映射为安全文案,不得从 manifest、动画或前端计时器伪造生产中、进度百分比或完成状态。当前父 run 或专业状态集合变化时状态区回到顶部,总控摘要在内部滚动期间保持可见。
## 2026-07-19 AI 游戏创作 Runner 构建身份与 LLM Rustls 传输
- 背景:正式 release GUI 曾按相同 protocol + ping 继续复用更早启动的 debug Runner;真实专业 Agent 请求又在 native-tls/OpenSSL 链路间歇出现 TLS record bad-MAC。只做 UI 脱敏会掩盖真实失败,单次重启也不能消除后续瞬态抖动。
- Runner 身份:AppData endpoint 增加当前 executable 内容 SHA-256。只有协议、可执行文件身份和 ping 都匹配时才能复用;旧 endpoint 缺身份、debug/release 不同或构建内容变化都视为待退役。退役必须复用 `shutdown_if_idle`,busy 时明确阻止切换,不强杀活任务;公共错误不输出 executable 路径、fingerprint 或 token。
- Provider 重试:新建 Runtime 配置默认 `maxRetries=2 / retryBackoffMs=500`。既有显式配置保持用户选择;现场正式配置已从 `0` 调整为 `2`。只有 `timeout / connectivity / transport` 使用既有独立物理 lifecycle 和有界指数退避,其他上游、协议、配置与副作用错误不重试;显式设置 `0` 继续表示关闭重试。
- LLM TLSnative-tls 在 500ms / 1000ms 两次退避后仍连续三次命中同一 TLS record bad-MAC,证明重试只能兜底。`platform-llm` 的 LLM 专用 `reqwest` 改为 Rustls 并显式选择 Rustls backendMCP 等其他 HTTP 客户端保持原传输栈,避免扩大变更面。该变更消除了已观测的旧 OpenSSL bad-MAC 路径,但新 Rustls raw log 仍可出现 `connection error: cannot decrypt peer's message`,不得据此宣称 TLS/transport 根因已彻底关闭。
- 生命周期控制:构建切换时有在途 Provider 请求会按安全协议进入 `needs-reconciliation`。CLI 提供精确 `agentId + runId` 的取消和显式新 runId 重试;取消只要求已配置的项目外 AppData,以免“旧 Runner busy 阻止新二进制,而取消又要求新 Runner”形成闭环,重试和其他写命令仍要求当前构建 Runner。
- 现场验证:Provider 鉴权和配置模型可用,短/长认证 Chat Completions 均成功;旧 run 被显式取消后,新 endpoint 的 executable fingerprint 与 release 二进制 SHA-256 一致。Rustls Runner 下 `design-foundation` 完成并产生最终回复,但后续 `code-prototype` 仍在三次尝试后因 `connection error: cannot decrypt peer's message` 失败。这证明 Runner 身份修复和 Rustls 切换有效缩小了问题面,但未完成 TLS 根因验收;普通 UI 继续只显示安全状态。
## 2026-07-19 AI 游戏创作工作台专业 Agent 恢复与成果回执
- 失败恢复:当前 Supervisor 父 run 下的专业 Agent 失败时,正式工作台提供“在当前项目重试”,不要求新建项目。入口必须精确核对原 `agentId + runId + parentRunId`,复用原 task、active Session 和父 run 归属,并为重试生成新 runId;原失败 run 保留为历史审计事实。
- 能力边界:UI 重试是恢复入口,不是 Provider/TLS 根因修复。新 run 仍必须按真实 Runtime 结果展示 running、failed 或 completed,不得因点击重试而伪造成功或丢失旧失败证据。
- 成果真相:专业 Agent 曾完成但没有文件产物时,“没有文件产物”不等于“没有成果”。工作台必须读取该 Agent 持久对话中最新一条带合法 `agent-finalization-<32 lower hex>` messageId 的 assistant,以明确标注的“专业 Agent 文本回执”展示;普通失败 assistant 不得覆盖既有成果。
- 资源投影:上述回执同步投影到“资源管理 → 文档”,保留来源 Agent 和 run 身份。它是持久回执的可见视图,不得冒充 manifest asset、项目目录中的实际文件或可下载交付物。
- 重试确认:`agent.resume` 默认仍为 `confirm`。普通自动 retry command 保留 auto gate;正式失败卡的“在当前项目重试”按钮本身视为本次明确确认,调用单独的 confirmed retry command,但仍不得绕过 deny。点击后必须在原卡即时显示提交中、成功或安全错误,不能把错误放到专业列表末尾。若总控已为同一 delegation 准备精确 repair,按钮优先确认该 repair,不再创建重复的无合同重试。
- 回执命名:无文件的 completed 结果统一称“专业 Agent 文本回执”,不得称“美术产物”或直接暴露 `design-foundation / art-asset-plan / balance-seed` 等内部 ID。美术任务只完成计划且 manifest 没有图片时,普通界面明确显示“仅完成计划,尚未生成或登记图片”。
- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;资源卡支持同分类、当前会话内的真实拖拽重排,不宣称持久保存。资源详情使用独立可拖动浮层,位置约束在工作台与 viewport 内并避开底部 Agent dock,长正文独立滚动。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。
## 2026-07-20 AI 游戏创作策划与美术图片交付门禁
- 问题:`design-foundation``art-asset-plan` 的旧 seed / 委派合同允许空 `expectedArtifacts`,因此专业 Agent 只提交策划或美术计划文本也会进入 `evidence-ready / completed`;真实项目没有界面原型图或美术图片。
- canonical 合同:策划必须交付 `assets/ui-prototype.png`(16:9 横屏界面原型),美术必须交付 `assets/art-spritesheet.png`(首版核心美术素材)。Supervisor 发起这两类新委派时,`expectedArtifacts` 必须包含对应确定路径;普通只读委派仍允许空产物。
- 完成门禁:Runtime 只在图片文件存在、manifest 中存在同路径 `image/*` 项、来源为 `canvas` 且 kind 分别为 `ui-prototype / art-spritesheet` 时允许专业 Agent 完成。`task.update completed` 使用同一门禁;旧 manifest 即使保留资产登记,只要真实图片已丢失也把 completed 降回 pending。缺 Key、待确认、生成失败、只有文本或只有未登记文件时保持明确阻塞,不得伪造 completed。
- 外部与本地一致性:`canvas.asset_generate` 生成前创建或复用与本地项目同名的 External Editor 画布项目和素材库目录;生成请求必须携带 `projectId + assetFolderId + canvasCompletion`,使结果同时进入画布与素材库,再下载到确定本地路径并登记 manifest。canonical 策划 / 美术 Agent 不允许覆盖固定路径、比例、尺寸、kind 或展示名,避免真实扣费后生成无法通过完成门禁的旁路图片。路径限定为项目 `assets/` 下 png/jpg/jpeg/webp,拒绝父目录、绝对路径、符号链接与静默覆盖;登记失败时删除本轮新文件。
- 历史恢复:旧 delivery 合同不可被 repair 扩大。已有项目缺图时创建新的独立补图委派并保持 `repairOf=null`;不得修改历史 delivery,也不得要求用户新建项目。
## 2026-07-20 AI 游戏创作总控失败恢复边界
- 正式工作台的项目总控进入 `failed` 后必须在失败摘要内提供“在当前项目重试总控”,并明确不会新建项目;提交中、成功和安全错误反馈固定在同一区域。旧总控下仍在运行的专业 Agent 继续按真实 Runtime 轮询和展示,不得因为父 run 终态就被前端隐藏或误报为已停止。
- 总控失败后的恢复事实是创建新的总控 run,由新总控重新建立专业委派合同。专业 Agent 的原委派父 run 已为 terminal 时,前端不得继续提供单独重试,Runtime 的 confirmed retry 也必须在创建新 task、delegationId 或 delivery 前拒绝,避免产生无法向父总控交付的孤立重试。
- 当前父 run 仍活跃时保留既有专业 Agent 恢复入口;无 parent 的普通后台任务仍按原 retry 契约运行。本门禁不取消或重放父总控失败时仍在途的专业 Agent 副作用。
## 2026-07-20 Agent Runtime 重试受理与同源幂等
- 外部 Runner 入队后返回的 Session Runtime 可能仍是旧 run 或当前 active run,不能把 `state.runId` 当作本次重试是否受理的确认。重试结果新增可选 `acceptedRunId`;新入队返回实际 runId,已有同源 successor 时返回被复用的 runId,普通 Runtime 读取不携带该字段。
- 同一 `agentId + sourceRunId` 的 retry 使用跨进程锁串行受理,并从持久 `agent.runtime.background_task.retry` 审计解析 successor。存在 pending、running、waiting-for-confirmation 或 waiting-for-user-input successor 时直接复用,不创建第二个 task、用户消息或 retry audit;审计扫描被容量上限截断时失败关闭,不猜测幂等状态。
- 前端收到 `acceptedRunId` 后立即显示“重试已受理”并禁用按钮,继续监听和轮询精确 successor;响应快照仍为旧 failed run 不得误报失败,也不得让用户重复点击。只有真实同步到 successor 后才切换总控状态。
## 2026-07-20 AI 游戏创作项目开发工作台分期合同
- 正式产品合同统一进入 `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。工作台继续复用现有项目页、Supervisor、Runtime、manifest、画板和本地 preview,不新建平行项目或资产系统;Agent 对话式工作台作为创作工具平台例外被显式记录。
- 正式预览只在客户端当前窗口载入受限 loopback URL。可运行版本不可变,资源替换创建下一迭代版本;依赖/类型两套布局分别持久化坐标并只在新资源首次进入时自动排版;类型兼容按大类、子类型、尺寸规格共同判断。
- 数值微调立即写编辑态 revision,已拉起 preview 与测试切片继续使用旧 revision,重新拉起后才消费新值。自然语言新增参数只能绑定预定义注册表,禁止修改代码。
- 六专业组固定为 `design / art / code / balance / audio / publishing`。底栏默认突出策划、美术、程序,可展开数值、音频、发布;泥点只能展示后端账本归因投影,无数据不估算。
- P0 只开放严格审批。高风险审批 Rank 进入 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`;风险/无需审批使用视觉不可用但可点击说明原因,不能静默改变 Runtime 策略。Agent.md 与自定义 Skill 在来源审核、版本、权限、sandbox 和回滚合同完成前不向普通用户开放。
- 当前 run 状态与项目历史成果是两个投影:前者继续按当前 `parentRunId` 过滤,后者只从专业 Agent 持久对话中合法 `agent-finalization-<32 lower hex>` assistant 恢复。新 run 失败或待确认不清除旧成果,普通失败 assistant 不得进入资源管理。
- 历史成果读取采用项目内单调合并:新的合法 finalization 可以替换同 Agent 的旧回执,但 Runtime 轮询引发的持久对话瞬时读取失败、空结果或新 run 普通失败消息都不得清空已恢复成果。资源卡必须明确标记“历史成果”,继续与 manifest 正式资产和项目文件区分。
- Tauri `read_local_conversation` 的公开消息 DTO 必须把持久 JSONL 的可选 `messageId` 原样投影给前端;否则真实 finalization 在客户端边界丢失身份,前端只能看到普通 assistant 并把资源区错误显示为 0。旧消息缺少 ID 时保持 `null`,不按正文或时间猜测成果。
@@ -0,0 +1,32 @@
# AI 游戏创作高风险审批 Rank 待解决事项
更新时间:`2026-07-20`
## 背景
项目开发工作台规划“严格审批 / 高风险审批 / 无需审批”三档。严格审批可直接复用现有 Runtime 确认门禁;高风险审批仍缺少可执行 Rank 合同,当前不能由前端自行判断。
## 待确定
- Rank 的事实源和版本号。
- 成本、历史错误率、资源不可逆性、代码/命令副作用、外部发布等维度的权重。
- 单动作 Rank 与批次 Rank 的聚合方式。
- Rank 阈值由谁配置、如何审计和回滚。
- 模型建议与 Runtime 强制规则冲突时的裁决顺序。
- 计费发生前、外部调用前、项目写入前分别在哪个阶段执行门禁。
- Runner 恢复、重复 action、reconciliation 和策略升级时如何保持确定性。
- 普通用户可见的风险原因和不泄露内部诊断的展示格式。
## 临时产品边界
- P0 只启用严格审批。
- 高风险审批在界面中保持视觉不可用,但允许点击查看“Rank 规则待定,暂不可用”。
- 不得把高风险审批静默降级成严格审批或无需审批。
- Rank 合同、Runtime 实现和确定性回归完成前不得开放。
## 关闭条件
1. PRD 冻结 Rank 输入、阈值、版本和恢复语义。
2. Runtime 在副作用前执行同一 Rank 决策,前端只展示后端结果。
3. 覆盖计费、文件写入、命令、外部生成、批次动作、重放和 reconciliation 回归。
4. 普通用户界面能说明为何需要确认,但不暴露 provider、fingerprint、密钥或绝对路径。
@@ -88,7 +88,7 @@ Runner 从显式 AppData 目录读取 `game-creator.config.json`API Key 不
### 本地协议
- Runner 只监听随机 `127.0.0.1` 端口。正常启动先使用 `bind(127.0.0.1:0)`Linux 仅在该调用因 `AddrInUse` 失败后,才读取并严格解析 `/proc/sys/net/ipv4/ip_local_port_range``ip_unprivileged_port_start``ip_local_reserved_ports`。候选限定在 61000-65535 高位段,排除当前临时范围、低于实际非特权起点和显式 reserved ranges,再按当前 boot 随机化起点并跳过已占用端口。任一 sysctl 缺失/非法、候选耗尽或出现非占用类错误必须失败关闭,不得停止现有服务、绑定非 loopback 地址或回退到无 token IPC。
- AppData endpoint 文件权限收紧为当前用户,保存 `protocolVersion / pid / bootId / port / token / heartbeatAt`
- AppData endpoint 文件权限收紧为当前用户,保存 `protocolVersion / pid / bootId / port / token / heartbeatAt / executableFingerprint``executableFingerprint` 是当前发布可执行文件内容的 SHA-256;只有协议和可执行文件身份同时一致且 ping 成功时才能复用已有 Runner。旧 endpoint 缺少该字段或身份不同仍可按原协议读取,但只能经 `shutdown_if_idle` 安全退役;Runner 忙时明确阻止版本切换,不能强杀或让新客户端继续把任务交给旧二进制
- 请求使用 `u32` 长度前缀加 UTF-8 JSON,单帧最多 1 MiB。
- 每个请求必须携带私有 token、`requestId` 和协议版本。
- 首版方法:`runner.ping``runner.status``runtime.wake_pending``runtime.resume``runtime.continue_action``runner.shutdown_if_idle`
@@ -107,6 +107,7 @@ Runner 从显式 AppData 目录读取 `game-creator.config.json`API Key 不
- `.agent/agent.db`、conversation、events、tasks、activity、output 和 Session catalog 的读改写临界区升级为进程内 Mutex + OS 文件锁。
- App 配置写入改为同目录临时文件 + 原子替换,Runner 只读取完整版本。
- Runner endpoint、项目 execution-owner 和协议主版本共同阻止 split-brain。
- Runner endpoint 的可执行文件身份额外阻止 debug/release 或旧/新构建在协议版本相同的情况下跨构建复用;身份计算只在进程内按当前 executable 内容完成并缓存,错误和公共状态不得输出 executable 路径或 fingerprint。
- 所有会写 Runtime 的 CLI 命令必须显式传入项目外 AppData,不能回退到 CLI 进程内执行;`--runner-status` 只读已有 endpoint,不得为了查询状态启动 Runner。
- AppData 在 Unix 上必须由当前用户持有且权限为 `0700`endpoint/lock 为 `0600`。Windows 下 AppData 目录、Runner endpoint、AppData Runner lock 和 execution-owner 诊断文件使用禁止继承的 protected DACL,只允许当前用户 SID;目录 ACE 带对象 / 容器继承,文件 ACE 不带继承。写入口可以收紧后复核,只读状态入口只能校验,不能创建、收紧或修复。
- Runner lock、Agent lane lock 和项目 execution-owner 均拒绝符号链接、硬链接或 Windows reparse pointUnix 通过 `openat` 逐级无跟随打开,Windows 持有逐级目录句柄,取得稳定句柄和 OS 锁后才允许写诊断信息。
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More