修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导

- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
This commit is contained in:
2026-06-27 21:32:12 +08:00
parent a6d692270f
commit 9c3f27f4ca
26 changed files with 743 additions and 104 deletions

5
.cargo/config.toml Normal file
View File

@@ -0,0 +1,5 @@
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]

105
.cargo/ts-bench/Cargo.lock generated Normal file
View File

@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "regex"
version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "tree-sitter"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
dependencies = [
"cc",
"regex",
"regex-syntax",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-rust"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "ts-bench"
version = "0.1.0"
dependencies = [
"tree-sitter",
"tree-sitter-rust",
]

View File

@@ -0,0 +1,13 @@
[package]
name = "ts-bench"
version = "0.1.0"
edition = "2021"
[dependencies]
tree-sitter = "0.23"
tree-sitter-rust = "0.23"
[profile.release]
opt-level = 3
[workspace]

184
.cargo/ts-bench/src/main.rs Normal file
View File

@@ -0,0 +1,184 @@
use std::env;
use std::fs;
use std::hint::black_box;
use std::time::Instant;
use tree_sitter::{Node, Parser, Query, QueryCursor};
fn char_to_token_est(chars: usize) -> usize {
(chars + 3) / 4
}
fn extract_skeleton(root: Node, src: &str, q: &Query) -> (String, usize) {
let mut out = String::new();
let mut count = 0usize;
let mut cursor = QueryCursor::new();
let matches = cursor.matches(q, root, src.as_bytes());
for mat in matches {
for cap in mat.captures {
let n = cap.node;
let start_byte = n.start_byte();
let header_end = {
let from_start = &src[start_byte..];
let mut depth: i32 = 0;
let mut end = from_start.len();
for (i, b) in from_start.char_indices() {
match b {
'{' => {
if depth == 0 {
end = i;
break;
}
depth += 1;
}
';' => {
end = i;
break;
}
_ => {}
}
let _ = depth;
}
start_byte + end.min(from_start.len())
};
let header = &src[start_byte..header_end].trim_end();
out.push_str(header);
out.push_str(" { ... }\n");
count += 1;
}
}
(out, count)
}
fn main() {
let args: Vec<String> = env::args().collect();
let path = args.get(1).expect("usage: ts-bench <file.rs>");
let src = fs::read_to_string(path).expect("read");
let src_chars = src.chars().count();
let src_tokens_est = char_to_token_est(src.len());
let mut parser = Parser::new();
parser.set_language(&tree_sitter_rust::LANGUAGE.into()).unwrap();
// warmup
let _ = parser.parse(&src, None).unwrap();
// ---- PURE PARSE timing ----
let n = 200;
let mut times_us = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let tree = parser.parse(black_box(&src), None).unwrap();
let t1 = Instant::now();
drop(tree);
times_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let min = times_us[0];
let med = times_us[n / 2];
let p95 = times_us[(n as f64 * 0.95) as usize];
let mean = times_us.iter().sum::<f64>() / n as f64;
// ---- INCREMENTAL PARSE (edit one byte at end) ----
let mut src_edited = src.clone();
src_edited.push_str("\n// comment\n");
let tree0 = parser.parse(&src, None).unwrap();
let mut times_inc = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let t2 = parser.parse(&src_edited, Some(&tree0)).unwrap();
let t1 = Instant::now();
drop(t2);
times_inc.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
times_inc.sort_by(|a, b| a.partial_cmp(b).unwrap());
let inc_med = times_inc[n / 2];
// ---- Query compilation timing ----
let query_text = r#"
[
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(enum_item name: (type_identifier) @name)
(trait_item name: (type_identifier) @name)
(impl_item trait: (type_identifier)? @trait type: (type_identifier)? @type)
(const_item name: (identifier) @name)
(static_item name: (identifier) @name)
(type_item name: (type_identifier) @name)
(mod_item name: (identifier) @name)
(macro_definition name: (identifier) @name)
]
"#;
let mut qtimes = Vec::with_capacity(n);
for _ in 0..n {
let t0 = Instant::now();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let t1 = Instant::now();
drop(q);
qtimes.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
}
qtimes.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q_med = qtimes[n / 2];
// ---- Skeleton extraction (compiled query reused) ----
let tree = parser.parse(&src, None).unwrap();
let root = tree.root_node();
let q = Query::new(&tree_sitter_rust::LANGUAGE.into(), query_text).unwrap();
let m = 200;
let mut ext_us = Vec::with_capacity(m);
let mut last_skel = String::new();
let mut last_count = 0;
for _ in 0..m {
let t0 = Instant::now();
let (skel, cnt) = extract_skeleton(root, &src, &q);
let t1 = Instant::now();
ext_us.push(t1.duration_since(t0).as_nanos() as f64 / 1000.0);
last_skel = skel;
last_count = cnt;
}
ext_us.sort_by(|a, b| a.partial_cmp(b).unwrap());
let ext_med = ext_us[m / 2];
let skel_chars = last_skel.chars().count();
let skel_tokens_est = char_to_token_est(last_skel.len());
println!("=== FILE ===");
println!("path : {}", path);
println!("lines : {}", src.lines().count());
println!("chars : {}", src_chars);
println!("bytes : {}", src.len());
println!("tokens est (~/4c) : {}", src_tokens_est);
println!();
println!("=== PARSE SPEED (pure tree-sitter-rust, release, {} iters) ===", n);
println!("min={:.2}us median={:.2}us p95={:.2}us mean={:.2}us", min, med, p95, mean);
println!("median ms : {:.3} ms", med / 1000.0);
println!();
println!("=== INCREMENTAL PARSE (small edit) ===");
println!("median : {:.2}us ({:.3}ms)", inc_med, inc_med / 1000.0);
println!();
println!("=== QUERY COMPILATION (once, should be cached) ===");
println!("median : {:.2}us ({:.3}ms)", q_med, q_med / 1000.0);
println!();
println!("=== SKELETON EXTRACTION (compiled query reused) ===");
println!("symbols extracted : {}", last_count);
println!("skeleton chars : {}", skel_chars);
println!("skeleton tokens : {}", skel_tokens_est);
println!("extract median : {:.2}us ({:.3}ms)", ext_med, ext_med / 1000.0);
println!();
println!("=== DENSITY (full vs skeleton) ===");
println!(
"chars : {:.1}x ({:.1}% reduction)",
src_chars as f64 / skel_chars as f64,
(1.0 - skel_chars as f64 / src_chars as f64) * 100.0
);
println!(
"tokens : {:.1}x ({:.1}% reduction)",
src_tokens_est as f64 / skel_tokens_est as f64,
(1.0 - skel_tokens_est as f64 / src_tokens_est as f64) * 100.0
);
println!();
println!("=== SKELETON PREVIEW (first 30 lines) ===");
for line in last_skel.lines().take(30) {
println!("{}", line);
}
}

View File

@@ -5,3 +5,4 @@ mod adversarial_helpers;
pub mod capture; pub mod capture;
pub mod promotion; pub mod promotion;
pub mod scoring; pub mod scoring;
pub mod scoring_keywords;

View File

@@ -11,29 +11,14 @@ pub use crate::capture::IdeaScores;
use df_types::types::Priority; use df_types::types::Priority;
// ─── 启发式关键词(DEC-03 c:同文件 const 拆出,接 LLM 语义评分后升 a:JSON 文件配置)─── // ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
// //
// 现阶段关键词集合固定且与维度强相关,抽 const 仅为可读性/可维护性(零行为变化) // 关键词常量已拆至独立文件 `scoring_keywords.rs`
// TODO(DEC-03 a):接入 LLM 语义评分后,这些关键词退居「冷启动兜底」, // 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
// 集合迁移到 JSON 配置文件,支持热更新与项目级覆盖。 pub use crate::scoring_keywords::{
// 注:以下集合与原方法内字面量逐一对应,集合/顺序/否定前缀/权重均不变(纯结构提取)。 FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
/// 可行性正向信号词(描述充实度外的技术/资源信号),命中每个 +0.5 };
pub const FEASIBILITY_POSITIVE_KEYWORDS: &[&str] = &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
];
/// 可行性负向信号词(复杂度上升信号),命中每个 -0.6
pub const FEASIBILITY_NEGATIVE_KEYWORDS: &[&str] = &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
];
/// 影响力价值信号词(用户/增长/收入...),命中每个 +0.5
pub const IMPACT_KEYWORDS: &[&str] = &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
];
/// 紧急度时效信号词(立即/马上/紧急...),命中每个 +0.5
pub const URGENCY_KEYWORDS: &[&str] = &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
];
/// 评分权重配置 /// 评分权重配置
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@@ -0,0 +1,24 @@
//! 评分引擎关键词 — 从 `scoring.rs` 拆出的独立常量文件。
//!
//! 启发式评分是过渡方案(对抗式评估上线后按需升级为 JSON 配置),
//! 此处仅做最小改动:将硬编码关键词集中于此,保持 `scoring.rs` 逻辑清晰。
/// 可行性正向信号词(描述充实度外的技术/资源信号),命中每个 +0.5
pub const FEASIBILITY_POSITIVE_KEYWORDS: &[&str] = &[
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
];
/// 可行性负向信号词(复杂度上升信号),命中每个 -0.6
pub const FEASIBILITY_NEGATIVE_KEYWORDS: &[&str] = &[
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
];
/// 影响力价值信号词(用户/增长/收入...),命中每个 +0.5
pub const IMPACT_KEYWORDS: &[&str] = &[
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
];
/// 紧急度时效信号词(立即/马上/紧急...),命中每个 +0.5
pub const URGENCY_KEYWORDS: &[&str] = &[
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
];

View File

@@ -0,0 +1,30 @@
# df-relay 跨端 WS 中继反向代理(miniapp ↔ 桌面端)
# wss 在 nginx 终止(复用 *.1216.top 通配符证书),明文 ws 转 df-relay :9180
# HTTP → HTTPS 重定向
server {
listen 80;
server_name u-work.1216.top;
return 301 https://$host$request_uri;
}
# HTTPS wss 反代
server {
listen 443 ssl;
server_name u-work.1216.top;
include /etc/nginx/snippets/ssl-1216.conf;
# df-relay 两个 WS 端点原样转发(/ws/device + /ws/miniapp)
# proxy_pass 不带路径后缀 → 完整 URI 透传给 df-relay
location /ws/ {
proxy_pass http://127.0.0.1:9180;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}

View File

@@ -73,6 +73,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
dot / (norm_a * norm_b + 1e-8) dot / (norm_a * norm_b + 1e-8)
} }
/// 解析 JSON 数组字符串为 Vec<String>。NULL / 空 / 非法 → 空 Vec。
fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
match raw {
Some(s) if !s.is_empty() => match serde_json::from_str::<Vec<String>>(s) {
Ok(v) => v,
Err(_) => Vec::new(),
},
_ => Vec::new(),
}
}
// ============================================================ // ============================================================
// from_row 辅助函数 // from_row 辅助函数
// ============================================================ // ============================================================
@@ -385,6 +396,120 @@ impl IdeaRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
/// 新增的关联目标追加 `subject_id` 到其 `related_ids`;
/// 移除的关联目标从中删除 `subject_id`。
/// 全部操作在同一 SQLite 事务中完成,保证原子性。
pub async fn sync_related_ids(
&self,
subject_id: &str,
new_target_ids: &[String],
) -> Result<()> {
use rusqlite::Transaction;
let conn = self.conn.clone();
let subject_id = subject_id.to_owned();
let new_target_ids = new_target_ids.to_vec();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock();
let tx: Transaction = guard
.transaction()
.map_err(storage_err)?;
// 1. 读主体当前 related_ids
let old_raw: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![subject_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
// 2. 解析新旧集合
let old_set: std::collections::HashSet<String> =
parse_json_id_array(&old_raw).into_iter().collect();
let new_set: std::collections::HashSet<String> =
new_target_ids.iter().cloned().collect();
let added: Vec<&str> = new_set
.difference(&old_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id) // 不自关联
.collect();
let removed: Vec<&str> = old_set
.difference(&new_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id)
.collect();
// 3. 更新 added 目标:追加 subject_id
for target_id in &added {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
if !ids.iter().any(|i| i == &subject_id) {
ids.push(subject_id.clone());
}
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 4. 更新 removed 目标:移除 subject_id
for target_id in &removed {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
ids.retain(|i| i != &subject_id);
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 5. 更新主体
let new_json = serde_json::to_string(&new_target_ids)
.map_err(|e| storage_err::<df_types::error::Error>(e.into()))?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![new_json, &now, &subject_id],
)
.map_err(storage_err)?;
tx.commit().map_err(storage_err)?;
Ok(())
})
.await
.map_err(storage_err)?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。 /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。 /// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> { pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {

View File

@@ -463,9 +463,9 @@ pub async fn ai_chat_send(
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取首条 user 目标 pinned_goal(仅 None 时写,连续追问视为同目标)。 // G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入 // GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() { if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content); let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() { if !goal.is_empty() {
conv.pinned_goal = Some(goal); conv.pinned_goal = Some(goal);
@@ -751,12 +751,12 @@ pub async fn ai_approve(
Ok("completed".to_string()) Ok("completed".to_string())
} }
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。 /// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
fn authz_debug(msg: &str) { fn authz_debug(msg: &str) {
use std::io::Write; use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true) let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
.open("E:/wk-lab/devflow/authz-debug.log") if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
{
let _ = writeln!(f, "{}", msg); let _ = writeln!(f, "{}", msg);
} }
} }
@@ -774,17 +774,10 @@ fn drive_letter(path: &str) -> Option<String> {
None None
} }
/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。 /// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
/// /// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。
/// 失败回退 None(对比退化,不报跨盘提示)。
fn workspace_drive() -> Option<String> { fn workspace_drive() -> Option<String> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
.parent()
.and_then(|p| p.parent())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("."));
drive_letter(&root.to_string_lossy())
} }
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。 /// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
@@ -1366,6 +1359,13 @@ pub async fn ai_chat_edit(
conv.iteration_used = 0; conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。 // F-01 阶段6: 记录用户指定模型 override(主对话专用)。
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&new_message);
if !goal.is_empty() {
conv.pinned_goal = Some(goal);
}
}
} }
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
@@ -1531,10 +1531,10 @@ pub async fn ai_chat_force_send(
} }
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎:push 后提取首条 user 目标 pinned_goal(仅 None 时写,与 send 路径一致)。 // G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 → // force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
// pinned_goal 已更新,续跑用新目标)。 // pinned_goal 已更新,续跑用新目标)。
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() { if GOAL_PIN_ENABLED {
let goal = extract_pinned_goal(&user_content); let goal = extract_pinned_goal(&user_content);
if !goal.is_empty() { if !goal.is_empty() {
conv.pinned_goal = Some(goal); conv.pinned_goal = Some(goal);

View File

@@ -402,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
"run_command" => { "run_command" => {
let dir = match args.get("working_dir").and_then(|v| v.as_str()) { let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => normalize_dir_key(d), Some(d) => normalize_dir_key(d),
None => workspace_root_str(), None => String::new(),
}; };
Some(TrustKey::Execute { dir }) Some(TrustKey::Execute { dir })
} }
@@ -423,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String {
.unwrap_or_else(|| std::path::PathBuf::from(".")); .unwrap_or_else(|| std::path::PathBuf::from("."));
normalize_dir_key(&parent.to_string_lossy()) normalize_dir_key(&parent.to_string_lossy())
} }
Err(_) => workspace_root_str(), Err(_) => String::new(),
} }
} }
@@ -783,7 +783,7 @@ pub struct PerConvState {
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然 /// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。 /// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
/// ///
/// 写:chat.rs send/force_send push 后提取(仅 None 时写首条);读:loop 入口拼接。 /// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。 /// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。 /// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
pub pinned_goal: Option<String>, pub pinned_goal: Option<String>,

View File

@@ -286,15 +286,6 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String {
format!("{}_{}", modified.unwrap_or(0), meta.len()) format!("{}_{}", modified.unwrap_or(0), meta.len())
} }
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。 /// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
/// ///
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17` /// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
@@ -339,19 +330,15 @@ async fn rename_or_cross_volume_copy(
} }
} }
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录 /// 解析文件工具路径:相对路径锚定已授权目录的首个 persistent 目录,禁止越出项目目录
/// ///
/// 双层校验: /// 双层校验:
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界 /// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸 /// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好 /// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
/// ///
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。 /// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用, /// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
fn resolve_workspace_path_with_allowed( fn resolve_workspace_path_with_allowed(
path: &str, path: &str,
allowed: &AllowedDirs, allowed: &AllowedDirs,
@@ -363,31 +350,31 @@ fn resolve_workspace_path_with_allowed(
/// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数 /// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。 /// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
/// ///
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key), /// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在 /// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响 /// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> { pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root()) resolve_workspace_path_impl(path, &AllowedDirs::default())
} }
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> { fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
validate_path(path)?; validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() { let resolved = if Path::new(path).is_absolute() {
PathBuf::from(path) PathBuf::from(path)
} else { } else {
// 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
// 无授权目录时给出明确引导,避免指向编译期常量。
let root = allowed.first_persistent_dir()
.ok_or_else(|| anyhow::anyhow!(
"请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
))?;
root.join(path) root.join(path)
}; };
// 词法层:防明显越界(不存在路径的兜底)。 // 词法层:防明显越界(不存在路径的兜底)。
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
if !allowed.is_authorized(&resolved) { if !allowed.is_authorized(&resolved) {
anyhow::bail!("路径不在授权目录内: {}", path); anyhow::bail!("路径不在授权目录内: {}", path);
} }
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。 // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
if resolved.exists() { if resolved.exists() {
let canon_resolved = resolved.canonicalize()?; let canon_resolved = resolved.canonicalize()?;
if !allowed.is_authorized(&canon_resolved) { if !allowed.is_authorized(&canon_resolved) {
@@ -449,10 +436,11 @@ async fn bind_dir_to_project(
pub fn build_ai_tool_registry( pub fn build_ai_tool_registry(
db: &Arc<Database>, db: &Arc<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>, allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf,
) -> AiToolRegistry { ) -> AiToolRegistry {
let mut registry = AiToolRegistry::new(); let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db); register_data_tools(&mut registry, db);
register_file_tools(&mut registry, allowed_dirs); register_file_tools(&mut registry, allowed_dirs, data_dir);
register_http_tools(&mut registry); register_http_tools(&mut registry);
registry registry
} }
@@ -1412,6 +1400,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
fn register_file_tools( fn register_file_tools(
registry: &mut AiToolRegistry, registry: &mut AiToolRegistry,
allowed_dirs: &Arc<RwLock<AllowedDirs>>, allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf,
) { ) {
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。 // F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环), // run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
@@ -1967,8 +1956,9 @@ fn register_file_tools(
("permanent", "boolean", false), ("permanent", "boolean", false),
]), ]),
RiskLevel::High, RiskLevel::High,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { { let allowed_dirs = allowed_dirs.clone(); let data_dir = data_dir.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone(); let allowed_dirs = allowed_dirs.clone();
let data_dir = data_dir.clone();
Box::pin(async move { Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸 // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
let snap = allowed_dirs.read().await.clone(); let snap = allowed_dirs.read().await.clone();
@@ -2004,15 +1994,15 @@ fn register_file_tools(
})) }))
} else { } else {
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制) // 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace // .trash 放 app_data_dir 下,不依赖编译期 workspace_root
let trash_dir = workspace_root().join(".trash"); let trash_dir = data_dir.join(".trash");
tokio::fs::create_dir_all(&trash_dir).await tokio::fs::create_dir_all(&trash_dir).await
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?; .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
let backup_name = format!("{}-{}", new_id(), file_name); let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name); let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string(); let backup_path_str = backup_path.to_string_lossy().to_string();
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, // 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子, // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。 // 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理), // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败 // 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
@@ -2325,16 +2315,14 @@ fn register_file_tools(
Box::new(|args: serde_json::Value| Box::pin(async move { Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str() let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环) // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验—— // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => { Some(d) => {
validate_path(d)?; validate_path(d)?;
d.to_string() d.to_string()
} }
None => workspace_root().to_string_lossy().to_string(), None => String::new(),
}; };
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)

View File

@@ -140,6 +140,21 @@ pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool
state.ideas.restore(&id).await.map_err(err_str) state.ideas.restore(&id).await.map_err(err_str)
} }
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
#[tauri::command]
pub async fn relate_ideas(
state: State<'_, AppState>,
subject_id: String,
target_ids: Vec<String>,
) -> Result<(), String> {
state
.ideas
.sync_related_ids(&subject_id, &target_ids)
.await
.map_err(err_str)
}
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。 /// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
#[tauri::command] #[tauri::command]
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> { pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
@@ -199,10 +214,10 @@ pub async fn promote_idea(
..record ..record
}; };
if let Err(e) = state.ideas.update_full(&updated).await { if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿删已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项) // 回写失败:补偿删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}"); tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await { if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿删项目 {project_id} 也失败(需人工清理): {del_err}"); tracing::error!("补偿删项目 {project_id} 也失败(需人工清理): {del_err}");
} }
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e)); return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
} }

View File

@@ -47,3 +47,9 @@ pub async fn settings_delete(
) -> Result<bool, String> { ) -> Result<bool, String> {
state.settings.delete(&key).await.map_err(err_str) state.settings.delete(&key).await.map_err(err_str)
} }
/// 获取 DevFlow 数据目录路径(运行期确定,跨平台)
#[tauri::command]
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string())
}

View File

@@ -23,8 +23,8 @@ pub fn run() {
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join(db_name); let db_path = data_dir.join(db_name);
// 初始化全局状态(打开数据库 + 执行迁移) // 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?; let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断) // FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
if let Err(e) = tauri::async_runtime::block_on( if let Err(e) = tauri::async_runtime::block_on(
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers), commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
@@ -272,6 +272,7 @@ pub fn run() {
commands::idea::list_ideas, commands::idea::list_ideas,
commands::idea::create_idea, commands::idea::create_idea,
commands::idea::update_idea, commands::idea::update_idea,
commands::idea::relate_ideas,
commands::idea::delete_idea, commands::idea::delete_idea,
commands::idea::restore_idea, commands::idea::restore_idea,
commands::idea::list_deleted_ideas, commands::idea::list_deleted_ideas,
@@ -352,6 +353,7 @@ pub fn run() {
commands::settings::settings_set, commands::settings::settings_set,
commands::settings::settings_get_all, commands::settings::settings_get_all,
commands::settings::settings_delete, commands::settings::settings_delete,
commands::settings::get_data_dir,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -393,6 +393,8 @@ pub struct AppState {
pub knowledge_events: KnowledgeEventsRepo, pub knowledge_events: KnowledgeEventsRepo,
/// 知识库行为配置(提取 + 注入) /// 知识库行为配置(提取 + 注入)
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>, pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
/// 应用数据目录(运行期确定:Windows $APPDATA/devflow, macOS ~/Library/Application Support/devflow)
pub data_dir: PathBuf,
/// 通用应用设置 KV Repo(前端 localStorage 迁移目标) /// 通用应用设置 KV Repo(前端 localStorage 迁移目标)
pub settings: SettingsRepo, pub settings: SettingsRepo,
// ── LLM 并发控制 ── // ── LLM 并发控制 ──
@@ -468,6 +470,12 @@ impl AllowedDirs {
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() } Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
} }
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
self.persistent.iter().next()
}
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。 /// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
/// ///
/// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs /// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
@@ -577,11 +585,16 @@ pub fn check_path_authorization(
raw_path: &str, raw_path: &str,
allowed: &AllowedDirs, allowed: &AllowedDirs,
) -> PathAuthDecision { ) -> PathAuthDecision {
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致) // 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
let resolved = if Path::new(raw_path).is_absolute() { let resolved = if Path::new(raw_path).is_absolute() {
PathBuf::from(raw_path) PathBuf::from(raw_path)
} else if let Some(root) = allowed.first_persistent_dir() {
root.join(raw_path)
} else { } else {
workspace_root_path().join(raw_path) // 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。
return PathAuthDecision::NeedsAuthorization {
dir: PathBuf::from("."),
};
}; };
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
@@ -597,7 +610,7 @@ pub fn check_path_authorization(
let dir = resolved let dir = resolved
.parent() .parent()
.map(|p| p.to_path_buf()) .map(|p| p.to_path_buf())
.unwrap_or_else(|| workspace_root_path()); .unwrap_or_else(|| PathBuf::from("."));
PathAuthDecision::NeedsAuthorization { dir } PathAuthDecision::NeedsAuthorization { dir }
} }
@@ -638,11 +651,15 @@ fn best_effort_canonicalize(p: &Path) -> PathBuf {
p.to_path_buf() p.to_path_buf()
} }
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。 /// workspace 根目录(当前:编译期 CARGO_MANIFEST_DIR 上两级,仅开发机有效)。
/// ///
/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器 /// ⚠️ 分发限制:env!("CARGO_MANIFEST_DIR") 是编译期常量,打包后指向编译机路径,用户机器无效。
/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md /// 目标方案(D 混合策略):workspace_root 改为运行期动态确定,从已绑定项目的
/// (workspace_root 分发适配)。当前仅开发机自用有效。 /// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
/// 已修复:
/// - authz_debug → std::env::temp_dir() (chat.rs)
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
fn workspace_root_path() -> PathBuf { fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
@@ -651,15 +668,25 @@ fn workspace_root_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
} }
/// 应用数据目录(运行期确定,跨平台)。
/// 不依赖编译期常量,打包分发后仍有效。
/// 用于 DevFlow 自身数据存储(.trash / logs 等),非用户项目目录。
/// 取 AppState.data_dir,不持 state 时回退 workspace_root_path()(开发期兼容)。
pub fn data_dir_path(state: &AppState) -> &Path {
&state.data_dir
}
impl AppState { impl AppState {
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表 /// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
pub async fn init(db_path: &Path) -> Result<Self> { pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
let db = Arc::new(Database::open(db_path).await?); let db = Arc::new(Database::open(db_path).await?);
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc, // F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。 // 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。 // 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default()));
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs)); let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(
&db, &allowed_dirs, data_dir.clone(),
));
// Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>), // resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。 // 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
@@ -690,6 +717,7 @@ impl AppState {
knowledge: KnowledgeRepo::new(&db), knowledge: KnowledgeRepo::new(&db),
knowledge_events: KnowledgeEventsRepo::new(&db), knowledge_events: KnowledgeEventsRepo::new(&db),
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
data_dir: data_dir.clone(),
settings: SettingsRepo::new(&db), settings: SettingsRepo::new(&db),
llm_concurrency: LlmConcurrency::new(3, 2), llm_concurrency: LlmConcurrency::new(3, 2),
agent_max_iterations: Arc::new(AtomicUsize::new( agent_max_iterations: Arc::new(AtomicUsize::new(
@@ -722,6 +750,19 @@ impl AppState {
state.reload_allowed_dirs().await; state.reload_allowed_dirs().await;
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。 // P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
state.reload_knowledge_config().await; state.reload_knowledge_config().await;
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
let old_trash = workspace_root_path().join(".trash");
let new_trash = data_dir.join(".trash");
if old_trash.exists() && !new_trash.exists() {
if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
tracing::warn!(
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
old_trash, new_trash, e,
);
}
}
Ok(state) Ok(state)
} }
@@ -853,14 +894,10 @@ impl AppState {
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。 /// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
pub async fn get_allowed_dirs(&self) -> Vec<String> { pub async fn get_allowed_dirs(&self) -> Vec<String> {
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
let root = workspace_root_path();
let guard = self.allowed_dirs.read().await; let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard let mut out: Vec<String> = guard
.persistent .persistent
.iter() .iter()
.filter(|p| **p != root)
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
.collect(); .collect();
out.sort(); out.sort();
@@ -1336,4 +1373,3 @@ mod tests {
assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个"); assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个");
} }
} }

View File

@@ -40,6 +40,11 @@ export const ideaApi = {
return invoke('promote_idea', { id }) return invoke('promote_idea', { id })
}, },
/** 双向同步关联关系:原子地设置主体灵感的目标关联列表,自动添加/移除反向关联。 */
relateIdeas(subjectId: string, targetIds: string[]): Promise<void> {
return invoke('relate_ideas', { subjectId, targetIds })
},
/** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */ /** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> { listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
return invoke('list_idea_evaluations', { ideaId }) return invoke('list_idea_evaluations', { ideaId })

View File

@@ -16,6 +16,11 @@ export const settingsApi = {
return invoke<boolean>('settings_set', { key, value }) return invoke<boolean>('settings_set', { key, value })
}, },
/** 获取 DevFlow 数据目录路径(运行期确定,跨平台) */
getDataDir(): Promise<string> {
return invoke<string>('get_data_dir')
},
/** 取全部 key/value(启动时一次性拉回恢复偏好) */ /** 取全部 key/value(启动时一次性拉回恢复偏好) */
getAll(): Promise<Record<string, string>> { getAll(): Promise<Record<string, string>> {
return invoke<Record<string, string>>('settings_get_all') return invoke<Record<string, string>>('settings_get_all')

View File

@@ -5,6 +5,11 @@
<h2>{{ $t('settings.sectionAdvanced') }}</h2> <h2>{{ $t('settings.sectionAdvanced') }}</h2>
</div> </div>
<div class="general-settings"> <div class="general-settings">
<!-- 应用数据目录(运行期确定,跨平台) -->
<SettingRow :label="$t('settings.labelDataDir')" :desc="$t('settings.descDataDir')">
<span class="data-dir-path" :title="dataDir">{{ dataDir || $t('settings.loading') }}</span>
</SettingRow>
<!-- F-#97:三档 low/medium/alllow=仅低风险自动(等价旧 autoExecute=false); <!-- F-#97:三档 low/medium/alllow=仅低风险自动(等价旧 autoExecute=false);
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) --> medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')"> <SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
@@ -40,10 +45,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch, useTemplateRef } from 'vue' import { ref, reactive, watch, useTemplateRef, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { settingsApi } from '@/api'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
// ============================================================ // ============================================================
@@ -56,6 +62,14 @@ import SettingRow from './SettingRow.vue'
// ============================================================ // ============================================================
const { t } = useI18n() const { t } = useI18n()
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
// 应用数据目录(运行期确定,跨平台)
const dataDir = ref('')
onMounted(async () => {
try {
dataDir.value = await settingsApi.getDataDir()
} catch { /* 静默 */ }
})
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) } // useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) }
// confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText // confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText
// 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。 // 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。
@@ -125,6 +139,11 @@ function markSaved() {
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/ F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */ actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.data-dir-path {
font-size: 12px;
color: var(--df-text-dim);
word-break: break-all;
}
.confirm-mask { .confirm-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@@ -22,6 +22,14 @@ export default {
deleting: 'Deleting…', deleting: 'Deleting…',
promoting: 'Promoting…', promoting: 'Promoting…',
// Errors
err: {
loadFailed: 'Failed to load ideas',
createFailed: 'Failed to create idea',
deleteFailed: 'Failed to delete idea',
relateFailed: 'Failed to update relations',
},
// Adversarial evaluation // Adversarial evaluation
adversarialTitle: '⚖️ Adversarial Evaluation', adversarialTitle: '⚖️ Adversarial Evaluation',
evalModeHeuristic: 'Heuristic', evalModeHeuristic: 'Heuristic',

View File

@@ -111,6 +111,9 @@ export default {
aiLanguageZh: '🇨🇳 简体中文', aiLanguageZh: '🇨🇳 简体中文',
aiLanguageEn: '🇺🇸 English', aiLanguageEn: '🇺🇸 English',
labelDataDir: 'App Data Directory',
descDataDir: 'Where DevFlow stores .trash (recycle bin), database, logs and other internal data',
labelAutoExecute: 'AI auto-execute scope', labelAutoExecute: 'AI auto-execute scope',
descAutoExecute: 'Control risk level AI auto-executes (low/medium/all-takeover)', descAutoExecute: 'Control risk level AI auto-executes (low/medium/all-takeover)',
autoModeLow: 'Low risk (default)', autoModeLow: 'Low risk (default)',

View File

@@ -22,6 +22,14 @@ export default {
deleting: '删除中…', deleting: '删除中…',
promoting: '立项中…', promoting: '立项中…',
// 错误
err: {
loadFailed: '加载灵感失败',
createFailed: '创建灵感失败',
deleteFailed: '删除灵感失败',
relateFailed: '更新关联关系失败',
},
// 对抗式评估 // 对抗式评估
adversarialTitle: '⚖️ 对抗式评估', adversarialTitle: '⚖️ 对抗式评估',
evalModeHeuristic: '启发式', evalModeHeuristic: '启发式',

View File

@@ -111,6 +111,9 @@ export default {
aiLanguageZh: '🇨🇳 简体中文', aiLanguageZh: '🇨🇳 简体中文',
aiLanguageEn: '🇺🇸 English', aiLanguageEn: '🇺🇸 English',
labelDataDir: '应用数据目录',
descDataDir: 'DevFlow 存储 .trash(回收站)、数据库、日志等内部数据的目录路径',
labelAutoExecute: 'AI 自动执行范围', labelAutoExecute: 'AI 自动执行范围',
descAutoExecute: '控制 AI 自动执行操作的风险等级(低/中/全部接管)', descAutoExecute: '控制 AI 自动执行操作的风险等级(低/中/全部接管)',
autoModeLow: '低风险(默认)', autoModeLow: '低风险(默认)',

View File

@@ -61,6 +61,15 @@ export function createIdeasStore() {
return res return res
} }
async function relateIdeas(subjectId: string, targetIds: string[]) {
try {
await ideaApi.relateIdeas(subjectId, targetIds)
await loadIdeas() // 后端已原子更新全部受影响灵感,刷新列表保持 store 一致
} catch (e: any) {
state.error = e?.toString() ?? t('ideas.err.relateFailed')
}
}
return { return {
loadIdeas, loadIdeas,
createIdea, createIdea,
@@ -68,5 +77,6 @@ export function createIdeasStore() {
deleteIdea, deleteIdea,
evaluateIdea, evaluateIdea,
promoteIdea, promoteIdea,
relateIdeas,
} }
} }

View File

@@ -373,10 +373,10 @@ async function onUpdateDesc(desc: string) {
await store.updateIdea(currentIdea.value.id, 'description', desc) await store.updateIdea(currentIdea.value.id, 'description', desc)
} }
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存) // 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存,双向同步)
async function onUpdateRelated(ids: string[]) { async function onUpdateRelated(ids: string[]) {
if (!currentIdea.value) return if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids)) await store.relateIdeas(currentIdea.value.id, ids)
} }
onMounted(async () => { onMounted(async () => {

59
todo.md Normal file
View File

@@ -0,0 +1,59 @@
# DevFlow 待办
## 灵感模块功能完善(合并推进)
> 诊断发现的 P0-P2 问题合并为单次任务,逐项推进。
> 排除项:#3 status 无状态机(暂保留状态可跳跃迁移,不处理)。
### P0 — 数据安全 / 正确性
- [x] **#1 软删除机制**ideas 表加 `deleted_at` 列 + 软删除(对标 tasks/projects 的 `delete` + `restore` 全套模板)。当前 `delete_idea` 是硬删除,误删不可恢复。
- [x] **#2 白名单收紧**`settings.rs` ideas 白名单移除 `id` / `created_at`,防止主键和创建时间被篡改。(对标 B-260616-16 tasks 白名单修复)
- [x] **#4 priority 值域校验**`create_idea` / `update_idea` 加 priority 校验(`parse::<i32>() ∈ 0..=3`,非法返 Err对标 B-260615-15 tasks priority 修复)
### P1 — 架构 / 一致性
- [x] **#5 有损转换修复**`record_to_idea()` 不再硬编码 `status=Draft` / `related_ids=空`,从 IdeaRecord 读取真实值,评估时不丢失上下文。
- [x] **#6 评分关键词拆文件**:将 `scoring.rs` 硬编码关键词拆到独立 const 文件(最小改动)。启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。**不做 DB 表配置化**(过度工程)。
- [x] **#7 promote 补偿改软删除**`promote_idea` 回写失败时的补偿删除从 `purge_with_descendants`(不可恢复)改为 `soft_delete`(软删除,可恢复)。
- [x] **#8 关联双向同步**A 关联 B 时自动在 B 的 `related_ids` 中补入 A解绑时同步移除保持关联关系对称。
- [x] **#9 创建表单补 tags 输入**:捕捉灵感表单增加 tags 输入(逗号分隔或 chip 组件),`confirmCapture` 传 tags JSON 字符串给后端。
### P2 — 前端 / UX
- [x] **#10 创建表单补全 priority/source**:捕捉模态框增加 priority 下拉(低/中/高/紧急)和 source 输入框。
- [~] **#12 前端 filter 改后端查询**`hot`score≥80`pending`(多状态 OR仍保留前端 filter团队决策扩多值属过度设计keyword/order_by 已下沉后端。
- [x] **#14 雷达图正名/替换**:将 i18n key 和 CSS class 中的 "radar" 改为 "score-bar"(或引入真正的雷达图组件如 ECharts radar
## AI 对话目标丢失(诊断待办)
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
> 实测会话 `54c8b5bd`480 万 token原始目标消息被压缩出局LLM 尾部盲搜死循环。
- [~] **G1 目标钉扎** ✅代码落地(编译过)`PerConvState.pinned_goal` + chat.rs 两处提取 + `run_agentic_loop` 入口拼 system_prompt。核心治 R1。**待实测**。
- [~] **G2 探索熔断** ✅代码落地(编译过)`STALL_BREAKER_*` 四开关 + stall_count + `is_empty_tool_result`(对齐 serde_json 紧凑格式)。警示 `insert_at(0,system)` **经核验安全**(convert_request:101/117 把 system 抽顶层合并,无连续 System 1214 风险;G1 评审该论据不成立)。**待实测**。
- [~] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum。
- [~] **G4 话题标记修正** ✅代码落地(编译过)`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goal 存在时 take 后丢弃跳过 insert。**待实测**。
- [x] **G5 附 bug** ✅已排查(不改代码)dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。
- [ ] **G1 目标刷新策略**(openQuestion):首版 None 才写,同对话改目标保留旧目标。待观察是否需 drift 刷新。
> 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。
> 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。
## 对抗式评估接入(引擎已实现 → IPC + 前端打通)
> 引擎代码已就绪(`df-ideas/src/adversarial.rs` + `adversarial_helpers.rs`
> 但 IPC 层和前端零引用,用户无法使用。需补接入层。
### 后端 — IPC 接入
- [ ] **A1 evaluate_idea 命令**:新增 IPC command按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`
- [ ] **A2 批量评估命令**`evaluate_ideas_batch`,遍历多个 idea 调同一 engine 实例(复用 provider 连接)。
- [ ] **A3 评估结果持久化**:评估结果写回 `ideas`scores + evaluated_by + evaluated_at避免每次重新评估。
- [ ] **A4 类型导出**`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)。
### 前端 — 结果展示
- [ ] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分LLM / 启发式兜底 / 纯启发式)。
- [ ] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPCloading 状态 + 错误提示。
- [ ] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar