改进策划文件局部修改工具

支持同一文件多处独立替换

统一局部修改失败结果并补充测试
This commit is contained in:
2026-09-15 16:12:46 +08:00
parent aa15ee83c8
commit 2e217ba546
2 changed files with 106 additions and 28 deletions
@@ -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}}},
@@ -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::<Result<Vec<_>, 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::<Vec<_>>();
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());