diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 31b5b2e81..8136d39f6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,10 @@ "Bash(findstr LISTENING)", "Bash(npx tsc:*)", "Bash(lsof -ti:8081)", - "Bash(curl -s http://localhost:8081/health)" + "Bash(curl -s http://localhost:8081/health)", + "Bash(npm -v)", + "Bash(cargo --version)", + "Bash(rustc --version)" ] } } diff --git a/.gitignore b/.gitignore index b28197188..633a781d7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/gen/ /apps/ai-game-creator-shell/src-tauri/logs/ /apps/ai-game-creator-shell/logs/ +/apps/ai-game-creator-shell/.llm-drafts/ /apps/mobile-shell/.expo/ /apps/mobile-shell/.expo-export-smoke/ /server-rs/.spacetimedb/ @@ -54,6 +55,7 @@ temp*build*/ .worktrees/ .rag/ .env.secrets.local +.claude/settings.local.json spacetime.local.json deploy/container/api-server.env deploy/container/worker-smoke/ diff --git a/apps/ai-game-creator-shell/src-tauri/src/debug.rs b/apps/ai-game-creator-shell/src-tauri/src/debug.rs new file mode 100644 index 000000000..8172834ff --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/debug.rs @@ -0,0 +1,3 @@ +pub(crate) mod debug_drafts; + +pub(crate) use debug_drafts::*; \ No newline at end of file diff --git a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs new file mode 100644 index 000000000..8e0d606a3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs @@ -0,0 +1,100 @@ +//! 调试落盘:保存 LLM 原始输出 / 失败输入,便于排查截断、空返回等问题。 +//! +//! 本模块用 `#[cfg(all(debug_assertions, not(test)))]` 声明——仅在开发(debug) +//! 且非测试构建中编入二进制;生产 release 与 cargo test 下整个模块与其调用处一并被剔除, +//! 不会往仓库写任何文件。 + +use std::fs; +use std::path::PathBuf; +use crate::unix_millis; + +// 找到仓库根(包含 apps/ai-game-creator-shell/src-tauri/Cargo.toml 的目录), +// 以便把草案落到仓库内而非 tmp 项目目录。 +fn repo_root() -> Option { + let mut roots = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + roots.push(cwd); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + roots.push(parent.to_path_buf()); + } + } + for root in roots { + for directory in root.ancestors().take(8) { + let marker = directory + .join("apps") + .join("ai-game-creator-shell") + .join("src-tauri") + .join("Cargo.toml"); + if marker.is_file() { + return Some(directory.to_path_buf()); + } + } + } + None +} + +// 把生成器原始返回(含被截断、解析失败的内容)保存到仓库 .llm-drafts/, +// 便于排查 “EOF while parsing a string” 这类输出截断问题。尽力而为,不阻断主流程。 +pub(crate) fn persist_snapshot(raw_content: &str) { + let Some(repo_root) = repo_root() else { + eprintln!("llm.draft.snapshot.skip: 未能定位仓库根目录"); + return; + }; + let dir = repo_root + .join("apps") + .join("ai-game-creator-shell") + .join(".llm-drafts"); + if let Err(error) = fs::create_dir_all(&dir) { + eprintln!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display()); + return; + } + let path = dir.join(format!("draft-{}.txt", unix_millis())); + if let Err(error) = fs::write(&path, raw_content) { + eprintln!( + "llm.draft.snapshot.write.failed: {}: {error}", + path.display() + ); + return; + } + // 同步更新 latest.txt,方便直接打开最近一次草案。 + let latest = dir.join("latest.txt"); + let _ = fs::write(&latest, raw_content); + eprintln!("llm.draft.snapshot.saved: {}", path.display()); +} + +// LLM 调用失败(例如返回内容为空)时,把本次发送给模型的输入(system + 完整 user prompt) +// 连同错误信息一起落盘到 .llm-drafts/,便于按同样输入复现与定位。尽力而为,不阻断主流程。 +pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: &str) { + let Some(repo_root) = repo_root() else { + eprintln!("llm.draft.error-input.skip: 未能定位仓库根目录"); + return; + }; + let dir = repo_root + .join("apps") + .join("ai-game-creator-shell") + .join(".llm-drafts"); + if let Err(io_error) = fs::create_dir_all(&dir) { + eprintln!( + "llm.draft.error-input.dir.failed: {}: {io_error}", + dir.display() + ); + return; + } + let body = format!( + "# LLM 生成失败输入快照\n\n## 错误\n{error}\n\n## System Prompt\n{system_prompt}\n\n## User Prompt(含用户需求/短长期记忆/spec/agenda/组 brief/findings)\n{user_prompt}\n", + ); + let path = dir.join(format!("error-input-{}.txt", unix_millis())); + if let Err(io_error) = fs::write(&path, &body) { + eprintln!( + "llm.draft.error-input.write.failed: {}: {io_error}", + path.display() + ); + return; + } + // 同步更新 latest-error-input.txt,方便直接打开最近一次失败输入。 + let latest = dir.join("latest-error-input.txt"); + let _ = fs::write(&latest, &body); + eprintln!("llm.draft.error-input.saved: {}", path.display()); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3bcb8784d..d3272f533 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -34,6 +34,11 @@ use shared_contracts::game_creation_app::{ use tauri::Emitter; use tauri_plugin_opener::OpenerExt; +// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 +// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 +#[cfg(all(debug_assertions, not(test)))] +mod debug; + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct InitLocalProjectResult { @@ -364,7 +369,7 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" "#; const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000"; -const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 5000; +const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900; const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000; const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3; @@ -1465,6 +1470,7 @@ async fn request_llm_game_draft_with_client( "", "", "", + None, ) .await } @@ -1632,6 +1638,7 @@ async fn run_game_creator_agent_loop_at( &findings_markdown, &group_briefs_markdown, &read_optional_text(&root.join(&agenda.relative_path))?, + progress, ) .await { @@ -1814,23 +1821,61 @@ async fn request_generator_game_draft_with_client( findings_markdown: &str, group_briefs_markdown: &str, agenda_markdown: &str, + progress: Option<&AgentProgressEmitter<'_>>, ) -> Result { - let request = LlmTextRequest::new(vec![ - LlmMessage::system(game_creator_system_prompt()), - LlmMessage::user(game_creator_generator_user_prompt( - prompt, - short_memory, - long_memory, - spec_markdown, - findings_markdown, - group_briefs_markdown, - agenda_markdown, - )), - ]) - .with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); - let response = request_game_creator_llm_text(client, request) - .await - .map_err(|error| format!("LLM 生成失败:{error}"))?; + let system_prompt = game_creator_system_prompt(); + let user_prompt = game_creator_generator_user_prompt( + prompt, + short_memory, + long_memory, + spec_markdown, + findings_markdown, + group_briefs_markdown, + agenda_markdown, + ); + // deepseek-v4-pro 等推理模型偶发返回空 content(HTTP 200、completion_tokens=0, + // 参见 DeepSeek-V3 issue #1453)。这类空返回是上游瞬时故障,原样重发通常即可恢复, + // 因此仅对 EmptyResponse 最多重试 3 次(含首次共 4 次请求),其它错误不重试。 + const MAX_EMPTY_RETRIES: u32 = 3; + let mut empty_retries = 0u32; + let response = loop { + let request = LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt.clone()), + ]) + .with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); + match request_game_creator_llm_text(client, request).await { + Ok(response) => break response, + Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { + empty_retries += 1; + eprintln!( + "llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)" + ); + // 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。 + emit_agent_progress( + progress, + "llm.generator.empty_retry", + &format!( + "Generator 收到空返回(DeepSeek 偶发),正在自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" + ), + ); + continue; + } + Err(error) => { + // 调用失败(含空返回重试耗尽)时,把本次输入连同错误一并落盘,便于复现定位(仅 debug 构建)。 + #[cfg(all(debug_assertions, not(test)))] + debug::persist_error_input( + system_prompt, + user_prompt.as_str(), + &error.to_string(), + ); + return Err(format!("LLM 生成失败:{error}")); + } + } + }; + // 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。 + #[cfg(all(debug_assertions, not(test)))] + debug::persist_snapshot(response.content.as_str()); let content = strip_llm_thinking_blocks(response.content.as_str()); parse_llm_game_draft_response(content.as_str()) }