Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs
T
lhk229 811abdd375
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m40s
Project CI / Native shell tests (pull_request) Successful in 17m42s
修复策划 Agent 四个审查问题
修复 Retry 流式回合身份与阶段审批 transient 清理

拒绝无效 write_file 内容并补齐顾问阶段导轨
2026-09-11 10:57:49 +00:00

700 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::Manager;
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
const SEARCH_HIT_LIMIT: usize = 200;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesignWorkspaceEntry {
pub(crate) path: String,
pub(crate) kind: String,
}
#[derive(Clone, Debug, Deserialize)]
struct DesignCatalogFile {
#[serde(default)]
resources: Vec<DesignCatalogRecord>,
}
#[derive(Clone, Debug, Deserialize)]
struct DesignCatalogRecord {
id: String,
category: String,
title: String,
summary: String,
path: String,
#[serde(default)]
inject_phases: Vec<String>,
}
#[derive(Clone, Debug)]
struct DesignCatalogItem {
id: String,
category: String,
title: String,
summary: String,
path: PathBuf,
inject_phases: Vec<String>,
}
pub(crate) struct DesignResources {
system_prompt: String,
tools: Vec<platform_llm::LlmFunctionTool>,
catalog: Vec<DesignCatalogItem>,
phase_text: BTreeMap<String, String>,
overview_card: String,
common_tail: String,
consultant_tail: String,
}
impl DesignResources {
pub(crate) fn new(root: PathBuf) -> Result<Self, String> {
let catalog = load_design_catalog(&root)?;
Ok(Self {
system_prompt: read_pack_text(&root, "system-prompt.md")?,
tools: load_design_tools(&root)?,
catalog,
phase_text: DESIGN_PHASES
.iter()
.map(|phase| {
read_pack_text(&root, &format!("phase-context/{phase}.md"))
.map(|text| ((*phase).to_string(), text))
})
.collect::<Result<_, _>>()?,
overview_card: read_pack_text(&root, "phase-context/overview-card.md")?,
common_tail: read_pack_text(&root, "phase-context/common-tail.md")?,
consultant_tail: read_pack_text(&root, "phase-context/consultant-tail.md")?,
})
}
pub(crate) fn system_prompt(&self) -> String {
self.system_prompt.clone()
}
pub(crate) fn function_tools(&self) -> Vec<platform_llm::LlmFunctionTool> {
self.tools.clone()
}
pub(crate) fn list(&self) -> Result<String, String> {
let mut groups: BTreeMap<&str, Vec<&DesignCatalogItem>> = BTreeMap::new();
for item in &self.catalog {
groups.entry(item.category.as_str()).or_default().push(item);
}
let mut lines = vec!["固定资源目录:".to_string()];
for (category, mut items) in groups {
items.sort_by(|left, right| left.id.cmp(&right.id));
lines.push(format!("\n{category}"));
for item in items {
lines.push(format!("- {}{}{}", item.id, item.title, item.summary));
}
}
Ok(lines.join("\n"))
}
pub(crate) fn read(&self, resource_id: &str) -> Result<String, String> {
let item = self
.catalog
.iter()
.find(|item| item.id == resource_id)
.ok_or_else(|| "未知资源 ID".to_string())?;
fs::read_to_string(&item.path).map_err(|error| format!("读取资源失败:{error}"))
}
pub(crate) fn phase_context(&self, session: &DesignSession) -> String {
let mut lines = Vec::new();
if let Some(text) = self.phase_text.get(&session.current_phase) {
if !text.trim().is_empty() {
lines.push(text.trim_end().to_string());
}
}
if session.current_phase == "concept" && !self.overview_card.trim().is_empty() {
lines.push(String::new());
lines.push(self.overview_card.trim_end().to_string());
}
let mut injected = Vec::new();
for item in &self.catalog {
if !item
.inject_phases
.iter()
.any(|phase| phase == &session.current_phase)
{
continue;
}
match fs::read_to_string(&item.path) {
Ok(text) if !text.trim().is_empty() => injected.push((item, text)),
_ => continue,
}
}
if !injected.is_empty() {
lines.push("【本阶段必读资源】".to_string());
injected.sort_by(|left, right| left.0.id.cmp(&right.0.id));
for (item, text) in injected {
lines.extend([
format!("资源 ID{}", item.id),
format!("标题:{}", item.title),
"--- 正文开始 ---".to_string(),
text,
"--- 正文结束 ---".to_string(),
]);
}
}
lines.push("本阶段必需产物(首次创建时直接使用这些相对路径):".to_string());
let artifacts = session
.required_artifacts
.get(&session.current_phase)
.cloned()
.unwrap_or_default();
if artifacts.is_empty() {
lines.push("- (本阶段无固定必需产物路径)".to_string());
} else {
lines.extend(artifacts.into_iter().map(|path| format!("- {path}")));
}
if !self.common_tail.trim().is_empty() {
lines.push(self.common_tail.trim_end().to_string());
}
if session.current_phase == "consultant" && !self.consultant_tail.trim().is_empty() {
lines.push(self.consultant_tail.trim_end().to_string());
}
lines.join("\n")
}
}
pub(crate) fn resolve_design_resources_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let mut candidates = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")];
if let Ok(dir) = app.path().resource_dir() {
candidates.push(dir.join("design-agent"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
candidates.push(parent.join("design-agent"));
}
}
candidates
.into_iter()
.find(|root| {
root.join("resources/catalog.json").is_file()
&& root.join("system-prompt.md").is_file()
&& root.join("tools.json").is_file()
})
.ok_or_else(|| "策划 Agent 资源包未找到".to_string())
}
pub(crate) fn ensure_design_workspace(root: &Path) -> Result<PathBuf, String> {
let path = resolve_local_project_path(root, DESIGN_WORKSPACE_ROOT)?;
if !path.exists() {
crate::ensure_game_creator_private_directory_tree(&path, "策划工作区")?;
}
Ok(path)
}
pub(crate) fn execute_design_file_tool(
root: &Path,
name: &str,
args: &Value,
) -> Result<Value, String> {
match name {
"list_dir" => {
let relative = optional_tool_path(args)?;
let (display, path) = resolve_design_workspace_path(root, &relative)?;
if !path.is_dir() {
return Ok(Value::String("不是目录".to_string()));
}
let mut rows = Vec::new();
let mut entries = fs::read_dir(&path)
.map_err(|error| format!("列出目录失败:{error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("列出目录失败:{error}"))?;
entries.sort_by_key(|entry| {
(
!entry.path().is_dir(),
entry.file_name().to_string_lossy().to_lowercase(),
)
});
for entry in entries {
let child = entry.path();
if design_path_is_link(&child) {
continue;
}
let name = workspace_display_path(&relative, &entry.file_name().to_string_lossy());
rows.push(format!(
"{} {name}",
if child.is_dir() {
"[目录]"
} else {
"[文件]"
}
));
}
Ok(Value::String(if rows.is_empty() {
if display == "." {
"目录为空".to_string()
} else {
format!("{display} 为空")
}
} else {
rows.join("\n")
}))
}
"read_file" => {
let relative = required_tool_path(args)?;
let (display, path) = resolve_design_workspace_path(root, &relative)?;
if !path.is_file() {
return Err(format!("不是文件:{display}"));
}
fs::read_to_string(&path)
.map(Value::String)
.map_err(|error| format!("读取失败:{error}"))
}
"write_file" => {
let relative = required_tool_path(args)?;
let content = args
.get("content")
.and_then(Value::as_str)
.ok_or("content 必须是字符串")?;
let (display, path) = resolve_design_workspace_path(root, &relative)?;
crate::write_game_creator_private_file(&path, content.as_bytes(), "策划工作区文件")?;
Ok(Value::String(format!("已写入 {display}")))
}
"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 (display, path) = resolve_design_workspace_path(root, &relative)?;
if !path.is_file() {
return Ok(Value::String(format!(
"局部修改失败:文件不存在:{display}"
)));
}
let content =
fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?;
let newline = if content.contains("\r\n") {
"\r\n"
} 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} 处,需要唯一匹配;请重新读取文件并扩大匹配范围"
));
}
crate::write_game_creator_private_file(
&path,
content.replacen(&old, &new, 1).as_bytes(),
"策划工作区文件",
)?;
Ok(Value::String(format!("已局部修改 {display}")))
}
"delete_path" => {
let relative = required_tool_path(args)?;
let (display, path) = resolve_design_workspace_path(root, &relative)?;
if display == "." {
return Err("不能删除工作区根目录".to_string());
}
if design_path_is_link(&path) {
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
}
if !path.exists() {
return Err(format!("路径不存在:{display}"));
}
if path.is_dir() {
remove_design_dir(&path)?;
} else {
fs::remove_file(&path).map_err(|error| format!("删除失败:{error}"))?;
}
Ok(Value::String(format!("已删除 {display}")))
}
"search_text" => {
let query = args
.get("query")
.and_then(Value::as_str)
.ok_or("缺少 query")?;
let relative = optional_tool_path(args)?;
let (_, path) = resolve_design_workspace_path(root, &relative)?;
let mut hits = Vec::new();
search_design_text(root, &path, query, &mut hits)?;
Ok(Value::String(if hits.is_empty() {
"没有找到匹配内容".to_string()
} else {
hits.join("\n")
}))
}
_ => Err("未知工具".to_string()),
}
}
pub(crate) fn list_design_workspace_files(
root: &Path,
) -> Result<Vec<DesignWorkspaceEntry>, String> {
let workspace = match resolve_local_project_path(root, DESIGN_WORKSPACE_ROOT) {
Ok(path) if path.is_dir() => path,
_ => return Ok(Vec::new()),
};
let mut files = Vec::new();
let mut dirs = vec![(String::new(), workspace)];
while let Some((prefix, dir)) = dirs.pop() {
let mut entries = match fs::read_dir(&dir) {
Ok(entries) => entries
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取工作区失败:{error}"))?,
Err(_) => continue,
};
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if design_path_is_link(&path) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let relative = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取工作区元数据失败:{error}"))?;
if metadata.is_dir() {
files.push(DesignWorkspaceEntry {
path: relative.clone(),
kind: "directory".to_string(),
});
dirs.push((relative, path));
} else if metadata.is_file() {
files.push(DesignWorkspaceEntry {
path: relative,
kind: "file".to_string(),
});
}
}
}
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<String, String> {
let (display, target) = resolve_design_workspace_path(root, path)?;
if !target.is_file() {
return Err(format!("不是文件:{display}"));
}
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
}
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
let catalog_path = root.join("resources/catalog.json");
let data: DesignCatalogFile = serde_json::from_str(
&fs::read_to_string(&catalog_path).map_err(|error| format!("读取资源目录失败:{error}"))?,
)
.map_err(|error| format!("解析资源目录失败:{error}"))?;
let resources_root = root.join("resources");
let mut seen = std::collections::BTreeSet::new();
let mut items = Vec::new();
for record in data.resources {
if !seen.insert(record.id.clone()) {
return Err(format!("资源 ID 重复:{}", record.id));
}
let relative = PathBuf::from(&record.path);
if relative.is_absolute() || relative.components().any(|part| part.as_os_str() == "..") {
return Err(format!("资源路径非法:{}", record.id));
}
let path = resources_root.join(&relative);
let resolved = path.canonicalize().unwrap_or(path);
items.push(DesignCatalogItem {
id: record.id,
category: record.category,
title: record.title,
summary: record.summary,
path: resolved,
inject_phases: record.inject_phases,
});
}
Ok(items)
}
fn load_design_tools(root: &Path) -> Result<Vec<platform_llm::LlmFunctionTool>, String> {
let raw: Value = serde_json::from_str(
&fs::read_to_string(root.join("tools.json"))
.map_err(|error| format!("读取工具声明失败:{error}"))?,
)
.map_err(|error| format!("解析工具声明失败:{error}"))?;
let items = raw
.as_array()
.ok_or_else(|| "工具声明必须是数组".to_string())?;
let mut tools = Vec::new();
for item in items {
let function = item
.get("function")
.ok_or_else(|| "工具声明缺少 function".to_string())?;
tools.push(platform_llm::LlmFunctionTool::new(
function
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| "工具声明缺少 name".to_string())?,
function
.get("description")
.and_then(Value::as_str)
.unwrap_or_default(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}})),
));
}
Ok(tools)
}
fn read_pack_text(root: &Path, relative: &str) -> Result<String, String> {
fs::read_to_string(root.join(relative))
.map_err(|error| format!("读取 {relative} 失败:{error}"))
}
fn optional_tool_path(args: &Value) -> Result<String, String> {
Ok(args
.get("path")
.and_then(Value::as_str)
.unwrap_or(".")
.trim()
.to_string())
}
fn required_tool_path(args: &Value) -> Result<String, String> {
let path = args
.get("path")
.and_then(Value::as_str)
.ok_or("缺少 path")?
.trim();
if path.is_empty() {
return Err("缺少 path".to_string());
}
Ok(path.to_string())
}
fn resolve_design_workspace_path(root: &Path, relative: &str) -> Result<(String, PathBuf), String> {
let relative = relative.trim().replace('\\', "/");
if relative.is_empty() || relative == "." {
return Ok((".".to_string(), ensure_design_workspace(root)?));
}
if Path::new(&relative).is_absolute() {
return Err("只允许使用工作目录内的相对路径".to_string());
}
let normalized = normalize_relative_path(&relative)?;
let path = resolve_local_project_path(root, &format!("{DESIGN_WORKSPACE_ROOT}/{normalized}"))?;
Ok((normalized, path))
}
fn workspace_display_path(parent: &str, name: &str) -> String {
if parent == "." || parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
fn design_path_is_link(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
}
#[cfg(not(windows))]
false
}
Err(_) => false,
}
}
fn remove_design_dir(path: &Path) -> Result<(), String> {
if design_path_is_link(path) {
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
}
for entry in fs::read_dir(path).map_err(|error| format!("删除失败:{error}"))? {
let entry = entry.map_err(|error| format!("删除失败:{error}"))?;
let child = entry.path();
if design_path_is_link(&child) {
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
}
if child.is_dir() {
remove_design_dir(&child)?;
} else {
fs::remove_file(&child).map_err(|error| format!("删除失败:{error}"))?;
}
}
fs::remove_dir(path).map_err(|error| format!("删除失败:{error}"))
}
fn search_design_text(
root: &Path,
path: &Path,
query: &str,
hits: &mut Vec<String>,
) -> Result<(), String> {
if hits.len() >= SEARCH_HIT_LIMIT || design_path_is_link(path) {
return Ok(());
}
if path.is_file() {
let Ok(text) = fs::read_to_string(path) else {
return Ok(());
};
let display = design_workspace_relative(root, path)?;
for (index, line) in text.lines().enumerate() {
if line.contains(query) {
hits.push(format!("{display}:{}: {line}", index + 1));
if hits.len() >= SEARCH_HIT_LIMIT {
break;
}
}
}
return Ok(());
}
if !path.is_dir() {
return Ok(());
}
let mut entries = fs::read_dir(path)
.map_err(|error| format!("搜索失败:{error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("搜索失败:{error}"))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
search_design_text(root, &entry.path(), query, hits)?;
if hits.len() >= SEARCH_HIT_LIMIT {
break;
}
}
Ok(())
}
fn design_workspace_relative(root: &Path, path: &Path) -> Result<String, String> {
let workspace = resolve_local_project_path(root, DESIGN_WORKSPACE_ROOT)?;
let relative = path
.strip_prefix(&workspace)
.map_err(|_| "路径超出工作目录".to_string())?;
if relative.as_os_str().is_empty() {
return Ok(".".to_string());
}
Ok(relative.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_root() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn pack_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
}
#[test]
fn file_tools_stay_inside_workspace() {
let temp = test_root();
let root = temp.path();
execute_design_file_tool(
root,
"write_file",
&json!({"path":"notes/design.md","content":"游戏设计"}),
)
.expect("write");
assert_eq!(
fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"),
"游戏设计"
);
let listing = execute_design_file_tool(root, "list_dir", &json!({"path":"."}))
.expect("list")
.as_str()
.unwrap()
.to_string();
assert!(listing.contains("[目录] notes"));
assert!(!listing.contains(".agent"));
let escaped = execute_design_file_tool(
root,
"write_file",
&json!({"path":"../secret.md","content":"no"}),
)
.expect_err("escape");
assert!(escaped.contains("路径"));
let patched = execute_design_file_tool(
root,
"patch_file",
&json!({"path":"notes/design.md","old_text":"游戏","new_text":"玩法"}),
)
.expect("patch");
assert!(patched.as_str().unwrap().contains("已局部修改"));
execute_design_file_tool(root, "delete_path", &json!({"path":"notes"}))
.expect("delete dir");
assert!(!root.join("design_artifacts/notes").exists());
}
#[test]
fn phase_context_injects_current_skill_only() {
let resources = DesignResources::new(pack_root()).expect("pack");
let mut session = new_design_session("project", "quality");
let concept = resources.phase_context(&session);
assert!(concept.contains("skills.concept"));
assert!(!concept.contains("skills.top_design"));
assert!(concept.contains("project/速览卡.md"));
session.current_phase = "top_design".to_string();
let top = resources.phase_context(&session);
assert!(top.contains("skills.top_design"));
assert!(!top.contains("skills.concept"));
session.current_phase = "systems".to_string();
let systems = resources.phase_context(&session);
assert!(systems.contains("无固定必需产物路径"));
session.current_phase = "consultant".to_string();
let consultant = resources.phase_context(&session);
assert!(consultant.contains("顾问阶段没有下一层"));
}
#[test]
fn missing_injected_resource_does_not_block_context() {
let temp = tempfile::tempdir().expect("tempdir");
let dest = temp.path().join("design-agent");
copy_dir(&pack_root(), &dest).expect("copy pack");
fs::remove_file(dest.join("resources/skills/concept.md")).expect("remove");
let resources = DesignResources::new(dest).expect("pack without concept skill");
let session = new_design_session("project", "quality");
let context = resources.phase_context(&session);
assert!(!context.contains("skills.concept"));
assert!(context.contains("project/00_concept/design.md"));
}
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
fs::create_dir_all(to).map_err(|error| error.to_string())?;
for entry in fs::read_dir(from).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let target = to.join(entry.file_name());
if entry.path().is_dir() {
copy_dir(&entry.path(), &target)?;
} else {
fs::copy(entry.path(), target).map_err(|error| error.to_string())?;
}
}
Ok(())
}
}