优化: P0 shell stdout修复+灵感路由+3工具新增+ai-md全局CSS+import路径统一
P0 修复:
- B-260615-37: shell.rs 补 Stdio::piped() 修复 run_command stdout/stderr 恒空
(tokio 默认 inherit 导致 wait_with_output 读不到 pipe)
P1 功能:
- B-260615-36: 路由加 /ideas/:id + Ideas.vue 接 route.params.id + watch
(修复项目详情点「来源灵感」跳空白页)
- F-260615-08: 新增 file_info 工具(元信息: exists/size/lines/binary/dir)
- F-260615-09: 新增 append_file 工具(追加写入, RiskLevel::Medium)
- F-260615-12: 新增 search_files 工具(文件名 glob 搜索,递归,限50条)
+ search_files_recursive 辅助函数 + i18n zh/en 3工具×2命名空间
DRY/重构:
- CR-260615-09: .ai-md 样式5份→全局 src/styles/ai-md.css(75行)
AiChat.vue 删71行重复 + main.ts 引入 + typo修正(.a-md→.ai-md)
- 全量 import 路径统一为 @/ 别名(stores/composables/views ~28文件)
vite.config.ts 加 resolve.alias.{ '@': '/src' }
- i18n 批量改进: store error fallback 11处中文→t() / ToolCard ARG_LABEL
/ useAiSend queue文案 / Dashboard 空态 / Ideas.vue null守卫
- cargo check 0 error / vue-tsc 0 error
This commit is contained in:
@@ -31,6 +31,26 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前运行环境信息(注入 system prompt,供 LLM 写日期/命令时参考)
|
||||
///
|
||||
/// - 日期:LLM 无法自行获取当前日期,不注入则写文件日期全靠猜(常从项目已有文件名模式推断出错)
|
||||
/// - 操作系统:LLM 写 shell 命令需知道平台(dir vs ls、路径分隔符等)
|
||||
///
|
||||
/// 两项合计约 25 token,消除一整类系统性错误。
|
||||
fn env_info_line() -> String {
|
||||
let today = chrono::Local::now().format("%Y-%m-%d");
|
||||
let os = if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
format!("当前日期: {today} | 运行环境: {os}\n\n")
|
||||
}
|
||||
|
||||
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题)
|
||||
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
||||
match lang {
|
||||
@@ -67,10 +87,11 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建系统提示词(固定前缀 + 当前项目上下文)
|
||||
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目上下文)
|
||||
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
||||
let (prefix, ctx_label) = system_prompt_parts(lang);
|
||||
let mut prompt = String::from(prefix);
|
||||
let mut prompt = env_info_line();
|
||||
prompt.push_str(prefix);
|
||||
|
||||
// 附加当前数据上下文
|
||||
if let Ok(projects) = state.projects.list_active().await {
|
||||
|
||||
@@ -578,6 +578,91 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 文件元信息 (Low risk) ──
|
||||
registry.register(
|
||||
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let p = std::path::Path::new(path);
|
||||
if !p.exists() {
|
||||
return Ok(serde_json::json!({ "path": path, "exists": false }));
|
||||
}
|
||||
let metadata = tokio::fs::metadata(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||||
let is_dir = metadata.is_dir();
|
||||
let size = metadata.len();
|
||||
let modified = metadata.modified()
|
||||
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as i64);
|
||||
// is_binary: 读前 8KB 检测 \x00
|
||||
let is_binary = if !is_dir && size > 0 {
|
||||
let sample = tokio::fs::read(path).await.unwrap_or_default();
|
||||
sample[..sample.len().min(8192)].contains(&0x00)
|
||||
} else { false };
|
||||
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
|
||||
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
|
||||
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
|
||||
} else { None };
|
||||
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 追加写入 (Medium risk) ──
|
||||
registry.register(
|
||||
"append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||||
RiskLevel::Medium,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||||
}
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
|
||||
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
|
||||
let bytes = content.as_bytes();
|
||||
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
|
||||
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
|
||||
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
|
||||
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 文件搜索 (Low risk) ──
|
||||
registry.register(
|
||||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
const MAX_RESULTS: usize = 50;
|
||||
let mut results = Vec::new();
|
||||
let mut total = 0u64;
|
||||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
|
||||
let has_more = total as usize > MAX_RESULTS;
|
||||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
|
||||
})),
|
||||
);
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -654,6 +739,42 @@ fn is_noise_file(name: &str) -> bool {
|
||||
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
|
||||
}
|
||||
|
||||
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
|
||||
fn search_files_recursive<'a>(
|
||||
path: &'a str,
|
||||
pattern: &'a str,
|
||||
recursive: bool,
|
||||
depth: usize,
|
||||
max_depth: usize,
|
||||
max_results: usize,
|
||||
results: &'a mut Vec<serde_json::Value>,
|
||||
total: &'a mut u64,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let mut dir = tokio::fs::read_dir(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let metadata = entry.metadata().await?;
|
||||
let is_dir = metadata.is_dir();
|
||||
if !is_dir {
|
||||
// 字符串包含匹配(大小写不敏感)
|
||||
if name.to_lowercase().contains(pattern) {
|
||||
*total += 1;
|
||||
if results.len() < max_results {
|
||||
let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||
results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
|
||||
}
|
||||
}
|
||||
} else if recursive && depth < max_depth {
|
||||
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||
search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user