700 lines
25 KiB
Rust
700 lines
25 KiB
Rust
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(())
|
||
}
|
||
}
|