diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index e7623bcbf..9f12c81c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -58,6 +58,69 @@ pub(crate) async fn generate_local_game_draft_at( Ok(result) } +pub(crate) async fn chat_with_game_creator_agent_at( + root: &Path, + prompt: &str, +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + validate_project_root(root)?; + + let short_memory = read_optional_text(&root.join("memory/session.md"))?; + let long_memory = read_optional_text(&root.join("memory/project.md"))?; + let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; + let asset_context = render_local_asset_prompt_context(root)?; + let conversation_context = render_local_conversation_prompt_context(root, None)?; + let context = [ + ("短期记忆", short_memory.as_str()), + ("长期记忆", long_memory.as_str()), + ("项目黑板", project_blackboard.as_str()), + ("资产上下文", asset_context.as_str()), + ("最近项目对话", conversation_context.as_str()), + ] + .into_iter() + .filter_map(|(title, content)| { + let content = truncate_prompt_context(content); + if content.trim().is_empty() { + None + } else { + Some(format!("# {title}\n\n{content}")) + } + }) + .collect::>() + .join("\n\n"); + + let app_config = load_game_creator_app_config()?; + let llm = resolve_game_creator_llm_config_for_agent(&app_config, "chat"); + let client = build_game_creator_llm_client_from_llm_config(&llm, "agentLlm.chat")?; + let user_prompt = if context.trim().is_empty() { + format!("用户这轮输入:\n{prompt}") + } else { + format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}") + }; + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_chat_agent_system_prompt()), + LlmMessage::user(user_prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("主聊天 Agent 调用 LLM 失败:{error}"))?; + let reply_text = strip_llm_thinking_blocks(response.text.as_str()); + if reply_text.is_empty() { + return Err("主聊天 Agent 未返回内容".to_string()); + } + + Ok(GameCreatorChatAgentReply { reply_text }) +} + +pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str { + "你是 Genarrative AI 游戏创作桌面 App 的主聊天 Agent。你要像正常协作型聊天助手一样回应用户,理解需求、澄清不确定点、给出下一步建议,并在需要执行生成、运行、预览、读取文件、写记忆或生成美术时建议用户使用现有 slash 命令。普通聊天中不要假装已经写入文件、生成游戏、调用画板或执行工具;不要输出 JSON;不要泄露密钥;回复保持简洁、具体、中文优先。" +} + pub(crate) fn write_local_game_draft_at( root: &Path, prompt: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index b35227565..fe45ff391 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -227,6 +227,16 @@ pub(crate) async fn generate_local_game_draft( .await } +#[tauri::command] +pub(crate) async fn chat_with_game_creator_agent( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + chat_with_game_creator_agent_at(root, prompt.trim()).await +} + #[tauri::command] pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() 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 eb4a8c0a3..22ea79eea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -109,6 +109,12 @@ struct GenerateLocalGameDraftResult { 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 GameCreatorAgentProgressEvent { @@ -498,6 +504,7 @@ const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; 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; 2] = ["planner", "generator"]; @@ -770,6 +777,10 @@ struct GameCreatorLlmAgentStatusDefinition { fn game_creator_llm_agent_status_definitions() -> Vec { let mut agents = vec![ + GameCreatorLlmAgentStatusDefinition { + agent_id: "chat".to_string(), + label: "主聊天 Agent".to_string(), + }, GameCreatorLlmAgentStatusDefinition { agent_id: "planner".to_string(), label: "Planner".to_string(), @@ -913,6 +924,7 @@ fn main() { open_local_project_directory, control_agent_run, generate_local_game_draft, + chat_with_game_creator_agent, check_game_creator_llm_config, read_game_creator_app_config, write_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 14dc09b0c..f1900e130 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -842,6 +842,75 @@ async fn generate_local_game_draft_sends_asset_context_to_llm() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn chat_with_game_creator_agent_uses_project_context_and_chat_llm_route() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("memory/session.md"), "短期记忆:用户偏好轻快节奏") + .expect("write session memory"); + fs::write(root.join("memory/project.md"), "长期记忆:项目核心是月光厨房") + .expect("write project memory"); + fs::write(root.join(PROJECT_BLACKBOARD_MEMORY_PATH), "黑板:角色要先有规范图") + .expect("write blackboard memory"); + upload_local_asset_at(&root, "hero.png", "image/png", b"fake-png").expect("asset upload"); + append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "上一轮:主角必须挥舞月光锅铲".to_string(), + agent_id: None, + }, + ) + .expect("append project conversation"); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec!["可以,我们先把角色规范图定清楚。".to_string()], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "llm": {{ + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model", + "apiKind": "openai_responses" + }}, + "agentLlm": {{ + "chat": {{ + "apiKey": "chat-key", + "baseUrl": {base_url:?}, + "model": "chat-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let reply = chat_with_game_creator_agent_at(&root, "我要生成一个月光厨师角色图") + .await + .expect("chat reply"); + + assert_eq!(reply.reply_text, "可以,我们先把角色规范图定清楚。"); + let request = receiver + .recv_timeout(Duration::from_secs(1)) + .expect("captured chat llm request"); + assert!(request.contains("POST /responses HTTP/1.1")); + assert!(request.contains("chat-model")); + assert!(request.contains("我要生成一个月光厨师角色图")); + assert!(request.contains("短期记忆:用户偏好轻快节奏")); + assert!(request.contains("长期记忆:项目核心是月光厨房")); + assert!(request.contains("黑板:角色要先有规范图")); + assert!(request.contains("上一轮:主角必须挥舞月光锅铲")); + assert!(request.contains("# 本地项目资产")); + assert!(request.contains("hero.png")); + assert!(!request.contains("global-model")); + assert!(!request.contains("global-key")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_loop_uses_per_agent_llm_overrides() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index a203f554e..c1fb64eec 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -89,6 +89,10 @@ interface GenerateLocalGameDraftResult { manifest: GameCreationAppManifest; } +interface GameCreatorChatAgentReply { + replyText: string; +} + interface GameCreatorLlmConfigStatus { configured: boolean; apiKeyPresent: boolean; @@ -1861,6 +1865,8 @@ const capabilityAreaLabels: Record< }; const chatCommandHelp = [ + '直接输入普通文本:和主聊天 Agent 对话', + '/generate 创作想法:生成本地游戏草案', '/project /绝对路径:设置本地项目目录', '/config:打开运行时配置', '/llm-status:检查 LLM 配置', @@ -1984,6 +1990,9 @@ function missingChatCommandArgumentMessage(prompt: string) { switch (prompt) { case '/project': return '格式:/project /绝对路径'; + case '/generate': + case '/draft': + return '格式:/generate 创作想法'; case '/diff': return '格式:/diff checkpoint-id'; case '/restore': @@ -8901,6 +8910,7 @@ export function App() { const [preview, setPreview] = useState(null); const [previewStatus, setPreviewStatus] = useState('未启动'); const [chatInput, setChatInput] = useState(''); + const [chatAgentBusy, setChatAgentBusy] = useState(false); const chatInputRef = useRef(null); const [assetStatus, setAssetStatus] = useState('未上传'); const [uploadedAssets, setUploadedAssets] = useState< @@ -10435,6 +10445,34 @@ export function App() { return; } + if (prompt.startsWith('/generate ') || prompt.startsWith('/draft ')) { + if (!requireChatProjectForUserAction()) { + return; + } + const generationPrompt = prompt + .slice(prompt.startsWith('/generate ') ? '/generate '.length : '/draft '.length) + .trim(); + if (!generationPrompt) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '格式:/generate 创作想法' }, + ]); + return; + } + queuePendingCommand({ + id: 'game.generate_draft', + prompt: generationPrompt, + }); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `准备生成本地游戏草案:${generationPrompt}`, + }, + ]); + return; + } + if (prompt === '/status') { void executeProjectStatus(true); return; @@ -12346,14 +12384,7 @@ export function App() { return; } - if (!requireChatProjectForUserAction()) { - return; - } - queuePendingCommand({ id: 'game.generate_draft', prompt }); - setMessages((current) => [ - ...current, - { role: 'assistant', text: `准备生成本地游戏草案:${prompt}` }, - ]); + void executeChatAgentReply(prompt); } async function executeLlmConfigStatus() { @@ -12464,6 +12495,64 @@ export function App() { ]); } + async function executeChatAgentReply( + prompt: string, + confirmedPolicyCommands: GameCreationAppCommandDescriptor['id'][] = [], + ) { + const nextProjectPath = requireChatProjectForUserAction(); + if (!nextProjectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + return; + } + + try { + if ( + !confirmedPolicyCommands.includes('conversation.read') && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'conversation.read', + nextProjectPath, + `读取 ${nextProjectPath} 的项目上下文并发送给主聊天 Agent`, + '准备发送给主聊天 Agent。', + () => void executeChatAgentReply(prompt, ['conversation.read']), + )) + ) { + return; + } + setChatAgentBusy(true); + setCommandLog((current) => [...current, 'agent.chat']); + const result = await invoke( + 'chat_with_game_creator_agent', + { projectPath: nextProjectPath, prompt }, + ); + setMessages((current) => [ + ...current, + { role: 'assistant', text: result.replyText }, + ]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: message, + }, + ]); + } finally { + setChatAgentBusy(false); + } + } + async function executeGameDraft(prompt: string) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -17057,7 +17146,9 @@ export function App() { placeholder="例如:像素风横版动作小游戏" onChange={(event) => setChatInput(event.currentTarget.value)} /> - +
diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index da8eb2bfa..ef96f51f6 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1574,6 +1574,14 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -8363,6 +8371,11 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -8901,6 +8914,11 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -9511,6 +9529,11 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -9613,9 +9636,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:/tmp/authorized-game')).not.toBeNull(); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', { projectPath: '/tmp/authorized-game', @@ -9904,6 +9925,14 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -9964,9 +9993,9 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ '第一条创作需求', - '准备生成本地游戏草案:第一条创作需求', + '主聊天回复:第一条创作需求', '第二条创作需求', - '准备生成本地游戏草案:第二条创作需求', + '主聊天回复:第二条创作需求', ]); }); expect( @@ -10003,6 +10032,11 @@ describe('AI 游戏创作 App 界面边界', () => { }, }; } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -10053,7 +10087,7 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ '需要确认保存的需求', - '准备生成本地游戏草案:需要确认保存的需求', + '主聊天回复:需要确认保存的需求', ]); }); }); @@ -10086,6 +10120,11 @@ describe('AI 游戏创作 App 界面边界', () => { }, }; } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return { path: '/tmp/authorized-game/.agent/conversations/project.jsonl', @@ -10149,9 +10188,9 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ '先不保存的需求', - '准备生成本地游戏草案:先不保存的需求', + '主聊天回复:先不保存的需求', '继续补充一条', - '准备生成本地游戏草案:继续补充一条', + '主聊天回复:继续补充一条', ]); }); }); @@ -10181,6 +10220,14 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command === 'read_local_conversation') { return makeConversationResult(); } @@ -10223,9 +10270,9 @@ describe('AI 游戏创作 App 界面边界', () => { await waitFor(() => { expect(savedContents).toEqual([ '第一条创作需求', - '准备生成本地游戏草案:第一条创作需求', + '主聊天回复:第一条创作需求', '第二条创作需求', - '准备生成本地游戏草案:第二条创作需求', + '主聊天回复:第二条创作需求', ]); }); }); @@ -18373,7 +18420,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config'); }); - it('uses the authorized local project path for chat generation confirmation', async () => { + it('uses the authorized local project path for ordinary chat agent replies', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -18397,6 +18444,14 @@ describe('AI 游戏创作 App 界面边界', () => { messages: [], }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'chat_with_game_creator_agent') { + return { + replyText: `主聊天回复:${String(args?.prompt ?? '')}`, + }; + } if (command !== 'init_local_game_project') { throw new Error(`unexpected invoke ${command}`); } @@ -18414,19 +18469,23 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:/tmp/authorized-game')).not.toBeNull(); await act(async () => { submitChat('做一个反弹弹幕厨房游戏'); }); - expect(screen.getByText('game.generate_draft')).not.toBeNull(); expect( - screen.getByText( - '调用 LLM Planner / Generator,编排 6 组角色 brief,写入 /tmp/authorized-game/game、assets、memory、exports,通过 Evaluator 和自检后启动本地 HTTP 预览并交给外部浏览器', - ), + await screen.findByText('主聊天回复:做一个反弹弹幕厨房游戏'), ).not.toBeNull(); + expect(screen.queryByText('game.generate_draft')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_agent', { + projectPath: '/tmp/authorized-game', + prompt: '做一个反弹弹幕厨房游戏', + }); + expect(invoke).not.toHaveBeenCalledWith( + 'generate_local_game_draft', + expect.anything(), + ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { projectPath: '/tmp/authorized-game', @@ -18841,7 +18900,7 @@ describe('AI 游戏创作 App 界面边界', () => { await screen.findByText('已设置本地项目:/tmp/authorized-game'), ).not.toBeNull(); - submitChat('做一个反弹弹幕厨房游戏'); + submitChat('/generate 做一个反弹弹幕厨房游戏'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( @@ -18953,7 +19012,7 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); invoke.mockClear(); - submitChat('做一个厨房弹幕游戏'); + submitChat('/generate 做一个厨房弹幕游戏'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect(await screen.findByText('生成结果项目路径无效')).not.toBeNull(); @@ -19034,7 +19093,7 @@ describe('AI 游戏创作 App 界面边界', () => { await screen.findByText('已设置本地项目:/tmp/authorized-game'), ).not.toBeNull(); - submitChat('做一个厨房弹幕游戏'); + submitChat('/generate 做一个厨房弹幕游戏'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0c489631f..f2fed8ef8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3996,6 +3996,7 @@ - 2026-06-25 调整,2026-06-30 更新:美术组 `Asset` 和音乐组 `SFX` 角色在 loop 中读取 `.agent/manifest.json`;当本地项目还没有对应类型的 `canvas` 来源资产时,角色 step 会追加 `agent.tool.suggest.canvas.project_sync` toolCall:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。美术组在未配置 `editorApi.apiKey` 或平台生图失败时仍只给出同步建议;音乐组不调用图片生成接口,只建议同步已有音频资源。 - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 - 2026-07-01 调整:AI 游戏创作 App 在 `memory/session.md` 与 `memory/project.md` 之外新增项目级黑板 `memory/blackboard.md`,只记录重要跨 agent 决策、依赖和风险摘要;每个角色 agent 拥有私有记忆 `memory/agents//.md`。角色 brief 必须读取自己的私有记忆和项目黑板;`game.generate_draft` 通过 Evaluator 与 `game.static_smoke` 后,追加项目黑板摘要和各角色成功产出摘要,不得覆盖既有记忆。失败 run 仍只保留 trace 和 pass 快照,不写最终记忆摘要。 +- 2026-07-06 调整:AI 游戏创作 App 主聊天普通文本改为进入主聊天 Agent,而不是直接排队 `game.generate_draft`;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,支持 `agentLlm.chat` 单独 provider 配置,但只做自然语言交互、澄清和 slash 命令建议,不写项目、不运行工具、不伪装生成结果。显式 `/generate <创作想法>` 或 `/draft <创作想法>` 才进入 `game.generate_draft` 待确认流。 - 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。 - 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 - 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 100f64118..417c64002 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -251,7 +251,7 @@ game-project/ - 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 - `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 -- 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief;生成游戏或平台美术时如果返回 LLM / editorApi 缺配置错误,主窗口自动打开同一个运行时配置弹窗;API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 +- 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;主聊天 Agent 使用 `agentLlm.chat`,默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief;生成游戏、主聊天或平台美术时如果返回 LLM / editorApi 缺配置错误,主窗口自动打开同一个运行时配置弹窗;API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 - 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。`/llm-routes` 复用同一检查结果,但输出按 agent 展开的路由清单和缺口摘要,用于确认哪些 agent 解析后走全局路由、哪些 agent 走单独 provider。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 - `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;每个角色 brief 必须读取自己的私有记忆 `memory/agents//.md` 和项目黑板 `memory/blackboard.md`,写入 `.agent/passes/pass-N/groups//*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。 @@ -271,7 +271,7 @@ game-project/ - `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json` 与 `.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。 - `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/`、`memory/agents/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。 - `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/`、`.agent/`、`exports/`、`..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。 -- 聊天输入会生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents//.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;单 agent 最近证据中的输入 / 输出路径可一键填入 `/read ` 草稿,画板同步建议可一键填入 `/sync-canvas-project ` 草稿,再由用户补齐参数并走原确认流;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。 +- 普通聊天文本进入主聊天 Agent;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,只做自然语言交互、澄清、建议和 slash 命令引导,不写项目、不运行工具、不伪装已经生成产物,也不直接触发 `game.generate_draft`。用户显式输入 `/generate <创作想法>` 或 `/draft <创作想法>` 时才生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents//.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;单 agent 最近证据中的输入 / 输出路径可一键填入 `/read ` 草稿,画板同步建议可一键填入 `/sync-canvas-project ` 草稿,再由用户补齐参数并走原确认流;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。 - `game.generate_draft` 的 `game/index.html` 必须是可试玩原型,至少包含输入、主循环、目标、失败或胜利状态和重开路径;不能只输出按钮计分或纯展示页。 - `game.generate_draft` 会校验 LLM 输出:`balance`、美术清单和音乐清单必须是 JSON object,`gameHtml` 必须是自包含 HTML、包含 `canvas` 与 `requestAnimationFrame`,不得加载远程脚本或资源,不得使用 `eval` / `new Function` / `localStorage` / `fetch` / `WebSocket` / `ServiceWorker`,不得把包含 `<` / `>` 的用户输入原样写入 HTML。 - 同一项目内多次 `game.generate_draft` 不覆盖记忆文件,而是继续追加短期对话记录、长期创作目标记录、项目黑板摘要和角色私有摘要,保留用户迭代历史。