拆分游戏创作壳 Tauri 入口

拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块
拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口
扩展壳配置与原生壳门禁的 Rust 源码扫描范围
同步 AI 游戏创作 App 技术方案和决策记录
This commit is contained in:
AIGameCreator App
2026-07-04 16:25:45 +08:00
parent 3340b307d6
commit 7afec12958
14 changed files with 13211 additions and 13100 deletions
@@ -30,6 +30,10 @@ const tauriHandlerSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
);
const tauriRustSource = readSourceTree(
new URL('../src-tauri/src/', import.meta.url),
'.rs',
);
const sharedContractSource = fs.readFileSync(
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
@@ -73,6 +77,26 @@ function collectFiles(path) {
return [];
}
function readSourceTree(path, extension) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) =>
readSourceTree(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extension,
),
)
.join('\n');
}
if (pathnameExtension(path.pathname) !== extension) {
return '';
}
return fs.readFileSync(path, 'utf8');
}
function pathnameExtension(pathname) {
const index = pathname.lastIndexOf('.');
return index === -1 ? '' : pathname.slice(index);
@@ -299,7 +323,7 @@ assertCommandNamesSubset(
assertCommandNamesSubset(
'AI game creator shell Tauri command implementation',
parseTauriHandlerCommandNames(tauriHandlerSource),
parseRustFunctionNames(tauriHandlerSource),
parseRustFunctionNames(tauriRustSource),
);
assertCommandNamesSubset(
@@ -481,11 +505,6 @@ for (const snippet of [
}
}
const tauriMainSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
);
for (const snippet of [
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
@@ -513,7 +532,7 @@ for (const snippet of [
'"command.auto"',
'GameCreationAppPermission::Auto',
]) {
if (!tauriMainSource.includes(snippet)) {
if (!tauriRustSource.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
@@ -524,7 +543,7 @@ for (const snippet of [
'open_developer_window(app)?;',
'tauri::WebviewWindowBuilder::new(app, "developer"',
]) {
if (tauriMainSource.includes(snippet)) {
if (tauriRustSource.includes(snippet)) {
throw new Error(
`AI game creator shell must not auto-open developer windows: ${snippet}`,
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
use super::*;
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum CliCommand {
LlmStatus,
AgentRun {
project_path: PathBuf,
prompt: String,
wait_for_enter: bool,
},
}
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
let mut lines = vec![
format!("llm.configured={}", status.configured),
format!("llm.apiKeyPresent={}", status.api_key_present),
format!(
"llm.baseUrl={}",
status.base_url.as_deref().unwrap_or_default()
),
format!("llm.model={}", status.model.as_deref().unwrap_or_default()),
format!("llm.apiKind={}", status.api_kind),
format!("llm.stream={}", status.stream),
];
for agent in &status.agents {
lines.push(format!(
"llm.agent.{}.configured={}",
agent.agent_id, agent.configured
));
lines.push(format!(
"llm.agent.{}.apiKeyPresent={}",
agent.agent_id, agent.api_key_present
));
lines.push(format!(
"llm.agent.{}.baseUrl={}",
agent.agent_id,
agent.base_url.as_deref().unwrap_or_default()
));
lines.push(format!(
"llm.agent.{}.model={}",
agent.agent_id,
agent.model.as_deref().unwrap_or_default()
));
lines.push(format!(
"llm.agent.{}.apiKind={}",
agent.agent_id, agent.api_kind
));
lines.push(format!(
"llm.agent.{}.stream={}",
agent.agent_id, agent.stream
));
if let Some(error) = agent.error.as_deref() {
lines.push(format!("llm.agent.{}.error={error}", agent.agent_id));
}
}
if let Some(error) = status.error.as_deref() {
lines.push(format!("llm.error={error}"));
}
lines
}
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, String> {
if args.first().map(String::as_str) == Some("--llm-status") {
return Ok(Some(CliCommand::LlmStatus));
}
if args.first().map(String::as_str) != Some("--agent-run") {
return Ok(None);
}
let mut rest = args[1..].to_vec();
let wait_for_enter = if let Some(index) = rest.iter().position(|arg| arg == "--no-wait") {
rest.remove(index);
false
} else {
true
};
let project_path = rest
.first()
.map(String::as_str)
.ok_or_else(|| "用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string())?;
if rest.len() < 2 {
return Err("用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string());
}
let prompt = rest[1..].join(" ");
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("创作需求不能为空".to_string());
}
Ok(Some(CliCommand::AgentRun {
project_path: PathBuf::from(project_path),
prompt: prompt.to_string(),
wait_for_enter,
}))
}
pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
match command {
CliCommand::LlmStatus => {
let status = check_game_creator_llm_config_from_config();
for line in game_creator_llm_status_lines(&status) {
println!("{line}");
}
if status.configured {
Ok(())
} else {
Err("LLM 配置未就绪".to_string())
}
}
CliCommand::AgentRun {
project_path,
prompt,
wait_for_enter,
} => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let result =
runtime.block_on(generate_local_game_draft_at(&project_path, &prompt, None))?;
let (preview, stop) = start_local_game_preview_for_project(&project_path)?;
record_preview_state(
&project_path,
GameCreationAppPreviewStatus::Running,
Some(preview.url.clone()),
Some(preview.port),
)?;
append_preview_log(&project_path, "running", Some(&preview.url))?;
append_preview_start_trace_step(&project_path, &preview)?;
println!("agent.run.completed");
println!("projectPath={}", result.project_path);
println!("gameIndexPath={}", result.game_index_path);
println!("designPath={}", result.design_path);
println!(
"tracePath={}",
project_path.join(".agent/run.latest.json").display()
);
println!("previewUrl={}", preview.url);
if wait_for_enter {
println!("按 Enter 停止本地预览。");
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
}
let _ = stop.send(());
let _ = record_preview_state(
&project_path,
GameCreationAppPreviewStatus::Stopped,
None,
None,
);
let _ = append_preview_log(&project_path, "stopped", None);
let _ = append_preview_stop_trace_step(&project_path);
Ok(())
}
}
}
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
@@ -0,0 +1,476 @@
use super::*;
#[derive(Default)]
pub(crate) struct PreviewRegistry {
current: Mutex<Option<PreviewServer>>,
}
struct PreviewServer {
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
}
impl PreviewRegistry {
pub(crate) fn set_running(
&self,
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
) -> (LocalPreviewResult, Option<LocalPreviewResult>) {
let mut current = self.current.lock().expect("preview registry lock");
let previous_preview = if let Some(previous) = current.take() {
let preview = previous.preview;
let _ = previous.stop.send(());
Some(preview)
} else {
None
};
*current = Some(PreviewServer {
preview: preview.clone(),
stop,
});
(preview, previous_preview)
}
pub(crate) fn status(&self) -> LocalPreviewStatus {
let current = self.current.lock().expect("preview registry lock");
if let Some(server) = current.as_ref() {
local_preview_status_from_result(&server.preview)
} else {
stopped_preview_status()
}
}
pub(crate) fn stop(&self) -> LocalPreviewStatus {
let mut current = self.current.lock().expect("preview registry lock");
if let Some(server) = current.take() {
let _ = server.stop.send(());
}
stopped_preview_status()
}
pub(crate) fn stop_for_project(&self, root: Option<&Path>) -> (LocalPreviewStatus, bool) {
let mut current = self.current.lock().expect("preview registry lock");
let Some(server) = current.as_ref() else {
return (stopped_preview_status(), false);
};
if let Some(root) = root {
let status = local_preview_status_from_result(&server.preview);
if ensure_preview_belongs_to_project(&status, root).is_err() {
return (stopped_preview_status(), false);
}
}
let Some(server) = current.take() else {
return (stopped_preview_status(), false);
};
let _ = server.stop.send(());
(stopped_preview_status(), true)
}
}
pub(crate) fn stopped_preview_status() -> LocalPreviewStatus {
LocalPreviewStatus {
status: "stopped".to_string(),
url: None,
port: None,
root: None,
}
}
fn local_preview_status_from_result(preview: &LocalPreviewResult) -> LocalPreviewStatus {
LocalPreviewStatus {
status: "running".to_string(),
url: Some(preview.url.clone()),
port: Some(preview.port),
root: Some(preview.root.clone()),
}
}
pub(crate) fn preview_open_url(status: &LocalPreviewStatus) -> Result<String, String> {
if status.status == "running" {
if let Some(url) = status.url.as_deref() {
if url.starts_with("http://127.0.0.1:") {
return Ok(url.to_string());
}
}
}
Err("preview is not running".to_string())
}
pub(crate) fn validate_preview_open_project(
status: &LocalPreviewStatus,
project_path: Option<&str>,
) -> Result<(), String> {
let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else {
return Ok(());
};
let root = Path::new(project_path);
enforce_project_permission_policy(root, "preview.open")?;
ensure_preview_belongs_to_project(status, root)
}
pub(crate) fn ensure_preview_belongs_to_project(
status: &LocalPreviewStatus,
root: &Path,
) -> Result<(), String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let preview_root = status
.root
.as_deref()
.ok_or_else(|| "preview is not running".to_string())?;
let expected_root = root
.canonicalize()
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?;
let actual_root = Path::new(preview_root)
.canonicalize()
.map_err(|error| format!("读取预览项目目录失败:{preview_root}: {error}"))?;
if actual_root == expected_root {
Ok(())
} else {
Err("当前预览不属于已授权本地项目".to_string())
}
}
pub(crate) fn filter_preview_status_for_project(
status: LocalPreviewStatus,
project_path: Option<&str>,
) -> LocalPreviewStatus {
let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else {
return status;
};
if ensure_preview_belongs_to_project(&status, Path::new(project_path)).is_err() {
stopped_preview_status()
} else {
status
}
}
#[tauri::command]
pub(crate) fn start_local_game_preview(
project_path: String,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<LocalPreviewResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "preview.start")?;
let _lock = acquire_project_write_lock(root, "preview.start")?;
let (preview, stop) = start_local_game_preview_for_project(root)?;
if let Err(error) = record_preview_state(
root,
GameCreationAppPreviewStatus::Running,
Some(preview.url.clone()),
Some(preview.port),
) {
let _ = stop.send(());
return Err(error);
}
if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
let _ = stop.send(());
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None);
return Err(error);
}
let (preview, previous_preview) = registry.set_running(preview, stop);
if let Some(previous_preview) = previous_preview.as_ref() {
record_replaced_preview_stop(previous_preview);
}
if let Err(error) = append_preview_start_trace_step(root, &preview) {
let _ = registry.stop();
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None);
return Err(error);
}
Ok(preview)
}
#[tauri::command]
pub(crate) fn stop_local_game_preview(
project_path: Option<String>,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<LocalPreviewStatus, String> {
let project_path = project_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty());
let root = project_path.map(Path::new);
let _lock = if let Some(root) = root {
enforce_project_permission_policy(root, "preview.stop")?;
Some(acquire_project_write_lock(root, "preview.stop")?)
} else {
None
};
stop_local_game_preview_for_root(root, &registry)
}
pub(crate) fn stop_local_game_preview_for_root(
root: Option<&Path>,
registry: &PreviewRegistry,
) -> Result<LocalPreviewStatus, String> {
let (status, stopped) = registry.stop_for_project(root);
if let Some(root) = root.filter(|_| stopped) {
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
}
Ok(status)
}
#[tauri::command]
pub(crate) fn get_local_game_preview_status(
registry: tauri::State<'_, PreviewRegistry>,
project_path: Option<String>,
) -> Result<LocalPreviewStatus, String> {
get_local_game_preview_status_at(&registry, project_path.as_deref())
}
pub(crate) fn get_local_game_preview_status_at(
registry: &PreviewRegistry,
project_path: Option<&str>,
) -> Result<LocalPreviewStatus, String> {
let project_path = project_path.map(str::trim).filter(|path| !path.is_empty());
if let Some(project_path) = project_path {
enforce_project_permission_policy(Path::new(project_path), "preview.status")?;
}
Ok(filter_preview_status_for_project(
registry.status(),
project_path,
))
}
#[tauri::command]
pub(crate) fn open_local_game_preview(
app: tauri::AppHandle,
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}"))?;
Ok(status)
}
pub(crate) fn start_local_game_preview_for_project(
root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let game_root = root.join("game");
if !game_root.is_dir() {
return Err(format!("游戏目录不存在:{}", game_root.display()));
}
if !game_root.join("index.html").is_file() {
return Err(format!(
"游戏入口不存在:{}",
game_root.join("index.html").display()
));
}
let listener =
TcpListener::bind(("127.0.0.1", 0)).map_err(|error| format!("启动预览失败:{error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("读取预览端口失败:{error}"))?
.port();
listener
.set_nonblocking(true)
.map_err(|error| format!("设置预览监听失败:{error}"))?;
let served_root = root.to_path_buf();
let (stop_sender, stop_receiver) = mpsc::channel();
thread::spawn(move || loop {
if stop_receiver.try_recv().is_ok() {
break;
}
match listener.accept() {
Ok((stream, _)) => handle_preview_stream(stream, &served_root),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
});
Ok((
LocalPreviewResult {
url: format!("http://127.0.0.1:{port}/"),
port,
root: root.to_string_lossy().into_owned(),
},
stop_sender,
))
}
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
let mut request_line = String::new();
{
let mut reader = BufReader::new(&mut stream);
if reader.read_line(&mut request_line).is_err() {
return;
}
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
let url_path = parts.next().unwrap_or("/");
let response = build_preview_response(root, method, url_path);
let _ = stream.write_all(&response);
}
pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec<u8> {
let is_head = method == "HEAD";
if method != "GET" && !is_head {
return http_response(
"405 Method Not Allowed",
"text/plain",
b"method not allowed",
b"method not allowed".len(),
);
}
let file_path = match resolve_preview_path(root, url_path) {
Ok(path) => path,
Err(_) => {
let body: &[u8] = if is_head { &[] } else { b"not found" };
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
let body = match fs::read(&file_path) {
Ok(body) => body,
Err(_) => {
let body: &[u8] = if is_head { &[] } else { b"not found" };
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
let content_length = body.len();
let body = if is_head { Vec::new() } else { body };
http_response("200 OK", content_type(&file_path), &body, content_length)
}
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBuf, String> {
let path = url_path.split('?').next().unwrap_or("/");
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
let relative = decoded.trim_start_matches('/');
if relative.is_empty() {
return canonical_preview_path(root, &root.join("game/index.html"));
}
let mut file_path = root.to_path_buf();
let mut parts = relative.split('/');
let first = parts.next().ok_or_else(|| "预览路径非法".to_string())?;
if first != "game" && first != "assets" {
return Err("预览路径只能访问 game/ 或 assets/".to_string());
}
file_path.push(first);
for part in parts {
if part.is_empty() || part == "." || part == ".." || part.contains('\\') {
return Err("预览路径非法".to_string());
}
file_path.push(part);
}
canonical_preview_path(root, &file_path)
}
fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, String> {
let canonical_root = root
.canonicalize()
.map_err(|error| format!("预览根目录不可用:{}: {error}", root.display()))?;
let canonical_file = file_path
.canonicalize()
.map_err(|error| format!("预览文件不可用:{}: {error}", file_path.display()))?;
for segment in ["game", "assets"] {
let allowed_dir = root.join(segment);
let metadata = match fs::symlink_metadata(&allowed_dir) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!(
"预览目录不可用:{}: {error}",
allowed_dir.display()
))
}
};
if metadata.file_type().is_symlink() {
return Err(format!("预览目录不能是符号链接:{}", allowed_dir.display()));
}
let canonical_allowed_dir = allowed_dir
.canonicalize()
.map_err(|error| format!("预览目录不可用:{}: {error}", allowed_dir.display()))?;
if !canonical_allowed_dir.starts_with(&canonical_root) {
return Err("预览目录越过项目目录".to_string());
}
if canonical_file.starts_with(canonical_allowed_dir) {
return Ok(canonical_file);
}
}
Err("预览路径只能访问真实 game/ 或 assets/ 目录".to_string())
}
fn percent_decode_path(path: &str) -> Option<String> {
let bytes = path.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = hex_value(*bytes.get(index + 1)?)?;
let low = hex_value(*bytes.get(index + 2)?)?;
output.push((high << 4) | low);
index += 3;
} else {
output.push(bytes[index]);
index += 1;
}
}
String::from_utf8(output).ok()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
pub(crate) fn content_type(path: &Path) -> &'static str {
match path.extension().and_then(|extension| extension.to_str()) {
Some("aac") => "audio/aac",
Some("css") => "text/css; charset=utf-8",
Some("flac") => "audio/flac",
Some("gif") => "image/gif",
Some("html") => "text/html; charset=utf-8",
Some("jpeg" | "jpg") => "image/jpeg",
Some("js") => "text/javascript; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("m4a") => "audio/mp4",
Some("mp3") => "audio/mpeg",
Some("mp4") => "video/mp4",
Some("ogg") => "audio/ogg",
Some("png") => "image/png",
Some("svg") => "image/svg+xml",
Some("wasm") => "application/wasm",
Some("wav") => "audio/wav",
Some("webm") => "video/webm",
Some("webp") => "image/webp",
_ => "application/octet-stream",
}
}
fn http_response(status: &str, content_type: &str, body: &[u8], content_length: usize) -> Vec<u8> {
let header = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
content_length
);
let mut response = header.into_bytes();
response.extend_from_slice(body);
response
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
use super::*;
pub(crate) fn workspace_window_url(project_path: &str) -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from(format!(
"index.html?main&projectPath={}",
percent_encode_query_value(project_path)
)))
}
pub(crate) fn launcher_window_url() -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
}
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
return Err("请提供工作区绝对路径".to_string());
}
if !Path::new(project_path).is_absolute() {
return Err("工作区路径必须是绝对路径".to_string());
}
if project_path.chars().any(char::is_control) {
return Err("工作区路径不能包含控制字符".to_string());
}
Ok(project_path)
}
fn percent_encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char)
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
#[tauri::command]
pub(crate) fn open_game_creator_workspace_window(
app: tauri::AppHandle,
window: tauri::Window,
project_path: String,
) -> Result<(), String> {
let project_path = validate_workspace_window_project_path(&project_path)?;
if let Some(existing) = app.get_webview_window("main") {
existing.close().map_err(|error| error.to_string())?;
}
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
.title("AI 游戏创作")
.inner_size(1180.0, 820.0)
.min_inner_size(760.0, 560.0)
.build()
.map_err(|error| error.to_string())?;
window.close().map_err(|error| error.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) fn open_game_creator_launcher_window(
app: tauri::AppHandle,
window: tauri::Window,
) -> Result<(), String> {
if let Some(existing) = app.get_webview_window("launcher") {
existing.set_focus().map_err(|error| error.to_string())?;
} else {
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
.title("AI 游戏创作")
.inner_size(820.0, 640.0)
.min_inner_size(720.0, 520.0)
.build()
.map_err(|error| error.to_string())?;
}
window.close().map_err(|error| error.to_string())?;
Ok(())
}
@@ -3954,8 +3954,9 @@
- 2026-06-24 调整:普通用户通过聊天输入 `/project /绝对路径` 触发 `project.create` 待确认命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;不要把开发窗口项目路径输入框暴露到正式用户界面。
- 2026-06-25 调整:普通用户侧所有会写入、运行、查看 / 打开预览或导入本地产物的命令必须先完成 `/project` 初始化,包括 `game.generate_draft``asset.upload``game.run_local``command.run_limited``preview.start``preview.status``preview.open``preview.stop``memory.write``memory.delete``canvas.project_sync``canvas.asset_import``canvas.export_import`;没有已授权本地项目时只提示设置项目,不得落到默认 `/tmp` 草稿目录。
- 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-06-24 调整:AI 游戏创作 App 的 release 配置只登记 `main` 聊天窗口,主窗口保持聊天尺寸;任务、文件、记忆、预览、日志和能力面板只能通过 debug/dev 下额外创建的 `developer` 窗口或 Vite dev `?dev/#dev` 查看,不进入普通用户窗口
- 2026-06-24 调整`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留 `main` 聊天窗口,`developer` 窗口只在 debug 创建,开发面板只能在 `devMode` 分支渲染
- 2026-07-04 调整:`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-04 更新:AI 游戏创作 App 的 release 配置只登记 `launcher` 启动器窗口,选择工作区后由 Tauri command 关闭启动器并打开 `main` 主窗口;任务、文件、记忆、预览、日志和能力面板只能通过 Vite dev 的 `?dev/#dev` 分支查看,不进入普通用户窗口,Tauri 不再自动额外创建 `developer` 窗口
- 2026-06-24 调整,2026-07-04 更新:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留 `launcher` 启动器窗口,工作区切换命令会关闭来源窗口,用户侧预览只交给系统外部浏览器,开发面板只能在 `devMode` 分支渲染,Tauri 不得自动额外打开 `developer` 窗口。
- 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-25 调整:`/preview-status` 虽然是只读命令,也必须写入 `preview.status` 命令日志并向聊天返回错误,不得因查询失败产生未捕获异常或无审计记录。
@@ -228,7 +228,7 @@ game-project/
- `/publish` 聊天入口由 `appSurface.test.ts` 主窗口 smoke 覆盖:只基于当前已加载 manifest、最近 run trace、预览状态、资产来源和最近命令生成发布准备清单,提供 `/run``/trace``/agent-resume ``/export` 草稿,不触发 Tauri 读写、预览启动、文件读取或新增普通用户面板。
- 主窗口策略快捷入口只填入 `/policy-confirm project.index``/policy-confirm asset.register``/policy-confirm memory.write``/policy-confirm preview.start``/policy-confirm preview.open``/policy-confirm preview.stop``/policy-confirm agent.run_status``/policy-confirm conversation.read``/policy-confirm conversation.write` 草稿;Agent 状态栏的“继续说明”只填入 `/agent-resume ` 草稿,不直接触发 run 生命周期写入。
- 主窗口最近 checkpoint 列表展示 checkpoint id、文件数、大小和创建时间,同时提供直接对比、填入 `/diff`、确认回滚和填入 `/restore`;回滚继续走 `project.restore` 确认卡,不直接写项目文件。
- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。
- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,Tauri 不自动额外打开 `developer` 窗口,开发面板只在 Vite dev 的 `?dev` / `#dev` 分支渲染,独立壳能完成 release 编译。
- `npm run check:encoding``git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。
- `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。
- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl``llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind``openai_chat`Anthropic Messages 网关设置 `llm.apiKind``anthropic`URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream``true`
@@ -236,6 +236,7 @@ game-project/
## 当前最小落地
- `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,启动器 / 主窗口切换命令放在 `windows.rs`Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
- 本地项目初始化会创建 `game/``assets/``memory/``memory/agents/``exports/``.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion``role``content``agentId``updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
+30 -17
View File
@@ -29,9 +29,9 @@ const aiGameCreatorShellAppSource = fs.readFileSync(
const aiGameCreatorShellTauriConfig = JSON.parse(
fs.readFileSync('apps/ai-game-creator-shell/src-tauri/tauri.conf.json', 'utf8'),
);
const aiGameCreatorShellTauriSource = fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/src/main.rs',
'utf8',
const aiGameCreatorShellTauriSource = readSourceTree(
'apps/ai-game-creator-shell/src-tauri/src',
'.rs',
);
const productionShellScanRoots = [
@@ -80,6 +80,21 @@ const h5HostBridgeScannedFacadeImports = new Set([
'writeHostClipboardText',
]);
function readSourceTree(entryPath, extension) {
const stats = fs.statSync(entryPath);
if (stats.isDirectory()) {
return fs
.readdirSync(entryPath, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => readSourceTree(path.join(entryPath, entry.name), extension))
.join('\n');
}
if (path.extname(entryPath) !== extension) {
return '';
}
return fs.readFileSync(entryPath, 'utf8');
}
function assertRootNativeShellCheckScripts() {
if (rootPackageJson.scripts?.['check:native-shells'] !== 'node scripts/check-native-shells.mjs') {
throw new Error('root check:native-shells script must run scripts/check-native-shells.mjs');
@@ -2000,7 +2015,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
"await invoke<LocalPreviewStatus>('open_local_game_preview'",
"return '已交给外部浏览器打开。';",
'const openMessage = await openPreviewInExternalBrowser(',
"appendLocalPermissionLog(\n result.projectPath,\n 'permission.confirm',\n 'project.create',",
"appendLocalPermissionLog(\n openedProject.projectPath,\n 'permission.confirm',\n 'project.create',",
]) {
if (!aiGameCreatorShellAppSource.includes(snippet)) {
throw new Error(`AI game creator external preview boundary drifted: missing ${snippet}`);
@@ -2008,15 +2023,20 @@ function assertAiGameCreatorShellUserDevBoundary() {
}
for (const snippet of [
'#[cfg(debug_assertions)]\nfn developer_window_url()',
'tauri::WebviewUrl::App(PathBuf::from("index.html?dev"))',
'WebviewWindowBuilder::new(app, "developer", developer_window_url())',
'#[cfg(debug_assertions)]\n open_developer_window(app)?;',
'fn open_local_game_preview(',
'.open_url(&url, None::<&str>)',
]) {
if (!aiGameCreatorShellTauriSource.includes(snippet)) {
throw new Error(`AI game creator developer window boundary drifted: missing ${snippet}`);
throw new Error(`AI game creator external preview boundary drifted: missing ${snippet}`);
}
}
for (const snippet of [
'fn open_developer_window(',
'WebviewWindowBuilder::new(app, "developer"',
'open_developer_window(app)?',
]) {
if (aiGameCreatorShellTauriSource.includes(snippet)) {
throw new Error(`AI game creator release shell must not auto-open developer windows: ${snippet}`);
}
}
@@ -2026,21 +2046,14 @@ function assertAiGameCreatorShellUserDevBoundary() {
const launcherWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
'fn open_game_creator_launcher_window(',
);
const developerWindowIndex = aiGameCreatorShellTauriSource.indexOf(
'#[cfg(debug_assertions)]\nfn open_developer_window(',
);
const workspaceWindowCommandSource = aiGameCreatorShellTauriSource.slice(
workspaceWindowCommandIndex,
launcherWindowCommandIndex,
);
const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice(
launcherWindowCommandIndex,
developerWindowIndex,
);
const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice(launcherWindowCommandIndex);
if (
workspaceWindowCommandIndex < 0 ||
launcherWindowCommandIndex < 0 ||
developerWindowIndex < 0 ||
!workspaceWindowCommandSource.includes('window.close().map_err(|error| error.to_string())?;') ||
!launcherWindowCommandSource.includes('window.close().map_err(|error| error.to_string())?;')
) {