diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index 3313b9e..3353106 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -1,6 +1,7 @@ //! Shell 执行器 — 通过 tokio::process 执行 shell 命令 use serde::{Deserialize, Serialize}; +use std::process::Stdio; /// Shell 命令执行结果 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,10 +38,12 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { let mut cmd = if cfg!(windows) { let mut c = tokio::process::Command::new("cmd"); c.arg("/C").arg(&request.command); + c.stdout(Stdio::piped()).stderr(Stdio::piped()); c } else { let mut c = tokio::process::Command::new("sh"); c.arg("-c").arg(&request.command); + c.stdout(Stdio::piped()).stderr(Stdio::piped()); c }; @@ -52,24 +55,14 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { cmd.env(key, value); } - // kill_on_drop 确保 child 句柄被 drop 时(包括 timeout 取消)自动 kill 子进程, - // 防止 stdout/stderr pipe fd 泄漏 + 孤儿/僵尸进程累积。 - cmd.kill_on_drop(true); - let child = cmd.spawn()?; - // wait_with_output 消费 child 并回收所有 pipe + 等待退出(避免僵尸)。 let output = match request.timeout_secs { - Some(secs) => match tokio::time::timeout( + Some(secs) => tokio::time::timeout( std::time::Duration::from_secs(secs), - child.wait_with_output(), + cmd.output(), ) .await - { - Ok(res) => res?, - // timeout 触发:此处 child 已被 wait_with_output 消费,但因 kill_on_drop - // 在构建时已设,spawn 出的底层进程在 child 句柄 drop 时自动 kill。 - Err(_) => return Err(anyhow::anyhow!("命令执行超时: {}秒", secs)), - }, - None => child.wait_with_output().await?, + .map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??, + None => cmd.output().await?, }; let duration = start.elapsed().as_millis() as u64; diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index beaef4a..818dbad 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -31,6 +31,26 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result 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 { diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 220a3b9..21e182e 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -578,6 +578,91 @@ pub fn build_ai_tool_registry(db: &Arc) -> 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, + total: &'a mut u64, +) -> std::pin::Pin> + 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::*; diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index db58c58..f130dd3 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -4,83 +4,44 @@
- {{ $t('aiChat.sidebarTitle') }} -
- - - - - - + +
- {{ $t('aiChat.emptyConversation') }} + 暂无对话
@@ -91,7 +52,7 @@
- @@ -100,35 +61,31 @@ {{ $t('ai.assistant') }}
- - - @@ -140,33 +97,33 @@ class="ai-provider-bar" :class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }" v-if="store.state.providers.length > 0" - :title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''" + :title="store.state.providers.length > 1 ? '点击切换 Provider' : ''" @click="cycleProvider" > {{ activeProviderName }}
- {{ $t('aiChat.providerNotConfigured') }} + 未配置 AI 提供商,请在设置中添加
-
+
@@ -196,9 +153,8 @@ v-if="msg.content || (isLastAi(msg) && store.state.currentText)" class="ai-msg-bubble ai-msg-bubble--ai ai-md" :class="{ 'ai-msg-bubble--error': msg.isError }" - :key="'md-' + msg.id + '-' + (isLastAi(msg) ? _mdRenderKey : 0)" > -
+
@@ -206,90 +162,116 @@
- -
- 🔣 - {{ formatTokens(store.state.lastTokenUsage.prompt) }} in · {{ formatTokens(store.state.lastTokenUsage.completion) }} out -
+ +
+
+ +
+ + + +
+ {{ toolDisplayName(tc) }} + 执行中... +
+ +
- - + +
+
+
+
+
+ + +
+ + +
+ + +
+
+ + {{ parseResult(tc.result)?.path }} + {{ parseResult(tc.result)?.lines || 0 }} 行 · {{ formatBytes(parseResult(tc.result)?.size) }} + +
+
{{ parseResult(tc.result)?.content }}
+
+ + +
+
+ + {{ parseResult(tc.result)?.path }} + {{ parseResult(tc.result)?.entries?.length || 0 }} 项 +
+
+
+ + {{ entry.name }} + {{ formatBytes(entry.size) }} +
+
+
+ + +
+ + {{ parseResult(tc.result)?.path }} + {{ formatBytes(parseResult(tc.result)?.bytes_written) }} 已写入 +
+ + +
+ {{ formatToolResult(tc) }} +
+
+
- - - - - - +
- -
-
- {{ $t('aiChat.queueTitle', { n: store.state.queue.length }) }} - -
-
-
- /{{ item.skill }} - {{ item.text }} - -
-
-
- -
- /{{ pendingSkill.name }} - {{ pendingSkill.description }} - -
- -
-
- {{ store.state.skills.length ? $t('aiChat.skillNoMatch') : $t('aiChat.skillNotLoaded') }} -
-
- /{{ s.name }} - {{ s.description }} - {{ s.source }} -
-
-
{{ $t('aiChat.inputHint') }}
+
Enter 发送 · Shift+Enter 换行
- - - - -
{{ toast.msg }}
-
@@ -636,14 +656,25 @@ onMounted(async () => { overflow: hidden; } -.confidence-fill { +.debate-column.positive .confidence-bar::after { + content: ''; + position: absolute; + left: 0; + top: 0; height: 100%; + background: currentColor; border-radius: var(--df-radius-xs); - transition: width 0.4s; } -.debate-column.positive .confidence-fill { background: var(--df-success); } -.debate-column.negative .confidence-fill { background: var(--df-danger); } +.debate-column.negative .confidence-bar::after { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + background: currentColor; + border-radius: var(--df-radius-xs); +} .confidence-text { font-size: 11px; @@ -761,23 +792,6 @@ onMounted(async () => { background: var(--df-accent-hover); } -.eval-error { - margin-top: 8px; - font-size: 12px; - color: var(--df-danger); -} - -.eval-mode-tag { - font-size: 11px; - font-weight: 400; - padding: 1px 8px; - border-radius: var(--df-radius-xs); - background: rgba(255, 217, 61, 0.15); - color: var(--df-warning); - margin-left: 6px; - vertical-align: middle; -} - /* ===== 右侧详情 ===== */ .idea-detail-panel { background: var(--df-bg-card); @@ -811,63 +825,6 @@ onMounted(async () => { margin-bottom: var(--df-gap-page); } -/* ===== 灵感描述 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */ -.detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); } -.detail-desc.ai-md :deep(p) { margin: 0 0 6px; } -.detail-desc.ai-md :deep(p:last-child) { margin-bottom: 0; } -.detail-desc.ai-md :deep(ul), .detail-desc.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; } -.detail-desc.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; } -.detail-desc.ai-md :deep(code) { - font-family: var(--df-font-mono); - font-size: 12px; - padding: 1px 5px; - background: rgba(255,255,255,0.06); - border-radius: var(--df-radius-sm); - color: var(--df-accent); -} -.detail-desc.ai-md :deep(pre) { - margin: 8px 0; - padding: 10px 12px; - background: var(--df-bg); - border: 0.5px solid var(--df-border); - border-radius: var(--df-radius); - overflow-x: auto; -} -.detail-desc.ai-md :deep(pre code) { - padding: 0; - background: transparent; - border-radius: 0; - color: var(--df-text-secondary); - font-size: 12px; - line-height: 1.5; -} -.detail-desc.ai-md :deep(blockquote) { - margin: 6px 0; - padding: 4px 12px; - border-left: 2px solid var(--df-accent); - color: var(--df-text-secondary); -} -.detail-desc.ai-md :deep(h1), .detail-desc.ai-md :deep(h2), .detail-desc.ai-md :deep(h3) { - font-weight: 500; - color: var(--df-text); - margin: 8px 0 4px; -} -.detail-desc.ai-md :deep(h1) { font-size: 16px; } -.detail-desc.ai-md :deep(h2) { font-size: 14px; } -.detail-desc.ai-md :deep(h3) { font-size: 13px; } -.detail-desc.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; } -.detail-desc.ai-md :deep(a:hover) { text-decoration: underline; } -.detail-desc.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); } -.detail-desc.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; } -.detail-desc.ai-md :deep(table) { - width: 100%; border-collapse: collapse; margin: 6px 0; - font-size: 12px; overflow-x: auto; display: block; -} -.detail-desc.ai-md :deep(th), .detail-desc.ai-md :deep(td) { - padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left; -} -.detail-desc.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); } - .detail-section { margin-bottom: var(--df-gap-page); } diff --git a/src/views/Knowledge.vue b/src/views/Knowledge.vue index 910d38a..b3feafc 100644 --- a/src/views/Knowledge.vue +++ b/src/views/Knowledge.vue @@ -240,9 +240,9 @@