diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index 06c643c6b..c9c1bd6cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件,优先用于已有文件的小范围修订。先读文件,以唯一且非空的 old_text 精确匹配并替换为 new_text;new_text 为空可删除片段,保留原文并追加可插入。匹配失败不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["path","old_text","new_text"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 6d65f9dd3..954793b1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -264,22 +264,45 @@ pub(crate) fn execute_design_file_tool( } "patch_file" => { let relative = required_tool_path(args)?; - let old = args - .get("old_text") - .and_then(Value::as_str) - .ok_or("缺少 old_text")?; - let new = args - .get("new_text") - .and_then(Value::as_str) - .ok_or("缺少 new_text")?; - if old.is_empty() { - return Err("old_text 不能为空".to_string()); - } + let edits = if let Some(items) = args.get("edits").and_then(Value::as_array) { + if items.is_empty() { + return Err("edits 不能为空".to_string()); + } + items + .iter() + .enumerate() + .map(|(index, item)| { + let old = item + .get("old_text") + .and_then(Value::as_str) + .ok_or_else(|| format!("edits[{index}].old_text 必须是字符串"))?; + let new = item + .get("new_text") + .and_then(Value::as_str) + .ok_or_else(|| format!("edits[{index}].new_text 必须是字符串"))?; + if old.is_empty() { + return Err(format!("edits[{index}].old_text 不能为空")); + } + Ok((old.to_string(), new.to_string())) + }) + .collect::, String>>()? + } else { + let old = args + .get("old_text") + .and_then(Value::as_str) + .ok_or("缺少 old_text")?; + let new = args + .get("new_text") + .and_then(Value::as_str) + .ok_or("缺少 new_text")?; + if old.is_empty() { + return Err("old_text 不能为空".to_string()); + } + vec![(old.to_string(), new.to_string())] + }; let (display, path) = resolve_design_workspace_path(root, &relative)?; if !path.is_file() { - return Ok(Value::String(format!( - "局部修改失败:文件不存在:{display}" - ))); + return Err(format!("文件不存在:{display}")); } let content = fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?; @@ -288,20 +311,52 @@ pub(crate) fn execute_design_file_tool( } else { "\n" }; - let old = old.replace("\r\n", "\n").replace('\n', newline); - let new = new.replace("\r\n", "\n").replace('\n', newline); - let count = content.matches(&old).count(); - if count != 1 { - return Err(format!( - "原文匹配 {count} 处,需要唯一匹配;请重新读取文件并扩大匹配范围" - )); + let normalized = edits + .into_iter() + .map(|(old, new)| { + ( + old.replace("\r\n", "\n").replace('\n', newline), + new.replace("\r\n", "\n").replace('\n', newline), + ) + }) + .collect::>(); + let mut matches = Vec::new(); + for (index, (old, new)) in normalized.iter().enumerate() { + let count = content.matches(old).count(); + if count == 0 { + return Err(format!("edits[{index}] 原文未找到:{display}")); + } + if count != 1 { + return Err(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + )); + } + let start = content.find(old).expect("count checked"); + let end = start + old.len(); + if let Some((other_index, _other_start, _other_end)) = matches + .iter() + .find(|(_, other_start, other_end)| start < *_other_end && *_other_start < end) + { + return Err(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + )); + } + matches.push((index, start, end)); + let _ = new; } - crate::write_game_creator_private_file( - &path, - content.replacen(&old, &new, 1).as_bytes(), - "策划工作区文件", - )?; - Ok(Value::String(format!("已局部修改 {display}"))) + let mut updated = content.clone(); + for (index, start, end) in matches.into_iter().rev() { + let (_, new) = &normalized[index]; + updated.replace_range(start..end, new); + } + if updated == content { + return Err(format!("没有产生修改:{display}")); + } + crate::write_game_creator_private_file(&path, updated.as_bytes(), "策划工作区文件")?; + Ok(Value::String(format!( + "已局部修改 {display}({} 处)", + normalized.len() + ))) } "delete_path" => { let relative = required_tool_path(args)?; @@ -645,6 +700,29 @@ mod tests { ) .expect("patch"); assert!(patched.as_str().unwrap().contains("已局部修改")); + execute_design_file_tool( + root, + "write_file", + &json!({"path":"notes/multi.md","content":"甲\n乙\n丙"}), + ) + .expect("write multi"); + let multi = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/multi.md", + "edits":[ + {"old_text":"甲","new_text":"一"}, + {"old_text":"丙","new_text":"三"} + ] + }), + ) + .expect("multi patch"); + assert!(multi.as_str().unwrap().contains("2 处")); + assert_eq!( + fs::read_to_string(root.join("design_artifacts/notes/multi.md")).expect("read multi"), + "一\n乙\n三" + ); execute_design_file_tool(root, "delete_path", &json!({"path":"notes"})) .expect("delete dir"); assert!(!root.join("design_artifacts/notes").exists());