Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/main.rs
T
suzmii 73f5c94de4 合并master并保留双侧决策记录
合并 origin/master 的 UI 编辑器、GDD 审批与前端修复。

保留本分支 LLM Router、Direct 过程卡与私有路径相关实现和决策记录。

解决 decision-log 文档冲突,完整保留双方新增决策条目。
2026-09-02 19:31:12 +08:00

2732 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")]
use std::collections::BTreeMap;
use std::fs;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicBool;
use std::sync::{mpsc, Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use platform_agent::{
build_game_creation_seed_task_graph, plan_game_creation_agent_pass,
route_game_creation_repair_issues,
};
use platform_llm::{
LlmApiKind, LlmClient, LlmConfig, LlmMessage, LlmMessageContentPart, LlmProvider,
LlmRunRequest, DEFAULT_RETRY_BACKOFF_MS,
};
use reqwest::header;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com";
const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024;
const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress";
fn build_agc_update_download_client() -> reqwest::Client {
reqwest::Client::new()
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgcUpdateDownloadProgress {
downloaded_bytes: u64,
total_bytes: Option<u64>,
}
#[cfg(windows)]
fn launch_agc_installer(path: &Path, relaunch_path: &Path) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let executable = path.to_string_lossy().replace('\'', "''");
let relaunch_executable = relaunch_path.to_string_lossy().replace('\'', "''");
let script = format!(
"$ErrorActionPreference = 'Stop'; $installer = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{executable}' -ArgumentList @('/S'); if ($installer.ExitCode -eq 0 -and (Test-Path -LiteralPath '{relaunch_executable}')) {{ Start-Process -FilePath '{relaunch_executable}' }}; exit $installer.ExitCode"
);
Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-Command",
script.as_str(),
])
.creation_flags(0x0800_0000)
.spawn()
.map(|_| ())
.map_err(|error| format!("无法启动更新安装程序:{error}"))
}
#[cfg(not(windows))]
fn launch_agc_installer(path: &Path, _relaunch_path: &Path) -> Result<(), String> {
Command::new(path)
.arg("/S")
.spawn()
.map(|_| ())
.map_err(|error| format!("无法启动更新安装程序:{error}"))
}
#[tauri::command]
async fn download_agc_update(
app: tauri::AppHandle,
download_url: String,
expected_sha256: Option<String>,
expected_size: Option<u64>,
) -> Result<String, String> {
let parsed =
url::Url::parse(download_url.trim()).map_err(|_| "更新下载地址无效".to_string())?;
if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) {
return Err("更新下载地址必须来自受信任的 OSS".to_string());
}
let encoded_filename = parsed
.path_segments()
.and_then(|segments| segments.last())
.filter(|value| !value.is_empty())
.ok_or_else(|| "更新下载地址缺少文件名".to_string())?
.to_string();
let filename = percent_encoding::percent_decode_str(&encoded_filename)
.decode_utf8()
.map_err(|_| "更新文件名无效".to_string())?
.into_owned();
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err("更新文件名无效".to_string());
}
if filename.is_empty() || filename.len() > 128 {
return Err("更新文件名无效".to_string());
}
let response = build_agc_update_download_client()
.get(parsed)
.send()
.await
.map_err(|_| "下载更新失败".to_string())?;
if !response.status().is_success() {
return Err("下载更新失败".to_string());
}
if response
.content_length()
.is_some_and(|length| length > AGC_UPDATE_MAX_DOWNLOAD_BYTES)
{
return Err("更新文件超过大小限制".to_string());
}
let download_dir = app
.path()
.temp_dir()
.map_err(|_| "无法定位临时目录".to_string())?
.join("genarrative-agc-update");
fs::create_dir_all(&download_dir).map_err(|_| "无法创建临时目录".to_string())?;
let target = download_dir.join(&filename);
let temporary = download_dir.join(format!(
"{}.{}.download",
filename,
uuid::Uuid::new_v4().simple()
));
let mut file = File::create(&temporary).map_err(|_| "保存更新文件失败".to_string())?;
let mut hasher = sha2::Sha256::new();
let total_bytes = response.content_length();
let mut downloaded_bytes = 0_u64;
let _ = app.emit(
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
AgcUpdateDownloadProgress {
downloaded_bytes,
total_bytes,
},
);
let mut stream = response.bytes_stream();
while let Some(chunk_result) = stream.next().await {
let chunk = match chunk_result {
Ok(chunk) => chunk,
Err(_) => {
let _ = fs::remove_file(&temporary);
return Err("读取更新文件失败".to_string());
}
};
downloaded_bytes = match downloaded_bytes.checked_add(chunk.len() as u64) {
Some(value) if value <= AGC_UPDATE_MAX_DOWNLOAD_BYTES => value,
_ => {
let _ = fs::remove_file(&temporary);
return Err("更新文件超过大小限制".to_string());
}
};
hasher.update(&chunk);
if file.write_all(&chunk).is_err() {
let _ = fs::remove_file(&temporary);
return Err("保存更新文件失败".to_string());
}
let _ = app.emit(
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
AgcUpdateDownloadProgress {
downloaded_bytes,
total_bytes,
},
);
}
if file.flush().is_err() {
let _ = fs::remove_file(&temporary);
return Err("保存更新文件失败".to_string());
}
drop(file);
if let Some(expected_size) = expected_size {
if downloaded_bytes != expected_size {
let _ = fs::remove_file(&temporary);
return Err("更新文件大小校验失败".to_string());
}
}
if let Some(expected_sha256) = expected_sha256 {
let expected_sha256 = expected_sha256.trim().to_ascii_lowercase();
if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
|| expected_sha256.len() != 64
{
let _ = fs::remove_file(&temporary);
return Err("更新文件摘要无效".to_string());
}
let actual = format!("{:x}", hasher.finalize());
if actual != expected_sha256 {
let _ = fs::remove_file(&temporary);
return Err("更新文件完整性校验失败".to_string());
}
}
if target.exists() {
let _ = fs::remove_file(&target);
}
if let Err(error) = fs::rename(&temporary, &target) {
let _ = fs::remove_file(&temporary);
return Err(format!("提交更新文件失败:{error}"));
}
let relaunch_path =
std::env::current_exe().map_err(|error| format!("无法定位客户端程序:{error}"))?;
launch_agc_installer(&target, &relaunch_path)?;
app.exit(0);
Ok(target.to_string_lossy().into_owned())
}
// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
mod agent;
mod agent_native_tools;
mod assets;
mod browser;
mod cli;
mod client_extensions;
mod collaboration;
mod command_exec;
mod command_output;
mod command_sandbox;
mod command_sandbox_trampoline;
mod commands;
mod config;
mod context_compaction;
mod context_menu;
#[cfg(all(debug_assertions, not(test)))]
mod debug;
mod delegation;
mod git_inspect;
mod goal;
mod http_client;
mod image_inspect;
mod isolated_agent;
mod patchset;
mod platform_session;
mod preview;
mod process_session;
mod process_session_bridge;
mod project;
mod provider_handoff;
mod provider_retry;
mod repository_context;
mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod tool_plan_handoff;
mod user_input;
mod windows;
use agent::*;
use agent_native_tools::*;
use assets::*;
use browser::*;
use cli::*;
use client_extensions::*;
use collaboration::*;
use command_exec::*;
use command_output::*;
use command_sandbox::*;
use commands::*;
use config::*;
use context_compaction::*;
use delegation::*;
use git_inspect::*;
use goal::*;
use image_inspect::*;
use isolated_agent::*;
use patchset::*;
use platform_session::*;
use preview::*;
use process_session::*;
use project::*;
use repository_context::*;
use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
use windows::*;
#[tauri::command]
async fn suggest_ui_design_semantic(
project_path: String,
state: ui_editor::state::State,
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
}
#[tauri::command]
async fn recognize_ui(
project_path: String,
state: ui_editor::state::State,
) -> Result<ui_editor::commands::RecognitionDTO, String> {
ui_editor::commands::recognize_ui_impl(project_path, state).await
}
#[tauri::command]
async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands::MergeDTO, String> {
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
) -> Result<ui_editor::persistence::UiDesignStateSnapshot, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
ui_editor::persistence::load_ui_design_state_at(input)
}
#[tauri::command]
fn save_ui_design_state(
input: ui_editor::persistence::SaveUiDesignStateInput,
) -> Result<ui_editor::persistence::SaveUiDesignStateResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::persistence::save_ui_design_state_at(input)
}
#[tauri::command]
fn generate_ui_design_code(
input: ui_editor::persistence::GenerateUiDesignCodeInput,
) -> Result<ui_editor::persistence::GenerateUiDesignCodeResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
ui_editor::persistence::generate_ui_design_code_at(input)
}
#[tauri::command]
fn ensure_ui_design_resource_for_prototype(
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
) -> Result<ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeResult, String> {
ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input)
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct InitLocalProjectResult {
project_path: String,
manifest_path: String,
manifest: GameCreationAppManifest,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectDirectoryStatus {
project_path: String,
exists: bool,
is_directory: bool,
is_game_creator_project: bool,
is_godot_project: bool,
godot_project_root: Option<String>,
project_name: Option<String>,
modified_at: Option<u64>,
manifest_error: Option<String>,
recent_run_status: Option<String>,
recent_run_stop_reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewResult {
url: String,
port: u16,
root: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewStatus {
status: String,
url: Option<String>,
port: Option<u16>,
root: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalGameProjectRevisionStatus {
revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GenerateLocalGameDraftResult {
project_path: String,
game_index_path: String,
design_path: String,
short_memory_path: String,
long_memory_path: String,
manifest: GameCreationAppManifest,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorChatAgentReply {
reply_text: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorRoleAgentChatStreamEvent {
project_path: String,
agent_id: String,
run_id: String,
status: String,
delta_text: String,
accumulated_text: String,
finish_reason: Option<String>,
session_id: Option<String>,
runtime_status: Option<String>,
runtime_phase: Option<String>,
runtime_summary: Option<String>,
runtime_state: Option<AgentRuntimeState>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeState {
#[serde(default)]
schema_version: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
task_id: String,
#[serde(default)]
session_id: String,
#[serde(default)]
run_id: String,
#[serde(default)]
source: String,
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
parent_run_id: Option<String>,
#[serde(default)]
delegation_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
#[serde(default)]
current_task: String,
#[serde(default)]
current_goal: String,
#[serde(default)]
goal_id: Option<String>,
#[serde(default)]
goal_revision: u64,
#[serde(default)]
goal_status: Option<String>,
#[serde(default)]
goal_outcome: Option<String>,
#[serde(default)]
goal_constraints: Vec<String>,
#[serde(default)]
goal_verification: Vec<String>,
#[serde(default)]
current_action: String,
#[serde(default)]
waiting_on: String,
#[serde(default)]
next_step: String,
#[serde(default)]
loop_iteration: u32,
/// Consecutive strict Fast-GDD submit rejections for this exact planning
/// child run. It is Runtime-owned durable state so a restart cannot turn
/// an invalid-provider-output loop back into an unbounded retry.
#[serde(default)]
plan_submit_gdd_rejection_count: u32,
/// Consecutive rounds where this run produced no action and no structured
/// plan step advance. Runtime-owned durable state so a runner restart
/// cannot launder an explanation-only planning loop back into an unbounded
/// Provider spend.
#[serde(default)]
plan_update_idle_rounds: u32,
/// Consecutive final replies this run had refused by a completion blocker.
/// Runtime-owned durable state for the same reason as the two counters
/// above: a blocker the model cannot satisfy is a livelock, and a runner
/// restart must not launder it back into unbounded Provider spend.
#[serde(default)]
stale_finalization_rounds: u32,
#[serde(default)]
max_loop_iterations: u32,
#[serde(default)]
tool_action_budget: u32,
#[serde(default)]
plan_revision: u64,
#[serde(default)]
plan_explanation: String,
#[serde(default)]
plan: Vec<String>,
#[serde(default)]
plan_steps: Vec<AgentRuntimePlanStep>,
#[serde(default)]
active_plan_step_index: Option<u32>,
#[serde(default)]
observations: Vec<String>,
#[serde(default)]
recent_tool_calls: Vec<AgentRuntimeToolCallRecord>,
#[serde(default)]
pending_tool_action: Option<AgentRuntimePendingToolActionSummary>,
#[serde(default)]
task_queue: AgentRuntimeTaskQueueSummary,
#[serde(default)]
allowed_tools: Vec<String>,
#[serde(default)]
tool_policy: AgentRuntimeToolPolicySnapshot,
#[serde(default)]
applied_steer_cursor: u64,
#[serde(default)]
applied_steer_refs: Vec<AgentRuntimeSteerRef>,
#[serde(default)]
queued_steer_count: u32,
#[serde(default)]
context_usage: AgentRuntimeContextUsage,
#[serde(default)]
last_response: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
started_at: u64,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeContextUsage {
#[serde(default)]
estimated_input_tokens: u64,
#[serde(default)]
auto_compact_token_limit: u64,
#[serde(default)]
last_prompt_tokens: Option<u64>,
#[serde(default)]
last_completion_tokens: Option<u64>,
#[serde(default)]
last_total_tokens: Option<u64>,
#[serde(default)]
compaction_revision: u64,
#[serde(default)]
compaction_count: u64,
#[serde(default)]
last_compaction_trigger: Option<String>,
#[serde(default)]
last_compacted_at: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeSteerRef {
#[serde(default)]
steer_id: String,
#[serde(default)]
sequence: u64,
#[serde(default)]
message_id: String,
#[serde(default)]
instruction_sha256: String,
#[serde(default)]
content_chars: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolPolicySnapshot {
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
allowed_tools: Vec<String>,
#[serde(default)]
auto_tools: Vec<String>,
#[serde(default)]
confirm_tools: Vec<String>,
#[serde(default)]
denied_tools: Vec<String>,
#[serde(default)]
updated_at: u64,
}
impl Default for AgentRuntimeToolPolicySnapshot {
fn default() -> Self {
Self {
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
allowed_tools: Vec::new(),
auto_tools: Vec::new(),
confirm_tools: Vec::new(),
denied_tools: Vec::new(),
updated_at: 0,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolCallRecord {
#[serde(default)]
action_id: Option<String>,
#[serde(default)]
tool: String,
#[serde(default)]
status: String,
#[serde(default)]
action_fingerprint: Option<String>,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
summary: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePendingToolActionSummary {
#[serde(default)]
action_id: String,
#[serde(default)]
action_fingerprint: String,
#[serde(default)]
tool: String,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
requested_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePlanStep {
#[serde(default)]
index: u32,
#[serde(default)]
title: String,
#[serde(default)]
status: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeTaskQueueSummary {
#[serde(default)]
total: u32,
#[serde(default)]
pending: u32,
#[serde(default)]
running: u32,
#[serde(default)]
waiting_for_confirmation: u32,
#[serde(default)]
waiting_for_user_input: u32,
#[serde(default)]
paused: u32,
#[serde(default)]
cancelled: u32,
#[serde(default)]
completed: u32,
#[serde(default)]
failed: u32,
#[serde(default)]
latest_run_id: Option<String>,
#[serde(default)]
updated_at: u64,
}
impl Default for AgentRuntimeTaskQueueSummary {
fn default() -> Self {
Self {
total: 0,
pending: 0,
running: 0,
waiting_for_confirmation: 0,
waiting_for_user_input: 0,
paused: 0,
cancelled: 0,
completed: 0,
failed: 0,
latest_run_id: None,
updated_at: 0,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeEvent {
#[serde(default)]
schema_version: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
task_id: String,
#[serde(default)]
session_id: String,
#[serde(default)]
run_id: String,
#[serde(default)]
source: String,
#[serde(default)]
event_type: String,
#[serde(default)]
event_id: String,
#[serde(default)]
action_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
#[serde(default)]
summary: String,
#[serde(default)]
public_text: Option<String>,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeResponseStream {
schema_version: String,
agent_id: String,
task_id: String,
session_id: String,
run_id: String,
request_kind: String,
request_slot: String,
applied_steer_cursor: u64,
response_revision: u64,
sequence: u64,
status: String,
accumulated_text: String,
finish_reason: Option<String>,
started_at: u64,
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeTaskRecord {
#[serde(default)]
schema_version: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
task_id: String,
#[serde(default)]
session_id: String,
#[serde(default)]
run_id: String,
#[serde(default)]
source: String,
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
parent_run_id: Option<String>,
#[serde(default)]
delegation_id: Option<String>,
#[serde(default)]
goal_id: Option<String>,
#[serde(default)]
goal_revision: u64,
#[serde(default)]
goal_status: Option<String>,
#[serde(default)]
task: String,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
#[serde(default)]
current_action: String,
#[serde(default)]
terminal_detail: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentGoalRecord {
schema_version: String,
project_id: String,
goal_id: String,
agent_id: String,
session_id: String,
run_id: String,
revision: u64,
status: String,
outcome: String,
constraints: Vec<String>,
verification: Vec<String>,
#[serde(default)]
completion_evidence: Vec<String>,
#[serde(default)]
response_fingerprint: Option<String>,
created_at: u64,
#[serde(default)]
pause_requested_at: Option<u64>,
#[serde(default)]
paused_at: Option<u64>,
#[serde(default)]
completed_at: Option<u64>,
#[serde(default)]
cleared_at: Option<u64>,
#[serde(default)]
error: Option<String>,
updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentGoalMutationResult {
goal: AgentGoalRecord,
runtime: AgentRuntimeResult,
provider_interrupted: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[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,
task_queue: AgentRuntimeTaskQueueSummary,
recent_events: Vec<AgentRuntimeEvent>,
recent_tasks: Vec<AgentRuntimeTaskRecord>,
response_stream: Option<AgentRuntimeResponseStream>,
user_input_request: Option<AgentRuntimeUserInputRequestView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeSteerResult {
runtime: AgentRuntimeResult,
steer_id: String,
sequence: u64,
status: String,
provider_interrupted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
assistant_reply: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
interrupt_decision: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
decision_reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentRuntimeUpdateEvent {
project_path: String,
agent_id: String,
run_id: String,
status: String,
phase: String,
manifest_invalidated: bool,
runtime: AgentRuntimeResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidatedEvent {
project_path: String,
agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidationRelayEnvelope {
token: String,
event: GameCreatorManifestInvalidatedEvent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct GameCreatorManifestInvalidationEventSink {
port: u16,
token: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
project_path: String,
stage: String,
message: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorDirectTurnUpdateEvent {
project_path: String,
turn_id: String,
sequence: u64,
status: String,
activity: Option<String>,
accumulated_text: Option<String>,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigStatus {
agent_mode: String,
configured: bool,
account_credential_state: String,
official_route_locked: bool,
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
error: Option<String>,
agents: Vec<GameCreatorAgentLlmConfigStatus>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentLlmConfigStatus {
agent_mode: String,
agent_id: String,
label: String,
configured: bool,
account_credential_state: String,
official_route_locked: bool,
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
error: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
schema_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
agent_mode: Option<String>,
llm: Option<GameCreatorLlmConfigFile>,
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
editor_api: Option<GameCreatorEditorApiConfigFile>,
planning: Option<GameCreatorPlanningConfigFile>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorPlanningConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
capability_enabled: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
api_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
web_search_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
context_window_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_compact_token_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_output_token_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
request_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_retries: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
retry_backoff_ms: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorEditorApiConfigFile {
base_url: Option<String>,
api_key: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfig {
#[serde(default = "default_game_creator_app_config_schema_version")]
schema_version: String,
#[serde(default = "default_game_creator_agent_mode")]
agent_mode: String,
llm: GameCreatorLlmConfig,
#[serde(default)]
agent_llm: BTreeMap<String, GameCreatorLlmConfigFile>,
editor_api: GameCreatorEditorApiConfig,
#[serde(default)]
planning: GameCreatorPlanningConfig,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorPlanningConfig {
#[serde(default = "default_game_creator_planning_capability_enabled")]
capability_enabled: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfig {
api_key: String,
base_url: String,
model: String,
api_kind: String,
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
#[serde(default = "default_game_creator_llm_context_window_tokens")]
context_window_tokens: u64,
#[serde(default = "default_game_creator_llm_auto_compact_token_limit")]
auto_compact_token_limit: u64,
#[serde(default = "default_game_creator_llm_tool_output_token_limit")]
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorEditorApiConfig {
base_url: String,
api_key: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfigView {
path: String,
config: GameCreatorAppConfig,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeContextCompactionResult {
agent_id: String,
session_id: String,
run_id: Option<String>,
trigger: String,
revision: u64,
estimated_tokens_before: u64,
estimated_tokens_after: u64,
prompt_tokens: Option<u64>,
completion_tokens: Option<u64>,
total_tokens: Option<u64>,
covered_agent_messages: u64,
covered_project_messages: u64,
covered_observations: u64,
reused: bool,
compacted_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRunControlResult {
run_id: String,
status: String,
lifecycle_status: String,
next_step: String,
message: String,
activity_path: String,
output_path: String,
context_bundle_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadLocalAssetResult {
id: String,
local_path: String,
absolute_path: String,
manifest_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateUiDesignResourceResult {
asset: UploadLocalAssetResult,
manifest: GameCreationAppManifest,
committed_project_revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportCanvasExportResult {
import_root: String,
metadata_path: String,
imported_count: usize,
assets: Vec<UploadLocalAssetResult>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncCanvasProjectAssetsResult {
canvas_project_id: String,
import_root: String,
imported_count: usize,
assets: Vec<UploadLocalAssetResult>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalImportResult {
assets: Vec<ImportedAsset>,
}
type RemoteImportResult = LocalImportResult;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportedAsset {
id: String,
local_path: String,
asset_kind: Option<String>,
}
#[derive(Debug, Eq, PartialEq)]
struct GeneratedPlatformArtAssetSlice {
name: String,
width: u32,
height: u32,
local_path: String,
resource_id: Option<String>,
asset_object_id: Option<String>,
content_sha256: String,
pixel_sha256: String,
}
#[derive(Debug, Eq, PartialEq)]
struct GeneratedPlatformArtAsset {
asset: UploadLocalAssetResult,
slices: Vec<GeneratedPlatformArtAssetSlice>,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
model: Option<String>,
warning: Option<String>,
slice_warning: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct OpenCanvasProjectResult {
url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CanvasExportMetadata {
project_title: String,
exported_at: String,
layers: Vec<CanvasExportLayerMetadata>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CanvasExportLayerMetadata {
title: String,
file: Option<String>,
visible: CanvasExportVisibleMetadata,
export_error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CanvasExportVisibleMetadata {
#[serde(rename = "type")]
layer_type: String,
model: String,
task: String,
object: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalGameMemoryResult {
scope: String,
path: String,
content: String,
exists: bool,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalAgentMemoryResult {
task_id: String,
path: String,
content: String,
exists: bool,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LimitedLocalCommandResult {
command_id: String,
status: String,
output: String,
log_path: String,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectFileEntry {
path: String,
kind: String,
size: u64,
modified_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ListLocalProjectFilesResult {
project_path: String,
files: Vec<LocalProjectFileEntry>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectFileResult {
path: String,
absolute_path: String,
content: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectFileMutationResult {
path: String,
absolute_path: String,
deleted: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConversationMessage {
role: String,
content: String,
agent_id: Option<String>,
}
#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConversationMessageRecord {
schema_version: String,
role: String,
content: String,
agent_id: Option<String>,
message_id: Option<String>,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConversationResult {
path: String,
agent_id: Option<String>,
session_id: Option<String>,
messages: Vec<LocalConversationMessageRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConversationSessionRecord {
session_id: String,
title: String,
created_at: u64,
updated_at: u64,
archived_at: Option<u64>,
message_count: u64,
legacy: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_message_count: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConversationSessionListResult {
path: String,
agent_id: String,
active_session_id: String,
sessions: Vec<AgentConversationSessionRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectPermissionPolicy {
denied_commands: Vec<String>,
confirm_commands: Vec<String>,
#[serde(default)]
agent_policies: BTreeMap<String, ProjectAgentPermissionPolicy>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectAgentPermissionPolicy {
#[serde(default)]
denied_commands: Vec<String>,
#[serde(default)]
confirm_commands: Vec<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectPermissionPolicyView {
path: String,
policy: ProjectPermissionPolicy,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectIndexedFile {
path: String,
size: u64,
checksum: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectIndexResult {
project_path: String,
index_path: String,
file_count: usize,
total_bytes: u64,
files: Vec<LocalProjectIndexedFile>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectCheckpointResult {
checkpoint_id: String,
checkpoint_path: String,
file_count: usize,
total_bytes: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectExportPackageResult {
package_path: String,
package_relative_path: String,
file_count: usize,
total_bytes: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectExportPackageSummary {
package_path: String,
package_relative_path: String,
total_bytes: u64,
modified_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectExportPackagesResult {
project_path: String,
packages: Vec<LocalProjectExportPackageSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectDiffEntry {
path: String,
status: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectDiffResult {
checkpoint_id: String,
added: Vec<LocalProjectDiffEntry>,
changed: Vec<LocalProjectDiffEntry>,
deleted: Vec<LocalProjectDiffEntry>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectRestoreResult {
checkpoint_id: String,
restored_count: usize,
deleted_count: usize,
}
const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Genarrative Game Draft</title>
<style>
body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }
main { width: min(720px, calc(100vw - 32px)); }
</style>
</head>
<body><main>还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。</main></body>
</html>
"#;
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json";
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol";
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max";
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_agent_mode() -> String {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
}
fn default_game_creator_app_config_schema_version() -> String {
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string()
}
fn default_game_creator_llm_context_window_tokens() -> u64 {
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
}
fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
}
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
}
fn default_game_creator_planning_capability_enabled() -> bool {
true
}
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world";
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900;
const GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS: u32 = 1200;
const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 3] = [
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"planner",
"generator",
];
const MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 1_000;
const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000;
const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3;
const GAME_CREATOR_AGENT_TOOL_CALL_MAX: u16 = GAME_CREATION_AGENT_TOOL_CALL_MAX;
const GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT: usize = 100;
const GAME_CREATOR_AGENT_DB_SCHEMA_VERSION: &str = "game-creator-agent-db.v1";
const PROJECT_BLACKBOARD_MEMORY_PATH: &str = "memory/blackboard.md";
const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json";
const PROJECT_INDEX_PATH: &str = ".agent/project.index.json";
const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock";
const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
const AGENT_CONVERSATION_SESSION_SCHEMA_VERSION: &str = "game-creator-agent-sessions.v1";
const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1";
const AGENT_RUNTIME_RUN_PROFILE_STANDARD: &str = "standard";
const AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD: &str = "autonomous-game-build";
const AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS: usize = 8_000;
const AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS: usize = 10_000;
const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20;
const AGENT_RUNTIME_RECENT_TASK_LIMIT: usize = 12;
const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12;
fn default_agent_runtime_run_profile() -> String {
AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string()
}
const MAX_CANVAS_EXPORT_FILES: usize = 500;
const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024;
const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200;
const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [
".agent/agent.db",
".agent/spec.md",
".agent/findings.md",
".agent/logs/agent.log",
".agent/logs/command.log",
".agent/logs/preview.log",
"memory/session.md",
"memory/project.md",
PROJECT_BLACKBOARD_MEMORY_PATH,
"game/game_design.md",
"game/balance.json",
"assets/manifest.art.json",
"assets/manifest.audio.json",
"exports/README.md",
"game/index.html",
];
static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
impl Default for GameCreatorAppConfig {
fn default() -> Self {
let mut llm = GameCreatorLlmConfig::default();
// DirectProject is the shipped product route, so the application-level
// default enables the controlled AGC search tool. The bare
// GameCreatorLlmConfig default remains conservative for legacy callers.
llm.web_search_enabled = true;
Self {
schema_version: default_game_creator_app_config_schema_version(),
agent_mode: default_game_creator_agent_mode(),
llm,
agent_llm: BTreeMap::new(),
editor_api: GameCreatorEditorApiConfig::default(),
planning: GameCreatorPlanningConfig::default(),
}
}
}
impl Default for GameCreatorPlanningConfig {
fn default() -> Self {
Self {
capability_enabled: default_game_creator_planning_capability_enabled(),
}
}
}
impl Default for GameCreatorLlmConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: true,
web_search_enabled: false,
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
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: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
}
}
}
impl Default for GameCreatorEditorApiConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(),
api_key: String::new(),
}
}
}
#[derive(Clone, Copy, Debug)]
struct AgentRoleDefinition {
id: &'static str,
role: &'static str,
task_id: &'static str,
tool_id: &'static str,
brief_path_name: &'static str,
}
#[derive(Clone, Copy, Debug)]
struct AgentGroupDefinition {
id: &'static str,
label: &'static str,
role: &'static str,
brief_path_name: &'static str,
roles: &'static [AgentRoleDefinition],
}
include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs"));
const GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID: &str = "chat";
struct GameCreatorLlmAgentStatusDefinition {
agent_id: String,
label: String,
}
fn game_creator_llm_agent_status_definitions() -> Vec<GameCreatorLlmAgentStatusDefinition> {
let mut agents = vec![
GameCreatorLlmAgentStatusDefinition {
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
label: format!("{} Agent", PROJECT_SUPERVISOR_AGENT_DEFINITION.label),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "planner".to_string(),
label: "Planner".to_string(),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "orchestrator".to_string(),
label: "Orchestrator".to_string(),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "generator".to_string(),
label: "Generator".to_string(),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "evaluator".to_string(),
label: "Evaluator".to_string(),
},
];
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
if agents.iter().any(|agent| agent.agent_id == role.task_id) {
continue;
}
agents.push(GameCreatorLlmAgentStatusDefinition {
agent_id: role.task_id.to_string(),
label: format!("{} / {}", group.label, role.role),
});
}
}
agents
}
#[derive(Clone, Debug)]
struct AgentPassArtifactPaths {
draft_json: String,
design_markdown: String,
balance_json: String,
art_manifest_json: String,
audio_manifest_json: String,
publish_readme: String,
game_html: String,
handoff_markdown: String,
}
#[derive(Clone, Debug)]
struct AgentRoleBrief {
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
markdown: String,
relative_path: String,
memory_relative_path: String,
status: String,
tool_id: String,
summary: String,
}
#[derive(Clone, Debug)]
struct AgentGroupBrief {
definition: AgentGroupDefinition,
markdown: String,
relative_path: String,
role_briefs: Vec<AgentRoleBrief>,
}
#[derive(Clone, Debug)]
struct AgentPassAgenda {
relative_path: String,
task_graph_relative_path: String,
active_task_ids: Vec<String>,
carried_task_ids: Vec<String>,
dependency_waves: Vec<Vec<String>>,
repair_focus: Vec<String>,
repair_routes: Vec<GameCreationAgentRepairRouteTrace>,
summary: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmGameDraft {
title: String,
design_markdown: String,
balance: serde_json::Value,
art_manifest: serde_json::Value,
audio_manifest: serde_json::Value,
publish_readme: String,
handoffs: Vec<LlmAgentHandoff>,
game_html: String,
handoff_summary: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmAgentHandoff {
group: String,
role: String,
summary: String,
outputs: Vec<String>,
next: String,
}
const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024;
static DIAGNOSTIC_LOG_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static STARTUP_PANIC_LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false);
fn diagnostic_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn append_bounded_diagnostic_line_with_limit(
path: &Path,
line: &str,
max_bytes: u64,
) -> std::io::Result<()> {
let _guard = DIAGNOSTIC_LOG_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = open_secure_diagnostic_log(path)?;
if file.metadata()?.len() >= max_bytes {
file.seek(SeekFrom::Start(0)).map_err(|error| {
std::io::Error::new(error.kind(), format!("seek current log: {error}"))
})?;
let mut previous_content = Vec::new();
std::io::Read::by_ref(&mut file)
.take(max_bytes.saturating_add(1))
.read_to_end(&mut previous_content)
.map_err(|error| {
std::io::Error::new(error.kind(), format!("read current log: {error}"))
})?;
let previous_path = path.with_extension("previous.log");
let mut previous = open_secure_diagnostic_log(&previous_path)?;
previous.set_len(0).map_err(|error| {
std::io::Error::new(error.kind(), format!("truncate previous log: {error}"))
})?;
previous.write_all(&previous_content).map_err(|error| {
std::io::Error::new(error.kind(), format!("write previous log: {error}"))
})?;
previous.flush().map_err(|error| {
std::io::Error::new(error.kind(), format!("flush previous log: {error}"))
})?;
file.set_len(0).map_err(|error| {
std::io::Error::new(error.kind(), format!("truncate current log: {error}"))
})?;
}
file.seek(SeekFrom::End(0))
.map_err(|error| std::io::Error::new(error.kind(), format!("seek log end: {error}")))?;
writeln!(file, "{} {line}", diagnostic_timestamp())?;
file.flush()
}
fn open_secure_diagnostic_log(path: &Path) -> std::io::Result<File> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"diagnostic log must be a regular file",
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"diagnostic log must be a regular file",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"diagnostic log must not be a hardlink",
));
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(&file, "diagnostic log")
.map_err(std::io::Error::other)?;
Ok(file)
}
pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io::Result<()> {
append_bounded_diagnostic_line_with_limit(path, line, DIAGNOSTIC_LOG_MAX_BYTES)
}
fn redact_windows_absolute_paths(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = String::with_capacity(value.len());
let mut cursor = 0;
while cursor < bytes.len() {
let previous_allows_drive_path = cursor == 0 || !bytes[cursor - 1].is_ascii_alphanumeric();
let is_drive_path = previous_allows_drive_path
&& cursor + 2 < bytes.len()
&& bytes[cursor].is_ascii_alphabetic()
&& bytes[cursor + 1] == b':'
&& matches!(bytes[cursor + 2], b'\\' | b'/');
if !is_drive_path {
let ch = value[cursor..]
.chars()
.next()
.expect("valid character boundary");
output.push(ch);
cursor += ch.len_utf8();
continue;
}
output.push_str("<absolute-path>");
cursor += 3;
while cursor < bytes.len()
&& !bytes[cursor].is_ascii_whitespace()
&& !matches!(bytes[cursor], b'\"' | b'\'' | b',' | b';')
{
cursor += 1;
}
}
output
}
fn redact_unix_absolute_paths(value: &str) -> String {
let chars = value.chars().collect::<Vec<_>>();
let mut output = String::with_capacity(value.len());
let mut cursor = 0;
while cursor < chars.len() {
let previous_allows_path = cursor == 0
|| chars[cursor - 1].is_whitespace()
|| matches!(chars[cursor - 1], '=' | '(' | ':' | '');
let is_url_separator = chars.get(cursor + 1) == Some(&'/');
if chars[cursor] != '/' || !previous_allows_path || is_url_separator {
output.push(chars[cursor]);
cursor += 1;
continue;
}
output.push_str("<absolute-path>");
cursor += 1;
while cursor < chars.len()
&& !chars[cursor].is_whitespace()
&& !matches!(chars[cursor], '"' | '\'' | ',' | ';')
{
cursor += 1;
}
}
output
}
pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String {
let mut sanitized = value.replace(['\r', '\n'], " ");
if let Some(root) = private_root {
let root = root.to_string_lossy();
if !root.is_empty() {
sanitized = sanitized.replace(root.as_ref(), "<appdata>");
}
}
let lowercase = sanitized.to_ascii_lowercase();
if [
"authorization",
"bearer ",
"api_key",
"apikey",
"api key",
"x-api-key",
"token=",
"token:",
"credential",
]
.iter()
.any(|marker| lowercase.contains(marker))
{
return "<sensitive diagnostic details redacted>".to_string();
}
sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized));
sanitized.chars().take(2_048).collect()
}
fn show_startup_error_dialog(log_path: &Path) {
eprintln!("Genarrative startup failed; see {}", log_path.display());
}
#[derive(Clone, Debug)]
struct GameCreatorAgentLoopResult {
run_id: String,
draft: LlmGameDraft,
spec_markdown: String,
findings_markdown: String,
passes: u8,
steps: Vec<GameCreationAgentRunStep>,
}
fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) -> bool {
matches!(event, tauri::RunEvent::Exit)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GameCreatorGuiRunnerShutdownOutcome {
NotRequested,
Requested,
Failed(GameCreatorGuiRunnerShutdownFailure),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GameCreatorGuiRunnerShutdownFailure {
EndpointUnavailable,
RunnerUnresponsive,
ProcessIdentity,
PlatformUnsupported,
LockTimeout,
Other,
}
impl GameCreatorGuiRunnerShutdownFailure {
fn code(self) -> &'static str {
match self {
Self::EndpointUnavailable => "endpoint_unavailable",
Self::RunnerUnresponsive => "runner_unresponsive",
Self::ProcessIdentity => "process_identity",
Self::PlatformUnsupported => "platform_unsupported",
Self::LockTimeout => "lock_timeout",
Self::Other => "other",
}
}
}
fn classify_game_creator_gui_runner_shutdown_error(
error: &str,
) -> GameCreatorGuiRunnerShutdownFailure {
if error.contains("进程启动身份")
|| error.contains("pid 已")
|| error.contains("pidfd")
|| error.contains("进程句柄")
{
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
} else if error.contains("实例锁") || error.contains("owner 锁") {
GameCreatorGuiRunnerShutdownFailure::LockTimeout
} else if error.contains("endpoint") {
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
} else if error.contains("响应") || error.contains("连接 Agent Runner") {
GameCreatorGuiRunnerShutdownFailure::RunnerUnresponsive
} else {
GameCreatorGuiRunnerShutdownFailure::Other
}
}
fn resolve_game_creator_gui_runner_shutdown<F>(
event: &tauri::RunEvent,
shutdown: F,
) -> GameCreatorGuiRunnerShutdownOutcome
where
F: FnOnce() -> Result<(), String>,
{
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
}
match shutdown() {
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
classify_game_creator_gui_runner_shutdown_error(&error),
),
}
}
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
if matches!(event, tauri::RunEvent::Exit) {
if let Err(error) = agent::shutdown_game_creator_codex_app_servers() {
eprintln!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
}
}
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
GameCreatorGuiRunnerShutdownOutcome::Requested => {
eprintln!("agent.runner.gui_exit.shutdown_requested")
}
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
eprintln!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
}
}
}
/// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃
/// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台
/// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn
/// 派发的活(例如静态委派的父 run 唤醒)都落在这些默认栈的 worker 上,同一段代码
/// 在那里直接 `thread 'tokio-rt-worker' has overflowed its stack` 把整个进程 abort
/// 掉——现场表现是父 run 认领委派回执那一刻 Agent Runner 无声消失,调用方只看到
/// 连接超时。这里在任何异步派发之前把全局 runtime 换成同样栈尺寸的实例。
///
/// `async_runtime::set` 只接受 handle 且要求底层 Runtime 常驻,所以这里刻意泄漏。
fn build_agent_runtime_async_runtime() -> Result<tokio::runtime::Runtime, String> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(crate::agent::AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES)
.build()
.map_err(|error| format!("创建全局异步 runtime 失败:{error}"))
}
#[cfg(not(test))]
fn install_agent_runtime_async_runtime_with_deep_stack() {
let runtime = match build_agent_runtime_async_runtime() {
Ok(runtime) => runtime,
Err(error) => {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
};
tauri::async_runtime::set(runtime.handle().clone());
// 进程生命周期内必须持有,否则 handle 立即失效。
Box::leak(Box::new(runtime));
}
#[cfg(windows)]
fn run_windows_acl_repair_if_requested(args: &[String]) -> Option<i32> {
let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] =
args
else {
if args.first().map(String::as_str) == Some("--repair-private-acl") {
eprintln!(
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
);
return Some(1);
}
return None;
};
if command != "--repair-private-acl"
|| target_user_sid_flag != "--target-user-sid"
|| authorization_flag != "--authorization"
|| scope_flag != "--scope"
{
eprintln!(
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
);
return Some(1);
}
let path = std::path::PathBuf::from(path);
let scope = match config::parse_windows_acl_repair_scope(scope_value) {
Ok(scope) => scope,
Err(error) => {
eprintln!("{error}");
return Some(1);
}
};
let result =
config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope)
.and_then(|()| {
config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid)
});
match result {
Ok(()) => Some(0),
Err(error) => {
eprintln!("AGC ACL 提权修复失败:{error}");
Some(1)
}
}
}
#[cfg(test)]
mod async_runtime_stack_tests {
/// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。
fn consume_stack(depth: usize) -> u64 {
let mut frame = [0_u8; 16 * 1024];
frame[depth % frame.len()] = depth as u8;
let sum = std::hint::black_box(&frame)
.iter()
.map(|byte| u64::from(*byte))
.sum::<u64>();
if depth == 0 {
sum
} else {
sum + consume_stack(depth - 1)
}
}
/// 全局异步 runtime 的 worker 必须和 Runtime 自己的后台线程用同一份栈预算。
/// 掉了 thread_stack_size 时这条不是断言失败而是整个测试进程被 abort——这正是
/// 线上的失效形态:Agent Runner 在父 run 认领委派回执时无声消失。
#[test]
fn async_runtime_workers_hold_a_call_chain_that_overflows_the_default_stack() {
const FRAMES: usize = 192; // 192 × 16 KiB = 3 MiB,超出 tokio 默认栈,远低于 16 MiB
let runtime = super::build_agent_runtime_async_runtime().expect("build async runtime");
let handled =
runtime.block_on(async { tokio::spawn(async { consume_stack(FRAMES - 1) }).await });
assert!(handled.is_ok(), "{handled:?}");
}
}
#[cfg(not(test))]
fn main() {
install_agent_runtime_async_runtime_with_deep_stack();
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) {
std::process::exit(exit_code);
}
#[cfg(windows)]
if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) {
std::process::exit(exit_code);
}
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_trampoline_mode(&args) {
match command_sandbox_trampoline::run_trampoline() {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) {
match command_sandbox_trampoline::run_process_session_trampoline() {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
#[cfg(target_os = "linux")]
if is_process_session_child_mode(&args) {
match run_process_session_child(&args) {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) {
Ok(config_dir) => config_dir,
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
};
if args.first().map(String::as_str) == Some("--agent-runner") {
let gui_owner_required = match args.as_slice() {
[_] => false,
[_, option] if option == "--gui-owner-required" => true,
_ => {
eprintln!(
"用法:--agent-runner [--gui-owner-required] --config-dir <AppData 绝对路径>"
);
std::process::exit(1);
}
};
let Some(config_dir) = runtime_config_dir else {
eprintln!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
std::process::exit(1);
};
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
set_game_creator_runtime_config_dir(config_dir.clone());
if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
return;
}
match parse_cli_command(&args) {
Ok(Some(mut command)) => {
let config_dir =
match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) {
Ok(config_dir) => config_dir,
Err(error) => {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
};
if let Some(config_dir) = config_dir {
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
if command.requires_external_agent_runner() {
let configured = if command.is_read_only_status() {
configure_external_agent_runner_read_only(&config_dir)
} else {
configure_external_agent_runner(&config_dir)
};
if let Err(error) = configured {
eprintln!("agent.runner.failed: {error}");
std::process::exit(1);
}
}
set_game_creator_runtime_config_dir(config_dir);
}
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);
}
}
if let Err(error) = run_cli_command(command) {
eprintln!("agent.run.failed: {error}");
std::process::exit(1);
}
return;
}
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
}
if let Some(config_dir) = runtime_config_dir {
set_game_creator_runtime_config_dir(config_dir);
}
let mut tauri_context = tauri::generate_context!();
let startup_log: Option<PathBuf> = None;
let setup_log = startup_log.clone();
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(context_menu::init())
.manage(game_creator_preview_registry())
.manage(ProjectResourcePreviewReadManager::default())
.setup(move |app| {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin");
}
configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
let _ = append_bounded_diagnostic_line(
path,
&format!("startup.appdata.configure.failed details={details}"),
);
show_startup_error_dialog(path);
}
})?;
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
}
let config_dir = game_creator_runtime_config_dir().ok_or_else(|| {
let error = std::io::Error::new(
std::io::ErrorKind::NotFound,
"客户端 AppData 配置目录未初始化",
);
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(
path,
"startup.appdata.resolve.failed details=config-dir-uninitialized",
);
show_startup_error_dialog(path);
}
error
})?;
load_platform_session_fixture_from_env(&config_dir).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("加载平台登录态测试 fixture 失败:{error}"),
)
})?;
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin");
}
configure_external_agent_runner(&config_dir)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
let _ = append_bounded_diagnostic_line(
path,
&format!("startup.runner.configure.failed details={details}"),
);
show_startup_error_dialog(path);
}
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("配置 Agent Runner 失败:{error}"),
)
})?;
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete");
}
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
let _ = append_bounded_diagnostic_line(
path,
&format!("startup.runner.owner-lock.failed details={details}"),
);
show_startup_error_dialog(path);
}
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("获取 GUI owner 锁失败:{error}"),
)
})?;
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
app.manage(gui_owner_lock);
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin");
}
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
let _ = append_bounded_diagnostic_line(
path,
&format!("startup.runner.attach-owner.failed details={details}"),
);
show_startup_error_dialog(path);
}
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("绑定 Agent Runner GUI owner 失败:{error}"),
)
})?;
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
}
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.setup.complete");
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
create_automatic_local_game_project,
init_local_game_project,
import_local_godot_project,
is_local_project_directory_non_empty,
inspect_local_project_directory,
pick_local_project_directory,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
remove_client_extension,
open_local_project_directory,
open_local_project_plan_gdd_markdown,
control_agent_run,
generate_local_game_draft,
chat_with_game_creator_agent,
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
start_game_creator_agent_runtime_task,
start_game_creator_supervisor_runtime_task,
compact_game_creator_agent_runtime_context,
read_game_creator_agent_goal,
start_game_creator_agent_goal,
edit_game_creator_agent_goal,
pause_game_creator_agent_goal,
resume_game_creator_agent_goal,
clear_game_creator_agent_goal,
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,
decide_game_creator_plan_gdd,
hydrate_game_creator_plan_gdd_state,
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
confirm_resume_game_creator_agent_runtime_tasks,
schedule_game_creator_agent_ready_tasks,
check_game_creator_llm_config,
read_platform_account_session_generation,
install_platform_account_session,
clear_platform_account_session,
read_game_creator_app_config,
write_game_creator_app_config,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
request_local_project_resource_edit_service_identity_confirmation,
confirm_local_project_resource_edit_service_identity,
archive_failed_local_project_resource_edit,
normalize_local_project_raster_resource,
import_canvas_asset,
import_canvas_export,
sync_canvas_project_assets,
import_ui_editor_assets,
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_ui_design_code,
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
open_canvas_project,
get_game_creation_agent_capabilities,
get_limited_local_commands,
run_limited_local_command,
append_local_permission_log,
list_local_project_files,
import_local_project_image_assets,
read_local_project_file,
read_local_project_image_preview,
read_local_project_text_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
delete_local_project_file,
read_local_game_memory,
read_local_agent_memory,
write_local_agent_memory,
write_local_game_memory,
delete_local_game_memory,
list_game_creator_agent_sessions,
create_game_creator_agent_session,
fork_game_creator_agent_session,
set_active_game_creator_agent_session,
archive_game_creator_agent_session,
read_local_conversation,
append_local_conversation_message,
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
read_project_permission_policy,
write_project_permission_policy,
open_game_creator_workspace_window,
open_game_creator_launcher_window,
open_project_supervisor_chat_window,
start_local_game_preview,
activate_local_game_preview,
stop_local_game_preview,
stop_local_game_preview_if_matches,
get_local_game_preview_status,
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
create_local_project_asset_canvas_draft,
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
acknowledge_local_project_asset_canvas_candidate_layers,
import_local_project_asset_canvas_images,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
finalize_local_project_asset_canvas_generation_failure,
archive_failed_local_project_asset_canvas_generation,
generate_local_project_asset_canvas_image,
recover_local_project_asset_canvas_generations,
confirm_local_project_asset_canvas_generation_service_identity,
read_local_project_asset_canvas_media,
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
commit_local_project_asset,
commit_local_project_asset_canvas_candidate,
get_local_game_project_revision,
get_local_game_manifest,
download_agc_update,
])
.build(tauri_context);
let app = match app {
Ok(app) => {
if let Some(path) = startup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.build.complete");
}
app
}
Err(error) => {
if let Some(path) = startup_log.as_deref() {
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
let _ = append_bounded_diagnostic_line(
path,
&format!("startup.build.failed details={details}"),
);
show_startup_error_dialog(path);
}
eprintln!("failed to build Genarrative AI Game Creator shell: {error}");
std::process::exit(1);
}
};
app.run(move |_app_handle, event| handle_game_creator_gui_run_event(&event));
}
#[cfg(test)]
mod diagnostic_log_tests {
use super::*;
#[test]
fn bounded_diagnostic_log_rotates_and_keeps_only_one_previous_file() {
let directory = tempfile::tempdir().expect("create diagnostics directory");
let path = directory.path().join("startup.log");
let first_record = "x".repeat(128);
append_bounded_diagnostic_line_with_limit(&path, &first_record, 64)
.expect("write first record");
append_bounded_diagnostic_line_with_limit(&path, "second-record", 64)
.expect("rotate diagnostic log");
let current = fs::read_to_string(&path).expect("read current diagnostic log");
let previous = fs::read_to_string(path.with_extension("previous.log"))
.expect("read previous diagnostic log");
assert!(current.contains("second-record"));
assert!(previous.contains(&"x".repeat(32)));
}
#[test]
fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() {
assert_eq!(
sanitize_diagnostic_message("Authorization: Bearer secret", None),
"<sensitive diagnostic details redacted>"
);
assert_eq!(
sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None),
"failed at <absolute-path>"
);
assert_eq!(
sanitize_diagnostic_message("failed at /home/example/private/game.json", None),
"failed at <absolute-path>"
);
}
#[test]
fn diagnostic_log_rejects_hardlink_targets_including_rotation_backup() {
let directory = tempfile::tempdir().expect("create diagnostics directory");
let outside = directory.path().join("outside.txt");
fs::write(&outside, "outside-unchanged").expect("write outside target");
let path = directory.path().join("startup.log");
fs::hard_link(&outside, &path).expect("create diagnostic hardlink");
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target"),
"outside-unchanged"
);
fs::remove_file(&path).expect("remove diagnostic hardlink");
fs::write(&path, "rotate-me").expect("write diagnostic file");
let previous = path.with_extension("previous.log");
fs::hard_link(&outside, &previous).expect("create previous hardlink");
assert!(append_bounded_diagnostic_line_with_limit(&path, "blocked", 1).is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target after rotation"),
"outside-unchanged"
);
}
#[cfg(unix)]
#[test]
fn diagnostic_log_rejects_symlink_targets() {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir().expect("create diagnostics directory");
let outside = directory.path().join("outside.txt");
fs::write(&outside, "outside-unchanged").expect("write outside target");
let path = directory.path().join("startup.log");
symlink(&outside, &path).expect("create diagnostic symlink");
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target"),
"outside-unchanged"
);
}
#[cfg(windows)]
#[test]
fn diagnostic_log_rejects_windows_symlink_or_reparse_targets_when_supported() {
use std::os::windows::fs::symlink_file;
let directory = tempfile::tempdir().expect("create diagnostics directory");
let outside = directory.path().join("outside.txt");
fs::write(&outside, "outside-unchanged").expect("write outside target");
let path = directory.path().join("startup.log");
if symlink_file(&outside, &path).is_err() {
return;
}
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target"),
"outside-unchanged"
);
}
}
#[cfg(test)]
mod update_client_tests {
use super::*;
use std::io::{Read, Write};
#[tokio::test]
async fn update_download_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind update fixture");
let address = listener.local_addr().expect("update fixture address");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept update request");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("set update fixture timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 1024];
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).expect("read update request");
assert!(read > 0, "update request closed before headers");
bytes.extend_from_slice(&buffer[..read]);
}
stream
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write update response");
String::from_utf8_lossy(&bytes).into_owned()
});
let client = build_agc_update_download_client();
let response = client
.get(format!("http://{address}/update.exe"))
.send()
.await
.expect("send update request");
let request = server.join().expect("join update fixture");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!request
.to_ascii_lowercase()
.contains("x-genarrative-client:"));
}
}
#[cfg(test)]
mod tests;
pub mod ui_editor;