8e78be766e
修复旧模型定价覆盖缺少ElevenLabs音效模型导致启动恢复持续失败 支持对话框回车发送并保留Shift换行和输入法组合态 重试受理后立即清理旧失败投影并展示新Run状态 阻止Windows后台Codex探测反复弹出控制台窗口 首板试玩持久回执通过后幂等登记初始项目版本 补充定价、交互、Windows与版本登记回归测试和文档
2486 lines
81 KiB
Rust
2486 lines
81 KiB
Rust
#![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,
|
||
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;
|
||
|
||
// 调试落盘模块(保存 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<String>,
|
||
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,
|
||
#[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(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorLlmConfigStatus {
|
||
agent_mode: String,
|
||
configured: bool,
|
||
api_key_present: bool,
|
||
base_url: Option<String>,
|
||
model: Option<String>,
|
||
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<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,
|
||
api_key_present: bool,
|
||
base_url: Option<String>,
|
||
model: Option<String>,
|
||
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<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAppConfigFile {
|
||
agent_mode: Option<String>,
|
||
llm: Option<GameCreatorLlmConfigFile>,
|
||
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
|
||
editor_api: Option<GameCreatorEditorApiConfigFile>,
|
||
mcp_servers: Option<BTreeMap<String, GameCreatorMcpServerConfig>>,
|
||
}
|
||
|
||
#[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_agent_mode")]
|
||
agent_mode: String,
|
||
llm: GameCreatorLlmConfig,
|
||
#[serde(default)]
|
||
agent_llm: BTreeMap<String, GameCreatorLlmConfigFile>,
|
||
editor_api: GameCreatorEditorApiConfig,
|
||
#[serde(default)]
|
||
mcp_servers: BTreeMap<String, GameCreatorMcpServerConfig>,
|
||
}
|
||
|
||
#[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<String>,
|
||
#[serde(default)]
|
||
cwd: String,
|
||
#[serde(default)]
|
||
env: BTreeMap<String, String>,
|
||
#[serde(default)]
|
||
url: String,
|
||
#[serde(default)]
|
||
bearer_token: String,
|
||
#[serde(default)]
|
||
http_headers: BTreeMap<String, String>,
|
||
#[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<String>,
|
||
#[serde(default)]
|
||
disabled_tools: Vec<String>,
|
||
#[serde(default = "default_game_creator_mcp_approval_mode")]
|
||
default_approval_mode: String,
|
||
#[serde(default)]
|
||
tools: BTreeMap<String, GameCreatorMcpToolConfig>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorMcpToolConfig {
|
||
#[serde(default)]
|
||
enabled: Option<bool>,
|
||
#[serde(default)]
|
||
approval_mode: Option<String>,
|
||
}
|
||
|
||
#[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 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)]
|
||
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 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<Mutex<Option<PathBuf>>> = 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<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 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::<Vec<_>>();
|
||
let message_text = format!(
|
||
"应用启动失败。请将以下诊断日志发给开发人员:\n{}",
|
||
log_path.display()
|
||
);
|
||
let message = std::ffi::OsStr::new(&message_text)
|
||
.encode_wide()
|
||
.chain(Some(0))
|
||
.collect::<Vec<_>>();
|
||
// 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<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) {
|
||
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<F>(shutdown: F) -> GameChatReleaseClientExitOutcome
|
||
where
|
||
F: FnOnce() -> Result<bool, String>,
|
||
{
|
||
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::<Vec<_>>();
|
||
#[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 <AppData 绝对路径>"
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
let Some(config_dir) = runtime_config_dir else {
|
||
eprintln!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 = 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,
|
||
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,
|
||
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,
|
||
create_local_project_asset_canvas_draft,
|
||
read_local_project_asset_canvas_draft,
|
||
discover_local_project_asset_canvas_draft,
|
||
update_local_project_asset_canvas_draft,
|
||
store_local_project_asset_canvas_media,
|
||
stage_local_project_asset_canvas_image,
|
||
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,
|
||
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),
|
||
"<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 tests;
|