优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)

- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod

- AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation

- 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n

- 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
This commit is contained in:
lxy
2026-08-05 22:15:34 +08:00
parent 28de5d6143
commit e68d283651
49 changed files with 1257 additions and 138 deletions
+38
View File
@@ -12,6 +12,7 @@ use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::tools::entity_resolve;
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
@@ -256,6 +257,43 @@ pub(crate) async fn process_tool_calls(
})
.collect();
// ── 实体参数 name→id 解析(机制层,治 list_tasks(project_id="moyu") 空返回)──
// resolve_entity_ids 只改 args / 返 Err;失败走结构化 failed envelope(镜像 path_denied),
// 不执行,让 LLM 拿到可行动错误自修。单点漏斗:auto + 审批 + 目录授权全部执行路径统一拿到已解析 id。
let mut resolved_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::with_capacity(drafts.len());
for (idx, draft, args) in drafts {
match entity_resolve::resolve_entity_ids(db, &draft.name, &args).await {
Ok(resolved) => resolved_drafts.push((idx, draft, resolved)),
Err(e) => {
let err_payload = serde_json::json!({
"status": "failed",
"error": e.to_string(),
}).to_string();
// 短 lock 段:push tool_result(纯写,无 await)
{
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold);
}
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_payload.clone()),
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
}
}
}
let drafts = resolved_drafts;
// ── 文件工具路径授权预校验 ──
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
+1 -1
View File
@@ -184,7 +184,7 @@ pub async fn list_tool_executions(
query: Option<ToolExecQuery>,
) -> Result<ToolExecutionPage, String> {
let q = query.unwrap_or_default();
let limit = q.limit.unwrap_or(50);
let _limit = q.limit.unwrap_or(50);
let offset = q.offset.unwrap_or(0);
let audit_q = AuditQuery::from(q);
@@ -568,7 +568,9 @@ pub async fn ai_conversation_delete(
state: State<'_, AppState>,
conversation_id: String,
) -> Result<(), String> {
state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?;
// G1.3:单事务删 ai_messages 子行 + ai_conversations 主行(孤儿根治)。
// 顺序:先删数据再摘内存(下方 per_conv.remove)——防后台在途 save 在删主行后复活孤儿消息。
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
let mut session = state.ai_session.lock().await;
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
@@ -108,7 +108,7 @@ pub async fn ai_save_provider(
provider_type: String,
model_configs: Vec<ModelConfig>,
) -> Result<String, String> {
// F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
// 编辑已有提供商时保留原 created_at,避免被覆盖
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
+1 -1
View File
@@ -200,7 +200,7 @@ fn extract_results(html: &str) -> Vec<Value> {
"snippet": snip.cloned().unwrap_or_default(),
}));
}
(None, Some(snip)) => {
(None, Some(_snip)) => {
// 残留 snippet 无对应标题(罕见,DDG 结构异常时兜底):跳过,无标题无 URL 无意义。
// 不构造半残条目,保持每条都有 title+url(让 LLM 能 fetch_url)。
}
+19 -5
View File
@@ -566,8 +566,18 @@ fn find_obscura() -> Option<String> {
/// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。
/// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 <title>,此函数仅 render 分支用)。
fn extract_title_from_markdown(md: &str) -> Option<String> {
// 代码块围栏状态:true=当前在 ``` 代码块内,块内行全部跳过(不当作标题)。
// 修复:原实现只跳过 ``` 围栏行本身,块内容行(如 "code block")会被误当标题。
let mut in_code_block = false;
for line in md.lines() {
let t = line.trim();
if t.starts_with("```") {
in_code_block = !in_code_block; // 切换围栏状态(``` 开或闭)
continue;
}
if in_code_block {
continue; // 代码块内:跳过
}
if t.is_empty() {
continue;
}
@@ -579,10 +589,13 @@ fn extract_title_from_markdown(md: &str) -> Option<String> {
}
}
// 否则取首个非空、非表格/代码块的行(去掉 markdown 强调前缀)
if !t.starts_with('|') && !t.starts_with("```") {
if !t.starts_with('|') {
let cleaned: String = t.trim_start_matches(|c: char| c == '*' || c == '-').trim().to_string();
if !cleaned.is_empty() && cleaned.chars().count() <= 120 {
return Some(cleaned);
// 超长行(>120)截断到 120 作标题(innerText 纯文本无结构时首行即正文,
// 原实现整行 >120 直接跳过 → title 为 None;截断保留可读标题)。
if !cleaned.is_empty() {
let sliced: String = cleaned.chars().take(120).collect();
return Some(sliced);
}
}
}
@@ -815,8 +828,9 @@ mod tests {
#[test]
fn test_substantial_content_real_markdown_passes() {
// 真实正文:600 chars,无空壳标记,应判有实质内容
let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。\n".repeat(15);
// 真实正文:>500 chars(达到 has_substantial_content 的 500 字符下限阈值),
// 无空壳标记,应判有实质内容。原测试 md 仅 494 chars(<500)被前置阈值拦截误判空壳。
let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。异步与同步的区别在于并发模型。\n".repeat(15);
assert!(has_substantial_content(&md));
}
+79 -2
View File
@@ -475,6 +475,7 @@ fn body_snippet(v: &Value) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
// ── build_images_url:端点拼接规则 ──
@@ -641,8 +642,6 @@ mod tests {
// 边界:估算值 = MAX_IMAGE_BYTES + 1 → 应被拒
// 反推 b64 长度:要使 len*3/4 == MAX+1,len = (MAX+1)*4/3
let target = MAX_IMAGE_BYTES + 1;
let b64_len = (target as u128 * 4 / 3) as usize;
let estimated = estimate_decoded_bytes(b64_len);
// 因整数除法,估算可能略小于 target,但应保证 > MAX_IMAGE_BYTES
// 用更精确的 b64_len:target*4/3 + 4(向上补一个 base64 块),保证估算严格 > MAX
let b64_len_safe = ((target as u128 * 4 + 2) / 3) as usize;
@@ -795,4 +794,82 @@ mod tests {
msg
);
}
// ── 真实 SenseNova U1-Fast 图像生成验证(#[ignore],手动跑) ──
//
// 端到端验证:用真实 db 副本(SenseNova provider 配置)+ OS keyring 解析 api_key +
// 真实调 https://token.sensenova.cn/v1/images/generations 生成一张测试图并下载落地。
// 覆盖 generate_image 全链路:provider 选择(host 匹配)/ build_images_url 端点拼接 /
// 请求构造 / 响应解析 / SSRF 防护下载落地。
//
// 运行方式:
// set DEVFLOW_LIVE_DB=<真实 devflow.db 路径(含 wal)>
// cargo test -p devflow --lib -- --ignored live_sensenova_u1_fast --nocapture
//
// 需真实 API 凭证(付费),默认 #[ignore] 不随 CI 跑。落盘到 <cwd>/tmp_generated/ 便于清理。
/// 构造带持久授权目录的 AllowedDirs,指向项目内 tmp_generated/ 输出目录。
fn mk_allowed_with_generated_dir() -> (AllowedDirs, PathBuf) {
let out_dir = std::env::current_dir()
.expect("读 cwd")
.join("tmp_generated");
let mut persistent = std::collections::HashSet::new();
persistent.insert(out_dir.clone());
(AllowedDirs { persistent, session: Default::default(), once: Default::default() }, out_dir)
}
#[tokio::test]
#[ignore] // 真实 API 调用(付费),需 DEVFLOW_LIVE_DB 环境变量,手动跑
async fn live_sensenova_u1_fast() {
let db_path = std::env::var("DEVFLOW_LIVE_DB")
.map(PathBuf::from)
.expect("请设置 DEVFLOW_LIVE_DB=<真实 devflow.db 路径> 以跑真实 API 验证");
assert!(
db_path.exists(),
"DEVFLOW_LIVE_DB 指向的 db 不存在: {}",
db_path.display()
);
// 打开真实 db(会跑迁移,用真实路径;读 SenseNova provider + keyring 解析 key)
let db = Arc::new(Database::open(&db_path).await.expect("打开真实 db"));
let (allowed, out_dir) = mk_allowed_with_generated_dir();
// 走 execute_generate_image 全链路:model 默认 sensenova-u1-fast → host 匹配 SenseNova provider
let args = json!({
"prompt": "一张极简科技风信息图,主题「DevFlow AI 工作流」,顶部大标题,三个步骤图标(规划→执行→核查),浅蓝灰配色",
"size": "1536x2752",
"n": 1
});
let result = execute_generate_image(args, &db, &Arc::new(RwLock::new(allowed)))
.await
.expect("execute_generate_image 应成功生成并下载图片");
let path = result["path"].as_str().expect("result 应有 path");
let bytes = result["bytes_written"].as_u64().expect("result 应有 bytes_written");
let model = result["model"].as_str().unwrap_or("");
let provider_id = result["provider_id"].as_str().unwrap_or("");
println!("✅ 生成成功:");
println!(" model : {}", model);
println!(" provider : {}", provider_id);
println!(" 本地路径 : {}", path);
println!(" 字节数 : {}", bytes);
// 断言:文件真实落盘 + 非空 + 是图片(PNG 魔数)
assert!(
std::path::Path::new(path).exists(),
"图片应真实落盘: {}",
path
);
assert!(bytes > 1000, "图片应 > 1KB,实际 {} bytes", bytes);
let head = std::fs::read(path).expect("读图片头").into_iter().take(8).collect::<Vec<_>>();
assert!(
head.starts_with(&[0x89, b'P', b'N', b'G']) || head.starts_with(&[0xFF, 0xD8, 0xFF]),
"应为 PNG/JPEG 魔数,实际头字节: {:?}",
head
);
// 清理输出目录(测后不留临时图)
let _ = std::fs::remove_dir_all(&out_dir);
println!(" (输出目录已清理:{})", out_dir.display());
}
}
+5 -5
View File
@@ -288,11 +288,11 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
///
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
/// `scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
///
/// 锁中毒(P1-260617-3 加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。
/// 锁中毒(加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。
/// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped`
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
@@ -346,11 +346,11 @@ async fn skills_lock_async() -> Option<SkillsGuard> {
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`clone),
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
///
/// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking
/// 改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
let g = skills_lock_async().await;
// 锁中毒 → None → unwrap_or_default() 得空 Vec(对齐 P1-260617-3 中毒降级)。
// 锁中毒 → None → unwrap_or_default() 得空 Vec(中毒降级)。
g.and_then(|g| g.clone()).unwrap_or_default()
}
@@ -376,7 +376,7 @@ pub(crate) fn invalidate_skills() {
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
/// 缓存未命中返 None;文件读失败返 None。
///
/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
/// 改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
///
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
+4 -4
View File
@@ -1251,8 +1251,8 @@ mod tests {
///
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> super::get_app_config::GetAppConfigCtx {
super::get_app_config::GetAppConfigCtx {
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
crate::commands::ai::get_app_config::GetAppConfigCtx {
db: db.clone(),
agent_max_iterations: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
@@ -1586,7 +1586,7 @@ mod tests {
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多");
assert_eq!(res["truncated"], true, "600 行只取 15 行,应有更多");
fs::remove_dir_all(&tmp).ok();
}
@@ -1613,7 +1613,7 @@ mod tests {
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多");
assert_eq!(res["truncated"], true, "600 行只取 500 行,应有更多");
fs::remove_dir_all(&tmp).ok();
}
+28 -10
View File
@@ -150,13 +150,13 @@ pub fn register(
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
let line_count = content.lines().count();
let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() {
let (result, offset_used, has_more, limit) = if let Some(offset) = args["offset"].as_u64() {
let lines: Vec<&str> = content.lines().collect();
let skip = offset as usize;
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
let more = (skip + page.len()) < line_count;
(page.join("\n"), Some(skip), more)
(page.join("\n"), Some(skip), more, limit)
} else {
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
@@ -168,13 +168,26 @@ pub fn register(
.min(2000) as usize;
let page: Vec<&str> = content.lines().take(limit).collect();
let more = line_count > page.len();
(page.join("\n"), None, more)
(page.join("\n"), None, more, limit)
};
// 截断明文化:治模型把 has_more 当"被压缩/损坏"反复重读同文件(实证 read_file 同文件重复读 6 次)。
// returned_lines=本次返回行数,consumed=累计已读(offset+本次),truncated_info 明确剩余行数+续读方式。
let returned_lines = result.lines().count();
let consumed = offset_used.unwrap_or(0) + returned_lines;
let truncated_info = if has_more {
Some(format!(
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={} 参数(可配合 limit={limit})",
offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()),
line_count - consumed,
consumed,
))
} else { None };
Ok(serde_json::json!({
"path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
"offset": offset_used,
"returned_lines": result.lines().count(),
"has_more": has_more,
"returned_lines": returned_lines,
"truncated": has_more,
"truncated_info": truncated_info,
}))
}
);
@@ -1034,7 +1047,7 @@ pub fn register(
registry,
dummy: Arc<()>,
"run_command",
"在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。",
"在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。返回含 succeeded 布尔(按 exit_code==0 判定)。注意:部分命令非零退出属正常语义(如 git diff 无差异返回 1),此时以 stdout/stderr 内容为准,勿仅凭 exit_code≠0 判失败。",
RiskLevel::High,
schema: object_schema(vec![
("command", "string", true),
@@ -1108,6 +1121,7 @@ pub fn register(
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"succeeded": result.exit_code == Some(0),
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
@@ -1289,11 +1303,15 @@ mod tests {
let win_start = char_start.saturating_sub(context_chars);
let win_end = (char_start + context_chars).min(total_chars);
let window: String = line.chars().skip(win_start).take(win_end - win_start).collect();
// 结构:window 前可加前导 …(仅 win_start>0,match 行首不加),窗口标记附加在 window 后。
// 修复:原 format 在 window 前有字面量 …(±N字符窗口)…,致 match 行首(win_start=0)
// 也以 … 开头,违反「行首不前导 …」语义。
let lead = if win_start > 0 { "" } else { "" };
format!(
"{}…(±{}字符窗口)…{}",
if win_start > 0 { "" } else { "" },
context_chars,
window
"{lead}{window}…(±{context_chars}字符窗口)…",
lead = lead,
window = window,
context_chars = context_chars
)
}
+1
View File
@@ -23,3 +23,4 @@ pub mod idea;
pub mod trash;
pub mod file;
pub mod download_file;
pub mod entity_resolve;