From 9c3f27f4caccb04f6b7520c59d869c32428b3930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 27 Jun 2026 21:32:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20workspace=5Froot=20?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E6=9C=9F=E5=B8=B8=E9=87=8F=E6=B6=88=E9=99=A4?= =?UTF-8?q?+data=5Fdir=20=E8=BF=90=E8=A1=8C=E6=9C=9F+=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 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 + 前端全链路 - 设置面板新增数据目录显示 --- .cargo/config.toml | 5 + .cargo/ts-bench/Cargo.lock | 105 +++++++++++ .cargo/ts-bench/Cargo.toml | 13 ++ .cargo/ts-bench/src/main.rs | 184 ++++++++++++++++++++ crates/df-ideas/src/lib.rs | 1 + crates/df-ideas/src/scoring.rs | 29 +-- crates/df-ideas/src/scoring_keywords.rs | 24 +++ crates/df-relay/deploy/u-work.1216.conf | 30 ++++ crates/df-storage/src/crud/idea_repo.rs | 125 +++++++++++++ src-tauri/src/commands/ai/commands/chat.rs | 38 ++-- src-tauri/src/commands/ai/mod.rs | 6 +- src-tauri/src/commands/ai/tool_registry.rs | 62 +++---- src-tauri/src/commands/idea.rs | 21 ++- src-tauri/src/commands/settings.rs | 6 + src-tauri/src/lib.rs | 6 +- src-tauri/src/state.rs | 66 +++++-- src/api/idea.ts | 5 + src/api/settings.ts | 5 + src/components/settings/AdvancedSection.vue | 21 ++- src/i18n/en/ideas.ts | 8 + src/i18n/en/settings.ts | 3 + src/i18n/zh-CN/ideas.ts | 8 + src/i18n/zh-CN/settings.ts | 3 + src/stores/project/ideas.ts | 10 ++ src/views/Ideas.vue | 4 +- todo.md | 59 +++++++ 26 files changed, 743 insertions(+), 104 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .cargo/ts-bench/Cargo.lock create mode 100644 .cargo/ts-bench/Cargo.toml create mode 100644 .cargo/ts-bench/src/main.rs create mode 100644 crates/df-ideas/src/scoring_keywords.rs create mode 100644 crates/df-relay/deploy/u-work.1216.conf create mode 100644 todo.md diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b2cbd54 --- /dev/null +++ b/.cargo/config.toml @@ -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"] diff --git a/.cargo/ts-bench/Cargo.lock b/.cargo/ts-bench/Cargo.lock new file mode 100644 index 0000000..29f531d --- /dev/null +++ b/.cargo/ts-bench/Cargo.lock @@ -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", +] diff --git a/.cargo/ts-bench/Cargo.toml b/.cargo/ts-bench/Cargo.toml new file mode 100644 index 0000000..bd4e381 --- /dev/null +++ b/.cargo/ts-bench/Cargo.toml @@ -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] diff --git a/.cargo/ts-bench/src/main.rs b/.cargo/ts-bench/src/main.rs new file mode 100644 index 0000000..315b73a --- /dev/null +++ b/.cargo/ts-bench/src/main.rs @@ -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 = env::args().collect(); + let path = args.get(1).expect("usage: ts-bench "); + 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::() / 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); + } +} diff --git a/crates/df-ideas/src/lib.rs b/crates/df-ideas/src/lib.rs index 20e706e..670a816 100644 --- a/crates/df-ideas/src/lib.rs +++ b/crates/df-ideas/src/lib.rs @@ -5,3 +5,4 @@ mod adversarial_helpers; pub mod capture; pub mod promotion; pub mod scoring; +pub mod scoring_keywords; diff --git a/crates/df-ideas/src/scoring.rs b/crates/df-ideas/src/scoring.rs index feea562..a632e2b 100644 --- a/crates/df-ideas/src/scoring.rs +++ b/crates/df-ideas/src/scoring.rs @@ -11,29 +11,14 @@ pub use crate::capture::IdeaScores; use df_types::types::Priority; -// ─── 启发式关键词(DEC-03 c:同文件 const 拆出,接 LLM 语义评分后升 a:JSON 文件配置)─── +// ─── 启发式关键词(从 `scoring_keywords` 模块导入)─── // -// 现阶段关键词集合固定且与维度强相关,抽 const 仅为可读性/可维护性(零行为变化)。 -// TODO(DEC-03 a):接入 LLM 语义评分后,这些关键词退居「冷启动兜底」, -// 集合迁移到 JSON 配置文件,支持热更新与项目级覆盖。 -// 注:以下集合与原方法内字面量逐一对应,集合/顺序/否定前缀/权重均不变(纯结构提取)。 - -/// 可行性正向信号词(描述充实度外的技术/资源信号),命中每个 +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", "截止", "先行", "阻塞", -]; +// 关键词常量已拆至独立文件 `scoring_keywords.rs`。 +// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。 +pub use crate::scoring_keywords::{ + FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS, + IMPACT_KEYWORDS, URGENCY_KEYWORDS, +}; /// 评分权重配置 #[derive(Debug, Clone)] diff --git a/crates/df-ideas/src/scoring_keywords.rs b/crates/df-ideas/src/scoring_keywords.rs new file mode 100644 index 0000000..abad3a7 --- /dev/null +++ b/crates/df-ideas/src/scoring_keywords.rs @@ -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", "截止", "先行", "阻塞", +]; diff --git a/crates/df-relay/deploy/u-work.1216.conf b/crates/df-relay/deploy/u-work.1216.conf new file mode 100644 index 0000000..df974f9 --- /dev/null +++ b/crates/df-relay/deploy/u-work.1216.conf @@ -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; + } +} diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index 561ea35..9a68960 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -73,6 +73,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { dot / (norm_a * norm_b + 1e-8) } +/// 解析 JSON 数组字符串为 Vec。NULL / 空 / 非法 → 空 Vec。 +fn parse_json_id_array(raw: &Option) -> Vec { + match raw { + Some(s) if !s.is_empty() => match serde_json::from_str::>(s) { + Ok(v) => v, + Err(_) => Vec::new(), + }, + _ => Vec::new(), + } +} + // ============================================================ // from_row 辅助函数 // ============================================================ @@ -385,6 +396,120 @@ impl IdeaRepo { .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 = 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 = + parse_json_id_array(&old_raw).into_iter().collect(); + let new_set: std::collections::HashSet = + 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 = 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 = 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::(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 = 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 = parse_json_id_array(&cur); + ids.retain(|i| i != &subject_id); + let json = serde_json::to_string(&ids).map_err(|e| { + storage_err::(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::(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),按更新时间(≈删除时间)降序。 /// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。 pub async fn list_deleted(&self) -> Result> { diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index a56a2d0..edd6aa8 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -463,9 +463,9 @@ pub async fn ai_chat_send( // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 let user_msg_id = conv.messages.last_user_message_id(); - // G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,连续追问视为同目标)。 - // GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入。 - if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() { + // G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。 + // GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。 + if GOAL_PIN_ENABLED { let goal = extract_pinned_goal(&user_content); if !goal.is_empty() { conv.pinned_goal = Some(goal); @@ -751,12 +751,12 @@ pub async fn ai_approve( Ok("completed".to_string()) } -/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。 +/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。 +/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。 fn authz_debug(msg: &str) { use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true) - .open("E:/wk-lab/devflow/authz-debug.log") - { + let log_path = std::env::temp_dir().join("devflow-authz-debug.log"); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { let _ = writeln!(f, "{}", msg); } } @@ -774,17 +774,10 @@ fn drive_letter(path: &str) -> Option { None } -/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。 -/// -/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。 -/// 失败回退 None(对比退化,不报跨盘提示)。 +/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。 +/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。 fn workspace_drive() -> Option { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(".")); - drive_letter(&root.to_string_lossy()) + std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string())) } /// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。 @@ -1366,6 +1359,13 @@ pub async fn ai_chat_edit( conv.iteration_used = 0; // F-01 阶段6: 记录用户指定模型 override(主对话专用)。 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(); @@ -1531,10 +1531,10 @@ pub async fn ai_chat_force_send( } // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 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:审批期间用户发新消息 → // pinned_goal 已更新,续跑用新目标)。 - if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() { + if GOAL_PIN_ENABLED { let goal = extract_pinned_goal(&user_content); if !goal.is_empty() { conv.pinned_goal = Some(goal); diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 362dd00..66b901b 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -402,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option { "run_command" => { let dir = match args.get("working_dir").and_then(|v| v.as_str()) { Some(d) => normalize_dir_key(d), - None => workspace_root_str(), + None => String::new(), }; Some(TrustKey::Execute { dir }) } @@ -423,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String { .unwrap_or_else(|| std::path::PathBuf::from(".")); 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 不变量,天然 /// 免疫压缩/裁剪/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(单点回退等价改动前)。 /// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。 pub pinned_goal: Option, diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 9dba100..204c5e5 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -286,15 +286,6 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String { 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。 /// /// 背景: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 新建文件)兜底防越界 -/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸 +/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸 /// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好 /// -/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。 -/// - `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>` 后 read lock 取快照传入本函数。 +/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录), +/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。 fn resolve_workspace_path_with_allowed( path: &str, allowed: &AllowedDirs, @@ -363,31 +350,31 @@ fn resolve_workspace_path_with_allowed( /// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数 /// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。 /// -/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key), -/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在 -/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响 -/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。 +/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err +/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时 +/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响。 pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result { - 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 { validate_path(path)?; - let root = workspace_root(); let resolved = if Path::new(path).is_absolute() { PathBuf::from(path) } else { + // 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。 + // 无授权目录时给出明确引导,避免指向编译期常量。 + let root = allowed.first_persistent_dir() + .ok_or_else(|| anyhow::anyhow!( + "请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)" + ))?; root.join(path) }; // 词法层:防明显越界(不存在路径的兜底)。 - // F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验 - // (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。 if !allowed.is_authorized(&resolved) { anyhow::bail!("路径不在授权目录内: {}", path); } // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。 - // 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink - // 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。 if resolved.exists() { let canon_resolved = resolved.canonicalize()?; if !allowed.is_authorized(&canon_resolved) { @@ -449,10 +436,11 @@ async fn bind_dir_to_project( pub fn build_ai_tool_registry( db: &Arc, allowed_dirs: &Arc>, + data_dir: PathBuf, ) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); 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); registry } @@ -1412,6 +1400,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc) { fn register_file_tools( registry: &mut AiToolRegistry, allowed_dirs: &Arc>, + data_dir: PathBuf, ) { // F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。 // run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环), @@ -1967,8 +1956,9 @@ fn register_file_tools( ("permanent", "boolean", false), ]), 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 data_dir = data_dir.clone(); Box::pin(async move { // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸 let snap = allowed_dirs.read().await.clone(); @@ -2004,15 +1994,15 @@ fn register_file_tools( })) } else { // 软删除:移到 .trash/-,可恢复(对齐 list_trash 机制) - // .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内 - let trash_dir = workspace_root().join(".trash"); + // .trash 放 app_data_dir 下,不依赖编译期 workspace_root + let trash_dir = data_dir.join(".trash"); tokio::fs::create_dir_all(&trash_dir).await .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?; let backup_name = format!("{}-{}", new_id(), file_name); let backup_path = trash_dir.join(&backup_name); let backup_path_str = backup_path.to_string_lossy().to_string(); // 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, - // 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子, + // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子, // 失败回滚删 backup 保源完整)。 // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_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 { let command = args["command"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; - // working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。 - // 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验—— - // run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir), - // 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。 + // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 + // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { Some(d) => { validate_path(d)?; d.to_string() } - None => workspace_root().to_string_lossy().to_string(), + None => String::new(), }; // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index ccb9761..f53a75f 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -140,6 +140,21 @@ pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result, + subject_id: String, + target_ids: Vec, +) -> Result<(), String> { + state + .ideas + .sync_related_ids(&subject_id, &target_ids) + .await + .map_err(err_str) +} + /// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。 #[tauri::command] pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result, String> { @@ -199,10 +214,10 @@ pub async fn promote_idea( ..record }; if let Err(e) = state.ideas.update_full(&updated).await { - // 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项) + // 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项) tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}"); - if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await { - tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}"); + if let Err(del_err) = state.projects.soft_delete(&project_id).await { + tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}"); } return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e)); } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 95f8132..5421d56 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -47,3 +47,9 @@ pub async fn settings_delete( ) -> Result { state.settings.delete(&key).await.map_err(err_str) } + +/// 获取 DevFlow 数据目录路径(运行期确定,跨平台) +#[tauri::command] +pub async fn get_data_dir(state: State<'_, AppState>) -> Result { + Ok(state.data_dir.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7d3376..02d0006 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,8 +23,8 @@ pub fn run() { std::fs::create_dir_all(&data_dir)?; let db_path = data_dir.join(db_name); - // 初始化全局状态(打开数据库 + 执行迁移) - let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?; + // 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir + let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?; // FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断) if let Err(e) = tauri::async_runtime::block_on( commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers), @@ -272,6 +272,7 @@ pub fn run() { commands::idea::list_ideas, commands::idea::create_idea, commands::idea::update_idea, + commands::idea::relate_ideas, commands::idea::delete_idea, commands::idea::restore_idea, commands::idea::list_deleted_ideas, @@ -352,6 +353,7 @@ pub fn run() { commands::settings::settings_set, commands::settings::settings_get_all, commands::settings::settings_delete, + commands::settings::get_data_dir, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d014b29..f2cec1f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -393,6 +393,8 @@ pub struct AppState { pub knowledge_events: KnowledgeEventsRepo, /// 知识库行为配置(提取 + 注入) pub knowledge_config: Arc>, + /// 应用数据目录(运行期确定:Windows $APPDATA/devflow, macOS ~/Library/Application Support/devflow) + pub data_dir: PathBuf, /// 通用应用设置 KV Repo(前端 localStorage 迁移目标) pub settings: SettingsRepo, // ── LLM 并发控制 ── @@ -468,6 +470,12 @@ impl AllowedDirs { 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 命中即放行。 /// /// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs @@ -577,11 +585,16 @@ pub fn check_path_authorization( raw_path: &str, allowed: &AllowedDirs, ) -> PathAuthDecision { - // 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致) + // 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致) let resolved = if Path::new(raw_path).is_absolute() { PathBuf::from(raw_path) + } else if let Some(root) = allowed.first_persistent_dir() { + root.join(raw_path) } else { - workspace_root_path().join(raw_path) + // 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。 + return PathAuthDecision::NeedsAuthorization { + dir: PathBuf::from("."), + }; }; // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 @@ -597,7 +610,7 @@ pub fn check_path_authorization( let dir = resolved .parent() .map(|p| p.to_path_buf()) - .unwrap_or_else(|| workspace_root_path()); + .unwrap_or_else(|| PathBuf::from(".")); PathAuthDecision::NeedsAuthorization { dir } } @@ -638,11 +651,15 @@ fn best_effort_canonicalize(p: &Path) -> PathBuf { p.to_path_buf() } -/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。 +/// workspace 根目录(当前:编译期 CARGO_MANIFEST_DIR 上两级,仅开发机有效)。 /// -/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器 -/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md -/// (workspace_root 分发适配)。当前仅开发机自用有效。 +/// ⚠️ 分发限制:env!("CARGO_MANIFEST_DIR") 是编译期常量,打包后指向编译机路径,用户机器无效。 +/// 目标方案(D 混合策略):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 { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -651,15 +668,25 @@ fn workspace_root_path() -> PathBuf { .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 { /// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表 - pub async fn init(db_path: &Path) -> Result { + pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result { let db = Arc::new(Database::open(db_path).await?); // F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc, // 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。 // 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。 - let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs)); + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default())); + let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry( + &db, &allowed_dirs, data_dir.clone(), + )); // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // resolver 持 Arc(非 AppState,避免循环依赖:AppState 持 Arc), // 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。 @@ -690,6 +717,7 @@ impl AppState { knowledge: KnowledgeRepo::new(&db), knowledge_events: KnowledgeEventsRepo::new(&db), knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())), + data_dir: data_dir.clone(), settings: SettingsRepo::new(&db), llm_concurrency: LlmConcurrency::new(3, 2), agent_max_iterations: Arc::new(AtomicUsize::new( @@ -722,6 +750,19 @@ impl AppState { state.reload_allowed_dirs().await; // P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。 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) } @@ -853,14 +894,10 @@ impl AppState { /// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。 pub async fn get_allowed_dirs(&self) -> Vec { - // 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行): - // 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。 - let root = workspace_root_path(); let guard = self.allowed_dirs.read().await; let mut out: Vec = guard .persistent .iter() - .filter(|p| **p != root) .map(|p| p.to_string_lossy().to_string()) .collect(); out.sort(); @@ -1336,4 +1373,3 @@ mod tests { assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个"); } } - diff --git a/src/api/idea.ts b/src/api/idea.ts index 4392694..694831c 100644 --- a/src/api/idea.ts +++ b/src/api/idea.ts @@ -40,6 +40,11 @@ export const ideaApi = { return invoke('promote_idea', { id }) }, + /** 双向同步关联关系:原子地设置主体灵感的目标关联列表,自动添加/移除反向关联。 */ + relateIdeas(subjectId: string, targetIds: string[]): Promise { + return invoke('relate_ideas', { subjectId, targetIds }) + }, + /** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */ listEvaluations(ideaId: string): Promise { return invoke('list_idea_evaluations', { ideaId }) diff --git a/src/api/settings.ts b/src/api/settings.ts index 1483b13..965ac84 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -16,6 +16,11 @@ export const settingsApi = { return invoke('settings_set', { key, value }) }, + /** 获取 DevFlow 数据目录路径(运行期确定,跨平台) */ + getDataDir(): Promise { + return invoke('get_data_dir') + }, + /** 取全部 key/value(启动时一次性拉回恢复偏好) */ getAll(): Promise> { return invoke>('settings_get_all') diff --git a/src/components/settings/AdvancedSection.vue b/src/components/settings/AdvancedSection.vue index d543f05..ad360e1 100644 --- a/src/components/settings/AdvancedSection.vue +++ b/src/components/settings/AdvancedSection.vue @@ -5,6 +5,11 @@

{{ $t('settings.sectionAdvanced') }}

+ + + {{ dataDir || $t('settings.loading') }} + + @@ -40,10 +45,11 @@