优化: 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:
@@ -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 + 黑名单):
|
||||
|
||||
@@ -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(负载均衡池可编辑层)。
|
||||
|
||||
@@ -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)。
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 用 { } 限作用域。
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ pub mod idea;
|
||||
pub mod trash;
|
||||
pub mod file;
|
||||
pub mod download_file;
|
||||
pub mod entity_resolve;
|
||||
|
||||
@@ -266,7 +266,6 @@ pub async fn evaluate_idea(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<IdeaRecord, String> {
|
||||
// 取出灵感
|
||||
let record = state
|
||||
.ideas
|
||||
.get_by_id(&id)
|
||||
@@ -274,13 +273,7 @@ pub async fn evaluate_idea(
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("灵感不存在: {id}"))?;
|
||||
|
||||
let idea = record_to_idea(&record);
|
||||
|
||||
// 多维评分(0-10,IPC 层 *10 缩放为 0-100)
|
||||
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
|
||||
|
||||
// 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM,无 provider/构造失败 → 启发式兜底)
|
||||
// F-01 阶段5: 透传 model_configs 池,evaluate_with_llm 经路由选模型(池空兜底 default_model)。
|
||||
// 构造 engine(单次评估)
|
||||
let provider = build_default_provider(&state).await;
|
||||
let engine = match provider {
|
||||
Some((p, pool)) => {
|
||||
@@ -288,6 +281,92 @@ pub async fn evaluate_idea(
|
||||
}
|
||||
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
|
||||
};
|
||||
|
||||
evaluate_one(&state, record, &engine).await
|
||||
}
|
||||
|
||||
/// 批量评估灵感:复用同一 AdversarialEngine 实例(同 provider/model_pool),逐条评估并持久化。
|
||||
///
|
||||
/// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。
|
||||
/// - provider 构造仅一次(批量场景减少重复初始化开销)。
|
||||
/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。
|
||||
#[tauri::command]
|
||||
pub async fn evaluate_ideas_batch(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<String>,
|
||||
) -> Result<BatchEvalResult, String> {
|
||||
let provider = build_default_provider(&state).await;
|
||||
let engine = match provider {
|
||||
Some((p, pool)) => {
|
||||
df_ideas::adversarial::AdversarialEngine::with_pool(Arc::from(p), pool)
|
||||
}
|
||||
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
|
||||
};
|
||||
|
||||
let mut success = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for id in ids {
|
||||
let record = match state.ideas.get_by_id(&id).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
errors.push(BatchEvalError {
|
||||
id: id.clone(),
|
||||
error: format!("灵感不存在: {id}"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(BatchEvalError {
|
||||
id: id.clone(),
|
||||
error: e.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match evaluate_one(&state, record, &engine).await {
|
||||
Ok(updated) => success.push(updated),
|
||||
Err(e) => errors.push(BatchEvalError { id, error: e }),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BatchEvalResult { success, errors })
|
||||
}
|
||||
|
||||
/// 批量评估结果
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct BatchEvalResult {
|
||||
/// 成功评估的灵感记录
|
||||
pub success: Vec<IdeaRecord>,
|
||||
/// 失败的灵感 ID + 错误信息
|
||||
pub errors: Vec<BatchEvalError>,
|
||||
}
|
||||
|
||||
/// 批量评估单项错误
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct BatchEvalError {
|
||||
pub id: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// 单条灵感评估内部函数(evaluate_idea / evaluate_ideas_batch 共用)。
|
||||
///
|
||||
/// 接收已构造的 AdversarialEngine(批量场景复用同一实例),完成:
|
||||
/// 1. 多维评分 + 对抗评估
|
||||
/// 2. 组装 ai_analysis / scores JSON
|
||||
/// 3. 原子写回主表(update_full)
|
||||
/// 4. 追加评估历史快照(idea_evaluations,含 version 唯一约束重试)
|
||||
async fn evaluate_one(
|
||||
state: &State<'_, AppState>,
|
||||
record: IdeaRecord,
|
||||
engine: &df_ideas::adversarial::AdversarialEngine,
|
||||
) -> Result<IdeaRecord, String> {
|
||||
let id = record.id.clone();
|
||||
let idea = record_to_idea(&record);
|
||||
|
||||
// 多维评分(0-10,IPC 层 *10 缩放为 0-100)
|
||||
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
|
||||
|
||||
let eval = engine.evaluate(&idea).await.map_err(err_str)?;
|
||||
|
||||
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
|
||||
@@ -334,7 +413,6 @@ pub async fn evaluate_idea(
|
||||
let score_value = (scores.overall * 10.0).round() as i64;
|
||||
|
||||
// 构造完整记录后单次原子写回(update_full 保留 id 与 created_at)。
|
||||
// ai_analysis/scores_json 按值 move 进主表记录后,下方历史快照仍需复用 → 此处 clone 保留绑定。
|
||||
let updated = IdeaRecord {
|
||||
scores: Some(scores_json.clone()),
|
||||
ai_analysis: Some(ai_analysis.clone()),
|
||||
@@ -349,15 +427,8 @@ pub async fn evaluate_idea(
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
// 追加一条评估历史快照(idea_evaluations 审计表,version 单调递增)。
|
||||
// 主表 update_full 成功后再追加,保证主表先落;历史表为额外冗余列(evaluated_by
|
||||
// 独立冗余,ai_analysis JSON 内的 evaluated_by 字段保留不删)。
|
||||
//
|
||||
// version 并发重复兜底(V25 唯一约束 + 重试):version 此前由
|
||||
// `list_by_idea().first().version + 1` 算出,读-改-写非原子,并发评估同一灵感
|
||||
// 可能写出相同 version。V25 在 idea_evaluations(idea_id, version) 上加了唯一索引,
|
||||
// 此处捕获唯一约束冲突 → 重新查最新 version 重算并重试(上限 3 次防死循环)。
|
||||
// 单用户桌面应用并发概率极低,但唯一约束 + 重试是数据完整性兜底,值得做。
|
||||
// 追加评估历史快照(idea_evaluations 审计表,version 单调递增)。
|
||||
// version 并发重复兜底(V25 唯一约束 + 重试)。
|
||||
let mut attempt = 0;
|
||||
let max_attempts = 3;
|
||||
loop {
|
||||
@@ -383,10 +454,6 @@ pub async fn evaluate_idea(
|
||||
match state.idea_evaluations.insert(eval_record).await {
|
||||
Ok(_) => break,
|
||||
Err(e) => {
|
||||
// 唯一约束冲突(SQLite extended code 2067 / SQLITE_CONSTRAINT_UNIQUE)
|
||||
// → version 并发重复,命中且未达上限则重试(重新查 version);否则向上抛错。
|
||||
// 检测逻辑收口到 df_storage::crud::is_unique_constraint_err,集中维护、
|
||||
// 大小写不敏感,不再散落脆弱的英文文案 contains。
|
||||
if is_unique_constraint_err(&e) && attempt < max_attempts {
|
||||
tracing::warn!(
|
||||
"灵感 {id} 评估历史 version 唯一约束冲突,重试 {}/{}",
|
||||
|
||||
Reference in New Issue
Block a user