修复: 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:
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal 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
105
.cargo/ts-bench/Cargo.lock
generated
Normal 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",
|
||||
]
|
||||
13
.cargo/ts-bench/Cargo.toml
Normal file
13
.cargo/ts-bench/Cargo.toml
Normal 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
184
.cargo/ts-bench/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@ mod adversarial_helpers;
|
||||
pub mod capture;
|
||||
pub mod promotion;
|
||||
pub mod scoring;
|
||||
pub mod scoring_keywords;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
24
crates/df-ideas/src/scoring_keywords.rs
Normal file
24
crates/df-ideas/src/scoring_keywords.rs
Normal 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", "截止", "先行", "阻塞",
|
||||
];
|
||||
30
crates/df-relay/deploy/u-work.1216.conf
Normal file
30
crates/df-relay/deploy/u-work.1216.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
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 辅助函数
|
||||
// ============================================================
|
||||
@@ -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<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),按更新时间(≈删除时间)降序。
|
||||
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
|
||||
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
|
||||
@@ -402,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
|
||||
"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<String>,
|
||||
|
||||
@@ -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<RwLock<AllowedDirs>>` 后 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<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> {
|
||||
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<Database>,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
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<Database>) {
|
||||
fn register_file_tools(
|
||||
registry: &mut AiToolRegistry,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
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/<uuid>-<filename>,可恢复(对齐 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 冻结会话(需更长命令应拆分而非无限等)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
|
||||
/// 主体灵感 + 所有受影响的关联目标在同一 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。
|
||||
#[tauri::command]
|
||||
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, 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));
|
||||
}
|
||||
|
||||
@@ -47,3 +47,9 @@ pub async fn settings_delete(
|
||||
) -> Result<bool, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -393,6 +393,8 @@ pub struct AppState {
|
||||
pub knowledge_events: KnowledgeEventsRepo,
|
||||
/// 知识库行为配置(提取 + 注入)
|
||||
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
|
||||
/// 应用数据目录(运行期确定: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<Self> {
|
||||
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
|
||||
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<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
|
||||
// 在 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<String> {
|
||||
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
|
||||
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
|
||||
let root = workspace_root_path();
|
||||
let guard = self.allowed_dirs.read().await;
|
||||
let mut out: Vec<String> = 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} 个");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ export const ideaApi = {
|
||||
return invoke('promote_idea', { id })
|
||||
},
|
||||
|
||||
/** 双向同步关联关系:原子地设置主体灵感的目标关联列表,自动添加/移除反向关联。 */
|
||||
relateIdeas(subjectId: string, targetIds: string[]): Promise<void> {
|
||||
return invoke('relate_ideas', { subjectId, targetIds })
|
||||
},
|
||||
|
||||
/** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */
|
||||
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
|
||||
return invoke('list_idea_evaluations', { ideaId })
|
||||
|
||||
@@ -16,6 +16,11 @@ export const settingsApi = {
|
||||
return invoke<boolean>('settings_set', { key, value })
|
||||
},
|
||||
|
||||
/** 获取 DevFlow 数据目录路径(运行期确定,跨平台) */
|
||||
getDataDir(): Promise<string> {
|
||||
return invoke<string>('get_data_dir')
|
||||
},
|
||||
|
||||
/** 取全部 key/value(启动时一次性拉回恢复偏好) */
|
||||
getAll(): Promise<Record<string, string>> {
|
||||
return invoke<Record<string, string>>('settings_get_all')
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<h2>{{ $t('settings.sectionAdvanced') }}</h2>
|
||||
</div>
|
||||
<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/all。low=仅低风险自动(等价旧 autoExecute=false);
|
||||
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
|
||||
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
|
||||
@@ -40,10 +45,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, useTemplateRef } from 'vue'
|
||||
import { ref, reactive, watch, useTemplateRef, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { settingsApi } from '@/api'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
// ============================================================
|
||||
@@ -56,6 +62,14 @@ import SettingRow from './SettingRow.vue'
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 应用数据目录(运行期确定,跨平台)
|
||||
const dataDir = ref('')
|
||||
onMounted(async () => {
|
||||
try {
|
||||
dataDir.value = await settingsApi.getDataDir()
|
||||
} catch { /* 静默 */ }
|
||||
})
|
||||
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) }
|
||||
// confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText
|
||||
// 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。
|
||||
@@ -125,6 +139,11 @@ function markSaved() {
|
||||
|
||||
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
|
||||
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
|
||||
.data-dir-path {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -22,6 +22,14 @@ export default {
|
||||
deleting: 'Deleting…',
|
||||
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
|
||||
adversarialTitle: '⚖️ Adversarial Evaluation',
|
||||
evalModeHeuristic: 'Heuristic',
|
||||
|
||||
@@ -111,6 +111,9 @@ export default {
|
||||
aiLanguageZh: '🇨🇳 简体中文',
|
||||
aiLanguageEn: '🇺🇸 English',
|
||||
|
||||
labelDataDir: 'App Data Directory',
|
||||
descDataDir: 'Where DevFlow stores .trash (recycle bin), database, logs and other internal data',
|
||||
|
||||
labelAutoExecute: 'AI auto-execute scope',
|
||||
descAutoExecute: 'Control risk level AI auto-executes (low/medium/all-takeover)',
|
||||
autoModeLow: 'Low risk (default)',
|
||||
|
||||
@@ -22,6 +22,14 @@ export default {
|
||||
deleting: '删除中…',
|
||||
promoting: '立项中…',
|
||||
|
||||
// 错误
|
||||
err: {
|
||||
loadFailed: '加载灵感失败',
|
||||
createFailed: '创建灵感失败',
|
||||
deleteFailed: '删除灵感失败',
|
||||
relateFailed: '更新关联关系失败',
|
||||
},
|
||||
|
||||
// 对抗式评估
|
||||
adversarialTitle: '⚖️ 对抗式评估',
|
||||
evalModeHeuristic: '启发式',
|
||||
|
||||
@@ -111,6 +111,9 @@ export default {
|
||||
aiLanguageZh: '🇨🇳 简体中文',
|
||||
aiLanguageEn: '🇺🇸 English',
|
||||
|
||||
labelDataDir: '应用数据目录',
|
||||
descDataDir: 'DevFlow 存储 .trash(回收站)、数据库、日志等内部数据的目录路径',
|
||||
|
||||
labelAutoExecute: 'AI 自动执行范围',
|
||||
descAutoExecute: '控制 AI 自动执行操作的风险等级(低/中/全部接管)',
|
||||
autoModeLow: '低风险(默认)',
|
||||
|
||||
@@ -61,6 +61,15 @@ export function createIdeasStore() {
|
||||
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 {
|
||||
loadIdeas,
|
||||
createIdea,
|
||||
@@ -68,5 +77,6 @@ export function createIdeasStore() {
|
||||
deleteIdea,
|
||||
evaluateIdea,
|
||||
promoteIdea,
|
||||
relateIdeas,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,10 +373,10 @@ async function onUpdateDesc(desc: string) {
|
||||
await store.updateIdea(currentIdea.value.id, 'description', desc)
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存)
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存,双向同步)
|
||||
async function onUpdateRelated(ids: string[]) {
|
||||
if (!currentIdea.value) return
|
||||
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids))
|
||||
await store.relateIdeas(currentIdea.value.id, ids)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
59
todo.md
Normal file
59
todo.md
Normal 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` IPC,loading 状态 + 错误提示。
|
||||
- [ ] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar)。
|
||||
Reference in New Issue
Block a user