优化: 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:
2026-06-15 16:40:09 +08:00
parent 6254d06c7a
commit 2196c7748f
36 changed files with 1000 additions and 1029 deletions

View File

@@ -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 {

View File

@@ -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::*;