#![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::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; 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 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, 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; // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; mod agent_native_tools; mod assets; mod browser; mod cli; mod collaboration; mod command_exec; mod command_output; mod command_sandbox; mod command_sandbox_trampoline; mod commands; mod config; mod context_compaction; #[cfg(all(debug_assertions, not(test)))] mod debug; mod delegation; mod git_inspect; mod goal; mod image_inspect; mod isolated_agent; mod mcp; mod patchset; 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 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 mcp::*; use patchset::*; 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::*; #[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, project_name: Option, manifest_error: Option, recent_run_status: Option, recent_run_stop_reason: Option, } #[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, port: Option, root: Option, } #[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, session_id: Option, runtime_status: Option, runtime_phase: Option, runtime_summary: Option, runtime_state: Option, } #[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, #[serde(default)] parent_run_id: Option, #[serde(default)] delegation_id: Option, #[serde(default)] status: String, #[serde(default)] phase: String, #[serde(default)] current_task: String, #[serde(default)] current_goal: String, #[serde(default)] goal_id: Option, #[serde(default)] goal_revision: u64, #[serde(default)] goal_status: Option, #[serde(default)] goal_outcome: Option, #[serde(default)] goal_constraints: Vec, #[serde(default)] goal_verification: Vec, #[serde(default)] current_action: String, #[serde(default)] waiting_on: String, #[serde(default)] next_step: String, #[serde(default)] loop_iteration: 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, #[serde(default)] plan_steps: Vec, #[serde(default)] active_plan_step_index: Option, #[serde(default)] observations: Vec, #[serde(default)] recent_tool_calls: Vec, #[serde(default)] pending_tool_action: Option, #[serde(default)] task_queue: AgentRuntimeTaskQueueSummary, #[serde(default)] allowed_tools: Vec, #[serde(default)] tool_policy: AgentRuntimeToolPolicySnapshot, #[serde(default)] applied_steer_cursor: u64, #[serde(default)] applied_steer_refs: Vec, #[serde(default)] queued_steer_count: u32, #[serde(default)] context_usage: AgentRuntimeContextUsage, #[serde(default)] last_response: Option, #[serde(default)] error: Option, #[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, #[serde(default)] last_completion_tokens: Option, #[serde(default)] last_total_tokens: Option, #[serde(default)] compaction_revision: u64, #[serde(default)] compaction_count: u64, #[serde(default)] last_compaction_trigger: Option, #[serde(default)] last_compacted_at: Option, } #[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, #[serde(default)] auto_tools: Vec, #[serde(default)] confirm_tools: Vec, #[serde(default)] denied_tools: Vec, #[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, #[serde(default)] tool: String, #[serde(default)] status: String, #[serde(default)] action_fingerprint: Option, #[serde(default)] input_summary: Option, #[serde(default)] reason: Option, #[serde(default)] summary: String, #[serde(default)] detail: Option, #[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, #[serde(default)] reason: Option, #[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, #[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, #[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, #[serde(default)] status: String, #[serde(default)] phase: String, #[serde(default)] summary: String, #[serde(default)] public_text: Option, #[serde(default)] detail: Option, #[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, 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, #[serde(default)] parent_run_id: Option, #[serde(default)] delegation_id: Option, #[serde(default)] goal_id: Option, #[serde(default)] goal_revision: u64, #[serde(default)] goal_status: Option, #[serde(default)] task: String, #[serde(default)] status: String, #[serde(default)] phase: String, #[serde(default)] current_action: String, #[serde(default)] terminal_detail: Option, #[serde(default)] error: Option, #[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, verification: Vec, #[serde(default)] completion_evidence: Vec, #[serde(default)] response_fingerprint: Option, created_at: u64, #[serde(default)] pause_requested_at: Option, #[serde(default)] paused_at: Option, #[serde(default)] completed_at: Option, #[serde(default)] cleared_at: Option, #[serde(default)] error: Option, 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, session_path: String, event_path: String, task_path: String, task_queue: AgentRuntimeTaskQueueSummary, recent_events: Vec, recent_tasks: Vec, response_stream: Option, user_input_request: Option, } #[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, #[serde(skip_serializing_if = "Option::is_none")] interrupt_decision: Option, #[serde(skip_serializing_if = "Option::is_none")] decision_reason: Option, } #[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(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigStatus { agent_mode: String, configured: bool, api_key_present: bool, base_url: Option, model: Option, api_kind: String, 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, agents: Vec, } #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentLlmConfigStatus { agent_mode: String, agent_id: String, label: String, configured: bool, api_key_present: bool, base_url: Option, model: Option, api_kind: String, 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, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAppConfigFile { agent_mode: Option, llm: Option, agent_llm: Option>, editor_api: Option, mcp_servers: Option>, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigFile { #[serde(skip_serializing_if = "Option::is_none")] api_key: Option, #[serde(skip_serializing_if = "Option::is_none")] base_url: Option, #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] api_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] stream: Option, #[serde(skip_serializing_if = "Option::is_none")] web_search_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] context_window_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] auto_compact_token_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_output_token_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] request_timeout_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] max_retries: Option, #[serde(skip_serializing_if = "Option::is_none")] retry_backoff_ms: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorEditorApiConfigFile { base_url: Option, api_key: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAppConfig { #[serde(default = "default_game_creator_agent_mode")] agent_mode: String, llm: GameCreatorLlmConfig, #[serde(default)] agent_llm: BTreeMap, editor_api: GameCreatorEditorApiConfig, #[serde(default)] mcp_servers: BTreeMap, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorMcpServerConfig { #[serde(default = "default_game_creator_mcp_enabled")] enabled: bool, #[serde(default)] required: bool, #[serde(default = "default_game_creator_mcp_transport")] transport: String, #[serde(default)] command: String, #[serde(default)] args: Vec, #[serde(default)] cwd: String, #[serde(default)] env: BTreeMap, #[serde(default)] url: String, #[serde(default)] bearer_token: String, #[serde(default)] http_headers: BTreeMap, #[serde(default)] allow_insecure_localhost: bool, #[serde(default = "default_game_creator_mcp_startup_timeout_ms")] startup_timeout_ms: u64, #[serde(default = "default_game_creator_mcp_tool_timeout_ms")] tool_timeout_ms: u64, #[serde(default)] enabled_tools: Vec, #[serde(default)] disabled_tools: Vec, #[serde(default = "default_game_creator_mcp_approval_mode")] default_approval_mode: String, #[serde(default)] tools: BTreeMap, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorMcpToolConfig { #[serde(default)] enabled: Option, #[serde(default)] approval_mode: Option, } #[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, trigger: String, revision: u64, estimated_tokens_before: u64, estimated_tokens_after: u64, prompt_tokens: Option, completion_tokens: Option, total_tokens: Option, 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 ImportCanvasExportResult { import_root: String, metadata_path: String, imported_count: usize, assets: Vec, } #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct SyncCanvasProjectAssetsResult { canvas_project_id: String, import_root: String, imported_count: usize, assets: Vec, } #[derive(Debug, Eq, PartialEq)] struct GeneratedPlatformArtAssetSlice { name: String, width: u32, height: u32, local_path: String, resource_id: Option, asset_object_id: Option, content_sha256: String, pixel_sha256: String, } #[derive(Debug, Eq, PartialEq)] struct GeneratedPlatformArtAsset { asset: UploadLocalAssetResult, slices: Vec, resource_id: Option, asset_object_id: Option, task_id: Option, model: Option, warning: Option, slice_warning: Option, } #[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, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CanvasExportLayerMetadata { title: String, file: Option, visible: CanvasExportVisibleMetadata, export_error: Option, } #[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, } #[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, } #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalConversationMessageRecord { schema_version: String, role: String, content: String, agent_id: Option, message_id: Option, updated_at: u64, } #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalConversationResult { path: String, agent_id: Option, session_id: Option, messages: Vec, } #[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, message_count: u64, legacy: bool, #[serde(default, skip_serializing_if = "Option::is_none")] forked_from_session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] forked_message_count: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentConversationSessionListResult { path: String, agent_id: String, active_session_id: String, sessions: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ProjectPermissionPolicy { denied_commands: Vec, confirm_commands: Vec, #[serde(default)] agent_policies: BTreeMap, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ProjectAgentPermissionPolicy { #[serde(default)] denied_commands: Vec, #[serde(default)] confirm_commands: Vec, } #[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, } #[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, } #[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, changed: Vec, deleted: Vec, } #[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#" Genarrative Game Draft
还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。
"#; 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 DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2; fn default_game_creator_agent_mode() -> String { GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.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 } const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; 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>> = OnceLock::new(); impl Default for GameCreatorAppConfig { fn default() -> Self { Self { agent_mode: default_game_creator_agent_mode(), llm: GameCreatorLlmConfig::default(), agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), mcp_servers: BTreeMap::new(), } } } 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: false, 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 { 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, } #[derive(Clone, Debug)] struct AgentPassAgenda { relative_path: String, task_graph_relative_path: String, active_task_ids: Vec, carried_task_ids: Vec, dependency_waves: Vec>, repair_focus: Vec, repair_routes: Vec, 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, 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, next: String, } const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024; static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); static STARTUP_PANIC_LOG_PATH: OnceLock = 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 { 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(""); 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::>(); 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(""); 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(), ""); } } 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 "".to_string(); } sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } fn initialize_game_chat_startup_log(identifier: &str) -> PathBuf { let appdata_path = std::env::var_os("APPDATA") .map(PathBuf::from) .unwrap_or_else(std::env::temp_dir) .join(identifier) .join("startup.log"); if append_bounded_diagnostic_line(&appdata_path, "startup.begin").is_ok() { return appdata_path; } let fallback_path = std::env::temp_dir() .join("Genarrative-Game-Chat-Diagnostics") .join("startup.log"); let _ = append_bounded_diagnostic_line( &fallback_path, "startup.begin appdata-log-unavailable=true", ); fallback_path } fn install_startup_panic_log(path: PathBuf) { if STARTUP_PANIC_LOG_PATH.set(path).is_err() { return; } let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { if let Some(path) = STARTUP_PANIC_LOG_PATH.get() { let location = info .location() .map(|location| { let file = Path::new(location.file()) .file_name() .and_then(|name| name.to_str()) .unwrap_or("unknown"); format!("{file}:{}:{}", location.line(), location.column()) }) .unwrap_or_else(|| "unknown".to_string()); let _ = append_bounded_diagnostic_line( path, &format!("startup.panic location={location} details=redacted"), ); } previous(info); })); } #[cfg(windows)] fn show_startup_error_dialog(log_path: &Path) { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::UI::WindowsAndMessaging::{ MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, }; if STARTUP_ERROR_DIALOG_SHOWN.swap(true, AtomicOrdering::AcqRel) { return; } let title = std::ffi::OsStr::new("Genarrative Game Chat") .encode_wide() .chain(Some(0)) .collect::>(); let message_text = format!( "应用启动失败。请将以下诊断日志发给开发人员:\n{}", log_path.display() ); let message = std::ffi::OsStr::new(&message_text) .encode_wide() .chain(Some(0)) .collect::>(); // SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call. unsafe { MessageBoxW( std::ptr::null_mut(), message.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR | MB_SETFOREGROUND, ); } } #[cfg(not(windows))] fn show_startup_error_dialog(log_path: &Path) { if STARTUP_ERROR_DIALOG_SHOWN.swap(true, AtomicOrdering::AcqRel) { return; } eprintln!( "Genarrative Game Chat 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, } 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( 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) { 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()) } } } #[derive(Debug, Eq, PartialEq)] enum GameChatReleaseClientExitOutcome { Shutdown, Busy, Failed(String), } fn resolve_game_chat_release_client_exit(shutdown: F) -> GameChatReleaseClientExitOutcome where F: FnOnce() -> Result, { match shutdown() { Ok(true) => GameChatReleaseClientExitOutcome::Shutdown, Ok(false) => GameChatReleaseClientExitOutcome::Busy, Err(error) => GameChatReleaseClientExitOutcome::Failed(error), } } fn show_game_chat_release_client_exit_blocked(app: &tauri::AppHandle) { app.dialog() .message("当前仍有游戏创作任务或 Provider 请求在运行。为避免结果丢失,已阻止关闭;请先等待任务完成,或在任务页暂停/取消后再退出。") .title("游戏创作任务仍在运行") .show(|_| {}); } #[cfg(test)] mod game_chat_release_client_exit_tests { use super::*; #[test] fn client_exit_resolution_distinguishes_shutdown_busy_and_failure() { assert_eq!( resolve_game_chat_release_client_exit(|| Ok(true)), GameChatReleaseClientExitOutcome::Shutdown ); assert_eq!( resolve_game_chat_release_client_exit(|| Ok(false)), GameChatReleaseClientExitOutcome::Busy ); assert_eq!( resolve_game_chat_release_client_exit(|| Err("runner unavailable".to_string())), GameChatReleaseClientExitOutcome::Failed("runner unavailable".to_string()) ); } } #[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[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 explicit_game_chat_launch = match parse_game_chat_launch_args(&args) { Ok(options) => options, Err(error) => { eprintln!("{error}"); std::process::exit(1); } }; let game_chat_launch = match select_game_chat_launch_options( explicit_game_chat_launch, cfg!(debug_assertions), cfg!(feature = "game-chat-release"), ) { Ok(options) => options, Err(error) => { eprintln!("{error}"); std::process::exit(1); } }; 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 " ); std::process::exit(1); } }; let Some(config_dir) = runtime_config_dir else { eprintln!("Agent Runner 必须显式传入 --config-dir "); 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 { 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); } } let mut tauri_context = tauri::generate_context!(); let startup_log = if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { let path = initialize_game_chat_startup_log(&tauri_context.config().identifier); install_startup_panic_log(path.clone()); Some(path) } else { None }; if let Some(options) = game_chat_launch.as_ref() { if let Err(error) = apply_game_chat_initial_window_url(tauri_context.config_mut(), options) { if let Some(path) = startup_log.as_deref() { let details = sanitize_diagnostic_message(error.as_str(), path.parent()); let _ = append_bounded_diagnostic_line( path, &format!("startup.window-url.failed details={details}"), ); show_startup_error_dialog(path); } eprintln!("{error}"); std::process::exit(1); } } if let Some(path) = startup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.context.ready"); } 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()) .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 })?; 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}"), ) })?; app.manage(gui_owner_lock); if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin"); } ensure_external_agent_runner_started_for_gui() .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.start.failed details={details}"), ); show_startup_error_dialog(path); } }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::Other, format!("启动 Agent Runner 失败:{error}"), ) })?; 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) .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"); } #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; } if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.setup.complete"); } Ok(()) }) .invoke_handler(tauri::generate_handler![ 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, open_local_project_directory, 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, 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, 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_game_creator_app_config, write_game_creator_app_config, read_game_creator_mcp_catalog, upload_local_asset, register_local_asset, import_canvas_asset, import_canvas_export, sync_canvas_project_assets, 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, 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, get_local_game_project_revision, get_local_game_manifest ]) .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); } }; if let Some(path) = startup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.run.begin"); } let shutdown_log = startup_log.clone(); app.run(move |app_handle, event| { let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release")); let game_chat_exit_requested = game_chat_release && matches!( &event, tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::CloseRequested { .. }, .. } | tauri::RunEvent::ExitRequested { .. } ); if game_chat_exit_requested { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, "startup.runner.shutdown-for-client-exit.begin", ); } let outcome = resolve_game_chat_release_client_exit( shutdown_external_agent_runner_for_client_exit, ); match &outcome { GameChatReleaseClientExitOutcome::Shutdown => { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, "startup.runner.shutdown-for-client-exit.complete", ); } } GameChatReleaseClientExitOutcome::Busy => { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, "startup.runner.shutdown-for-client-exit.busy", ); } } GameChatReleaseClientExitOutcome::Failed(error) => { if let Some(path) = shutdown_log.as_deref() { let details = sanitize_diagnostic_message(&error, path.parent()); let _ = append_bounded_diagnostic_line( path, &format!( "startup.runner.shutdown-for-client-exit.failed details={details}" ), ); } eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}") } } if outcome != GameChatReleaseClientExitOutcome::Shutdown { match &event { tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::CloseRequested { api, .. }, .. } => api.prevent_close(), tauri::RunEvent::ExitRequested { api, .. } => api.prevent_exit(), _ => {} } show_game_chat_release_client_exit_blocked(app_handle); } } else if !game_chat_release { handle_game_creator_gui_run_event(&event); } }); if let Some(path) = startup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.run.complete"); } } #[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), "" ); assert_eq!( sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), "failed at " ); assert_eq!( sanitize_diagnostic_message("failed at /home/example/private/game.json", None), "failed at " ); } #[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 tests;