Compare commits
192 Commits
bd6a41fe6e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e3578d26 | |||
| 42efb31bbf | |||
| 0dfaeb0f5b | |||
| aa0701a2ea | |||
| 89cfbc7499 | |||
| 3d8b755229 | |||
| 249b3b9ea8 | |||
| eee0f06e1a | |||
| 46246880b3 | |||
| b18740405c | |||
| d99a65f034 | |||
| 8fa3a93eec | |||
| 3f22fd3673 | |||
| 34944df764 | |||
| c3b47b5771 | |||
| acf0ed091d | |||
| 54ffc868b2 | |||
| d5b0459a8a | |||
| f1fb8655c3 | |||
| d10d7ff330 | |||
| 61d3e9c148 | |||
| 840949ad05 | |||
| 3e93a2cd70 | |||
| 6e44878021 | |||
| 27b4268acb | |||
| f40287bb00 | |||
| 1d580dccc3 | |||
| 9947e6c1fe | |||
| 3c9077b043 | |||
| 4483358f6b | |||
| ae6d3d0043 | |||
| f4fc0feff5 | |||
| 6833792de2 | |||
| af2e085bea | |||
| 583058acd6 | |||
| f04d47b09d | |||
| fa410e6843 | |||
| a9ee9b1f74 | |||
| 0a65ea2c5a | |||
| 91d80841ea | |||
| c89742cb9a | |||
| 2af24f0db8 | |||
| 535525c2f6 | |||
| 3abdfb489f | |||
| 2c2b2d710e | |||
| 6771d396f0 | |||
| 0338210ba2 | |||
| 36ea090d9b | |||
| 89912155a6 | |||
| dd0e059365 | |||
| bce2fea4e5 | |||
| 3758bea0b5 | |||
| 3c2fa91fc6 | |||
| 78ef5dcc44 | |||
| 976ce29ad9 | |||
| 4dac9fe5f1 | |||
| 76aec62d36 | |||
| 27eb0a9f6b | |||
| 948d6e644b | |||
| aa4d5213eb | |||
| 2780f6affa | |||
| 773ebb6ef6 | |||
| 15eaa0c637 | |||
| f5f101d88a | |||
| b19b0f3679 | |||
| abc6880936 | |||
| 6b131f3dc1 | |||
| 78bd8d4344 | |||
| 093fe172f8 | |||
| ff3f153d45 | |||
| dfed588b54 | |||
| 63936e3016 | |||
| 7a2a2e13a2 | |||
| 49c6f3360a | |||
| 9b6353c561 | |||
| 0cfea6da22 | |||
| 7b5b62d3ae | |||
| 50aad375eb | |||
| b72df78462 | |||
| 3c18dea45b | |||
| 8d18918e39 | |||
| cb7e1fce82 | |||
| cc3ef5bc1d | |||
| 996f1d9e5f | |||
| 696e34407c | |||
| 19b647e650 | |||
| 10629f88e8 | |||
| b5e159f190 | |||
| 55542abf41 | |||
| 3fac0a618d | |||
| 9839935782 | |||
| 4a396f4f88 | |||
| 7adaf97377 | |||
| 6ffcba7e4d | |||
| b157bc9077 | |||
| f776336eb1 | |||
| ad1821bc14 | |||
| c4b02b5370 | |||
| fee856129c | |||
| ead490783f | |||
| dcc3f0d230 | |||
| d1b9488853 | |||
| 48c966f6f7 | |||
| 7e3fe1961a | |||
| 8723075360 | |||
| 53e1c1da77 | |||
| e4ceb0015b | |||
| 22362a77b8 | |||
| cf223fc08b | |||
| b7b004dd68 | |||
| b7b54eb2a6 | |||
| 6212002270 | |||
| 7cd833b1e5 | |||
| 5c539fe764 | |||
| 6e1485e4f9 | |||
| 9c3f27f4ca | |||
| a6d692270f | |||
| 9189877a59 | |||
| bb2cfcbb62 | |||
| 80239500f1 | |||
| 4b754fe230 | |||
| e33e8f86d3 | |||
| 0c348f2311 | |||
| 5a893680b7 | |||
| 744b68da5b | |||
| 40b74e11bf | |||
| df8ed7e74f | |||
| 549f198cea | |||
| e7c9ed0805 | |||
| 6d979d6646 | |||
| 005079f762 | |||
| 308775cf4d | |||
| 8ce18cb482 | |||
| c011f864fd | |||
| fc705443bd | |||
| d7e0d7ac55 | |||
| c409a675cf | |||
| 4c54288a8d | |||
| 047b3b91fa | |||
| 24c3fa08f1 | |||
| 56a08fb5c1 | |||
| 953863af8e | |||
| 7d2998e04e | |||
| 93172a6844 | |||
| 8df7e248b4 | |||
| ba811ead96 | |||
| ebba9735ed | |||
| a7dbd50a4d | |||
| 7f6aa1e782 | |||
| 40a97a7655 | |||
| 7c2e3b23c1 | |||
| 1ffa023f3d | |||
| 61bad4fa28 | |||
| bdf3f96491 | |||
| bb8b3c6c59 | |||
| 96e554ce22 | |||
| 0a8db6504a | |||
| 78b48d871b | |||
| 698d981962 | |||
| 0c7c5e6068 | |||
| f4b65f6c2e | |||
| 59f07bf060 | |||
| a9703c3a1f | |||
| ed8e2fc155 | |||
| 612de4cd7c | |||
| 60b73377f6 | |||
| 030ddb668a | |||
| 0fb5af50c7 | |||
| 8ccdce282c | |||
| 5337c7d5e3 | |||
| d38d1af68d | |||
| 057fd0994e | |||
| da886fa1c8 | |||
| e8468c5bc6 | |||
| 36a8391626 | |||
| a5a4fc887a | |||
| 280baeaee0 | |||
| 25d65652cd | |||
| 2b8b30ef41 | |||
| 2fe1fe57c8 | |||
| d1d3871a88 | |||
| 2a13f45650 | |||
| 83cee61476 | |||
| 82059bac4a | |||
| cf7fe867ee | |||
| 5d1749a58f | |||
| 566bbcb394 | |||
| eda2a5f887 | |||
| c9b6e28433 | |||
| 72e57c6232 | |||
| cdb227f999 | |||
| d2cada97cd |
12
.cargo/config.toml
Normal file
12
.cargo/config.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
||||
#
|
||||
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
|
||||
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
|
||||
#
|
||||
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
|
||||
#
|
||||
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
|
||||
[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);
|
||||
}
|
||||
}
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
|
||||
target
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.git
|
||||
*.db
|
||||
*.log
|
||||
apps/df-miniapp/node_modules
|
||||
apps/df-miniapp/dist
|
||||
src-tauri/target
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -10,6 +10,9 @@ node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# uni-app 编译产物(微信小程序/H5 打包输出)
|
||||
unpackage/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -22,6 +25,7 @@ dist-ssr/
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
||||
!Cargo.lock
|
||||
|
||||
# Tauri
|
||||
@@ -56,6 +60,28 @@ tmp/
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
|
||||
/analysis_*.txt
|
||||
/analysis_*.py
|
||||
/core_domains.txt
|
||||
/core_domains.py
|
||||
/final_report.txt
|
||||
/grand_output.txt
|
||||
/im_live_verify.txt
|
||||
/liang_deep.py
|
||||
/liangxianyou_deep.txt
|
||||
/member_profiles.py
|
||||
/read_all.py
|
||||
/report_p2.txt
|
||||
/report_wall.txt
|
||||
/team_analysis.py
|
||||
/team_deep_analysis.py
|
||||
/team_member_profiles.txt
|
||||
/team_report_final.txt
|
||||
/tmp_*.py
|
||||
/tmp_*.txt
|
||||
/verify_im_live*.py
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
|
||||
48
.zed/rules/commit.rules.md
Normal file
48
.zed/rules/commit.rules.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# DevFlow 提交规范
|
||||
|
||||
## 提交注释格式
|
||||
|
||||
```
|
||||
<动词>: <简短描述>
|
||||
|
||||
<详细说明(可选,仅在需要补充上下文时)>
|
||||
```
|
||||
|
||||
## 动词词表
|
||||
|
||||
| 动词 | 使用场景 |
|
||||
|------|---------|
|
||||
| `新增` | 新功能、新特性、新文件 |
|
||||
| `修复` | Bug 修复、问题修正 |
|
||||
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
|
||||
| `重构` | 代码重构(行为不变的结构调整) |
|
||||
| `删除` | 移除功能、删除文件 |
|
||||
| `优化` | 性能优化、体验改进 |
|
||||
| `合并` | 分支合并 |
|
||||
|
||||
## 规范要求
|
||||
|
||||
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
|
||||
2. **主题行不超过 72 字符**
|
||||
3. **主语用中文**,描述清楚改动内容
|
||||
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
|
||||
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
|
||||
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
|
||||
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
|
||||
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
修复: 审批按钮永久转圈和等待计数不停
|
||||
```
|
||||
|
||||
```
|
||||
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
|
||||
|
||||
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
|
||||
- 行号显示: 文件预览左侧显示行号列
|
||||
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
|
||||
- 提交详情: 点击提交查看变更文件列表及文件级 diff
|
||||
- 中文编码修复: git 命令注入 LC_ALL 环境变量
|
||||
```
|
||||
29
.zed/rules/development.rules.md
Normal file
29
.zed/rules/development.rules.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# DevFlow 开发规则
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
|
||||
- 后端: Rust Workspace (8 crate) / SQLite
|
||||
- 包管理: bun (前端) / cargo (Rust)
|
||||
- 路径: `E:/wk-lab/devflow`
|
||||
|
||||
## 架构原则
|
||||
|
||||
1. **治本不治标**:修复问题时找到根因,不做表面补丁
|
||||
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
|
||||
3. **长远考虑**:不追求局部最优,考虑整体架构演进
|
||||
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
|
||||
|
||||
## 代码规范
|
||||
|
||||
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
|
||||
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
|
||||
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
|
||||
4. **TypeScript 文件名**:camelCase(`fileExplorer.ts`)
|
||||
5. **Vue 组件文件名**:PascalCase(`FileExplorer.vue`)
|
||||
|
||||
## i18n
|
||||
|
||||
- 中英文双语言同步:`src/i18n/en/` 和 `src/i18n/zh-CN/`
|
||||
- 新增组件时同时添加对应语言的翻译文件
|
||||
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)
|
||||
64
.zed/settings.json
Normal file
64
.zed/settings.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
// 项目级 Zed 配置:优化 rust-analyzer 性能
|
||||
//
|
||||
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
|
||||
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
|
||||
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
|
||||
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
"cargo": {
|
||||
"features": [], // 不编 default features 之外的重头
|
||||
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
|
||||
"targetDir": null, // 留空用 cargo 默认,避免双写
|
||||
"buildScripts": {
|
||||
"enable": true,
|
||||
"rerun": "on-save"
|
||||
}
|
||||
},
|
||||
"check": {
|
||||
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
|
||||
"workspace": false, // 即便手动 check 也只查当前 crate
|
||||
"command": "check", // 用 cargo check(非 clippy),首次开销低
|
||||
"features": [],
|
||||
"allTargets": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"enable": true, // 保留 rust-analyzer 自身诊断
|
||||
"experimental": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"procMacro": {
|
||||
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
|
||||
"attributes": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"symbol": {
|
||||
"search": {
|
||||
"kind": "only_types",
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"callable": {
|
||||
"snippets": "fill_arguments"
|
||||
},
|
||||
"fullFunction": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"interpret": {
|
||||
"tests": false
|
||||
},
|
||||
"files": {
|
||||
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 产研全流程工作流平台
|
||||
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
- **经验进化 (Evolution)**:开发过程中的模式自动沉淀为知识库(审查规则/Prompt模板/踩坑经验),持续进化复用
|
||||
- **阶段插件**:想法→需求→编码→测试→发布,阶段即模板
|
||||
|
||||
### 层级模型
|
||||
### 层级模型(业务层级)
|
||||
|
||||
DevFlow 的业务抽象分三层,自上而下层层实例化:
|
||||
|
||||
```
|
||||
💡 Idea Pool (想法池) — 独立运转,持续捕捉和评估
|
||||
@@ -31,6 +33,40 @@
|
||||
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
|
||||
```
|
||||
|
||||
### 层级模型(执行层级)
|
||||
|
||||
Workflow DAG 的执行层进一步拆分为三层,这是 AI Factory 的核心抽象:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 模板层 (Template) │
|
||||
│ "应该做什么" — 阶段蓝图、行业最佳实践 │
|
||||
│ │
|
||||
│ 职责: 定义节点拓扑 + 产出物规范 + 质量门禁 │
|
||||
│ 生命周期: 长期存在,跨项目复用 │
|
||||
│ 存储: YAML 文件 / DB 模板库 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 工作流层 (Workflow) │
|
||||
│ "怎么执行" — DAG 实例、状态机、运行时 │
|
||||
│ │
|
||||
│ 职责: 拓扑排序 + 节点调度 + 状态流转 + 持久化 │
|
||||
│ 生命周期: 随项目启动/结束,单次执行后归档 │
|
||||
│ 载体: df-workflow (DAG + Executor + StateMachine) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 人设层 (Persona) │
|
||||
│ "谁来做" — Agent 角色、能力边界、行为风格 │
|
||||
│ │
|
||||
│ 职责: 定义 system prompt + 可用工具 + 输出格式 │
|
||||
│ 生命周期: 长期存在,跨节点复用 │
|
||||
│ 注入点: AINode 执行时载入对应人设 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键设计原则**:三层各自独立演化,在 AINode 执行时交汇。
|
||||
- 模板 = 可复用的蓝图(定义节点拓扑 + 建议人设 + 质量门禁)
|
||||
- 工作流 = 模板的运行时实例(含状态、数据绑定、执行记录)
|
||||
- 人设 = Agent 的角色卡(system prompt + 工具集 + 行为规则)
|
||||
|
||||
### AI Working 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
@@ -119,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的
|
||||
devflow/
|
||||
├── Cargo.toml # Workspace 根
|
||||
├── crates/
|
||||
│ ├── df-core/ # 核心类型、错误、常量、事件
|
||||
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
|
||||
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
|
||||
@@ -284,8 +320,9 @@ pub struct Decision {
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
|
||||
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
|
||||
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
|
||||
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
|
||||
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
@@ -295,13 +332,13 @@ pub struct Decision {
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器操作 | ❌ 未实现 |
|
||||
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
|
||||
| HTTPNode | HTTP 请求 | ❌ 未实现 |
|
||||
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
|
||||
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
|
||||
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
|
||||
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
|
||||
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
|
||||
|
||||
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
|
||||
> `crates/df-nodes/src/` 含 ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
@@ -497,20 +534,26 @@ CREATE TABLE app_settings (
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
## 七、三层模型:模板 → 工作流 → 人设
|
||||
|
||||
5 个内置阶段作为工作流模板(YAML 定义),用户可自定义。
|
||||
> 本章详细设计已迁至专项文档,详见 [docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。此处仅保留摘要性定义。
|
||||
|
||||
- 💡 **想法**:市场分析 → 竞品调研 → 可行性评分
|
||||
- 📋 **需求**:AI 生成 PRD → 人工审阅 → 任务拆解
|
||||
- 💻 **编码**:AI 编码 → 代码审查 → 自动修复
|
||||
- 🧪 **测试**:运行测试 → AI 分析失败 → 回归验证
|
||||
- 🚀 **发布**:构建 → 人工确认 → 部署 → 健康检查
|
||||
### 7.1 三层定义
|
||||
|
||||
| 层 | 回答的问题 | 本质 | 生命周期 | 当前状态 |
|
||||
|----|-----------|------|---------|---------|
|
||||
| **流程模板 (Template)** | 应该做什么 | 可复用的蓝图(节点拓扑 + 建议人设 + 质量门禁) | 长期存在,跨项目复用 | ⚡ 需重新设计(原 df-stages 已移除) |
|
||||
| **工作流 (Workflow)** | 怎么执行 | 模板的运行时实例(DAG + 状态 + 数据绑定) | 随项目启停,单次执行归档 | ✅ df-workflow 核心完成 |
|
||||
| **人设 (Persona)** | 谁来做 | Agent 角色卡(system prompt + 工具集 + 行为规则) | 长期存在,跨节点复用 | ⬜ 待设计 |
|
||||
|
||||
**关键原则**:模板不绑定具体人设、工作流不感知人设、人设与模板解耦。
|
||||
|
||||
详细定义、三者关系、实例化流程、数据结构及 YAML 模板示例见 [专项设计文档](./docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md)。
|
||||
|
||||
## 八、Phase 规划
|
||||
|
||||
### Phase 1 — 引擎骨架 (4-6 周)
|
||||
- df-core + df-workflow (DAG + Node trait + Executor)
|
||||
- df-types + df-workflow (DAG + Node trait + Executor)
|
||||
- df-storage (SQLite 基础表)
|
||||
- df-execute (Shell 执行)
|
||||
- 最小前端:项目列表 + 工作流执行日志
|
||||
@@ -528,9 +571,10 @@ CREATE TABLE app_settings (
|
||||
- 前端:想法池视图 + 多项目 Tab
|
||||
- 验证:想法捕捉 → AI 评估 → 立项 → 工作流执行
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
### Phase 4 — 节点丰富 + 三层模型落地 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
|
||||
- 流程模板系统(YAML 定义 + 模板库 + 实例化引擎)
|
||||
- 人设系统(AgentPersona 数据结构 + 内置人设 + 工具过滤)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
@@ -566,3 +610,6 @@ CREATE TABLE app_settings (
|
||||
8. **本地优先**:SQLite 嵌入,不依赖云服务
|
||||
9. **多模型并行**:统一抽象,按任务路由,不锁定单一模型
|
||||
10. **流式优先**:AI 输出、Shell 输出全部流式推送到前端
|
||||
11. **模板/工作流/人设三层分离**:模板是蓝图,工作流是实例,人设是角色卡。三层各自独立演化,在 AINode 执行时交汇
|
||||
12. **人设与模板解耦**:模板标注建议人设但不绑定,同一个人设可用于不同模板的同类节点
|
||||
13. **模板实例化**:模板 → 工作流实例 + 人设分配,允许实例化时按项目覆盖人设
|
||||
|
||||
553
Batch.md
Normal file
553
Batch.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# DevFlow 批次推进记录
|
||||
|
||||
> 记录每个批次的提交 hash、改动内容和交付价值。
|
||||
> 最后更新: 2026-07-02 | 最新提交: `4624688`
|
||||
|
||||
---
|
||||
|
||||
## Batch 1 — 审批超时后端兜底
|
||||
|
||||
- **提交**: `b923d70`(后重写为 `9dd5a5a`)
|
||||
- **内容**:
|
||||
- 新增 `agentic/approval_timeout.rs`,在审批恢复入口检测超时,超时自动取消并写入审计
|
||||
- 待审批记录新增创建时刻字段,用于超时判定
|
||||
- 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用)
|
||||
- 新增读写超时配置的接口,设置页可改
|
||||
- 与前端共用同一个配置项,保证单一真相源
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 2 — 工具卡片拆分核验
|
||||
|
||||
- **提交**: `e95a5cd`(后重写为 `45fc493`)
|
||||
- **内容**:
|
||||
- 核验发现工具卡片拆分任务实际已完成,文档标记滞后
|
||||
- ToolCard.vue 从 1527 行降至 373 行
|
||||
- 审批状态机独立为 useToolApproval.ts(220 行)
|
||||
- 头部逻辑、渲染逻辑、通用工具函数各自独立
|
||||
- 大型文件拆分三项全部完成(主聊天组件/命令注册/通用 CRUD)
|
||||
- **验证**: 文档核验,无代码改动
|
||||
|
||||
---
|
||||
|
||||
## Batch 3 — 条件表达式 UI 翻译补齐
|
||||
|
||||
- **提交**: `e460e8d`(后重写为 `d79c816`)
|
||||
- **内容**:
|
||||
- 工作流条件表达式引擎和执行器集成早已完成
|
||||
- 前端边条件编辑入口已存在,但翻译缺失(用中文兜底)
|
||||
- 补齐中英文翻译(工作流结构标题/层标签/空态/边条件编辑器)
|
||||
- 核验确认条件引擎全部链路均已落地
|
||||
- **验证**: vue-tsc 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 4 — 知识提炼切读消息表
|
||||
|
||||
- **提交**: `6aac7ef`(后重写为 `f57c37e`)
|
||||
- **内容**:
|
||||
- 知识提炼是最后一个仍读旧消息 JSON 列的路径
|
||||
- 改为优先读消息表,表空时回退旧 JSON(老库兼容)
|
||||
- 复用既有的记录转消息映射函数,与其他读路径同源
|
||||
- 至此消息存储拆分全部完成
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 5 — 关闭已完成待办项
|
||||
|
||||
- **提交**: `59b7188`(后重写为 `694f24f`)
|
||||
- **内容**:
|
||||
- 工具卡片分组标题增强(文件路径摘要展示)已落地
|
||||
- 重试持锁问题核验无影响(多会话改造后全局锁已移除)
|
||||
- 对抗式评估批量命令暂不做(单项已接入,批量无消费方)
|
||||
- 残留诊断代码已清理(无硬编码绝对路径)
|
||||
- 工程内路径授权政策确认为白名单制
|
||||
- **验证**: 文档核验
|
||||
|
||||
---
|
||||
|
||||
## Batch 6 — 应用状态拆分 + 事件分发统一 + 路径授权治本
|
||||
|
||||
- **提交**: `996f1d9`
|
||||
- **内容**:
|
||||
- **状态文件拆分**: 1416 行单文件 → 按职责分模块(授权白名单/知识库配置/并发控制)
|
||||
- **事件分发器统一**: 5 类生命周期事件纳入主事件联合类型,删除第二监听器,一个事件源一个监听器一个分发器
|
||||
- **路径授权去硬编码**: 不再硬塞编译期路径到白名单,工程根授权靠项目绑定目录 + 设置页配置
|
||||
- **补丁**: `cc3ef5b` — 修复设置页面崩溃(模块循环依赖导致初始化失败)
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 7 — 小程序配置持久化与心跳闭环
|
||||
|
||||
- **提交**: `cb7e1fc`
|
||||
- **内容**:
|
||||
- **配置持久化**: 运行时配置接入 storage,懒加载恢复,新增重置函数
|
||||
- **心跳 pong 闭环**: 中继服务识别小程序心跳 ping 直接回 pong(不经桌面端透传),小程序看门狗据此判活
|
||||
- **文档同步**: 关闭条件引擎/streaming per-conv/workspace_root 三项已完成的待决策
|
||||
- **验证**: cargo check(devflow + df-relay)通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 8 — 消息分页懒加载与事件总线空转优化
|
||||
|
||||
- **提交**: `3c18dea`
|
||||
- **内容**:
|
||||
- **消息分页**: 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标),切换对话返回最近 50 条 + 游标,新增加载更多接口
|
||||
- **事件总线优化**: 发布事件前先检查订阅者数量,无订阅者跳过序列化(消除空转开销)
|
||||
- **核验关闭**: 对话上下文透明化三项均已落地(目标可见/项目增强预览/完整上下文面板)
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 9 — 项目多工程系统(数据层)
|
||||
|
||||
- **提交**: `b72df78`
|
||||
- **内容**:
|
||||
- **数据层**: project_modules 表(V34 迁移)+ ProjectModuleRepo 完整 CRUD
|
||||
- **后端接口**: 工程增删改查 + Git 状态查询(实时跑 git 命令返回分支/改动/提交,10s 超时)
|
||||
- **创建项目适配**: 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)
|
||||
- **AI 工具**: list_project_modules(AI 了解项目工程结构)
|
||||
- **设计文档**: 新增工程系统设计方案
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 10 — 项目文件浏览器(前端 UI)
|
||||
|
||||
- **提交**: `50aad37`
|
||||
- **内容**:
|
||||
- **后端接口**: 文件树查询(列目录 + Git 状态合并 + 噪音过滤 + 路径穿越防御)+ 文件读取(文本/图片/二进制,1MB 上限)
|
||||
- **文件树组件**: 递归树形展示,懒加载子目录,Git 状态标记(橙=修改/绿=新增/灰=未跟踪)
|
||||
- **文件预览组件**: 代码文本/图片/二进制三分支,加载状态
|
||||
- **主容器**: 工程选择(单工程隐藏)+ 面包屑导航 + 刷新
|
||||
- **集成**: 项目详情页新增"文件"Tab
|
||||
- **国际化**: 中英文同步
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 11 — Git 只读 AI 工具
|
||||
|
||||
- **提交**: `7b5b62d`
|
||||
- **内容**:
|
||||
- **git_status**(Low 自动执行): 工作区状态结构化(分支 + 改动文件列表 + 状态标记)
|
||||
- **git_diff**(Low): 改动详情(统计 + patch 截断到 8000 字符防 token 爆)
|
||||
- **git_log**(Low): 提交历史结构化(哈希/作者/消息/日期)
|
||||
- 所有 git 命令在工程目录执行,10s 超时,无 Git 仓库返回空状态
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 12 — Git 写操作 AI 工具
|
||||
|
||||
- **提交**: `0cfea6d`
|
||||
- **内容**:
|
||||
- **git_commit**(Medium 审批): 提交工作区改动,提交前检查敏感文件(.env/.key/.pem)
|
||||
- **git_branch**(Medium 审批): 分支列表/创建/切换
|
||||
- **git_merge**(High 审批): 合并分支,自动检测冲突返回冲突文件列表
|
||||
- 安全边界: 禁止 push / force / reset --hard
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## 架构设计文档状态同步
|
||||
|
||||
- **提交**: `8d18918`
|
||||
- **内容**: 9 份设计文档状态批量更新(从"待实施"改为"已落地"),1 份归档(规格契约自检被其他机制覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 提交注释规范重写
|
||||
|
||||
- **内容**: Batch 1-5 的提交注释经 filter-branch 重写,消除内部代号(Batch/P1/P2/L1/F-09 等),统一为"两字动词 + 冒号"格式(新增/修复/更新)
|
||||
|
||||
---
|
||||
|
||||
## Batch 13 — 文件浏览器增强(行号/图标/Diff/Git 变更面板/窗口分离)
|
||||
|
||||
- **提交**: `c6df455`
|
||||
- **内容**:
|
||||
- **行号显示**: 文件预览左侧行号列
|
||||
- **后缀图标**: 20+ 文件类型彩色 SVG 图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
|
||||
- **Diff 视图**: 有 Git 变更的文件可切换内容/Diff 红绿视图
|
||||
- **Git 变更面板**: 文件树侧栏切换至变更视图,变更文件列表(按目录缩进分组)+提交历史(分页 50 条)
|
||||
- **提交详情**: 点击提交行展开变更文件列表,点文件查看该文件 diff
|
||||
- **提交时间显示**: 相对时间(刚刚/N分钟前)+超过 30 天显示具体日期
|
||||
- **窗口分离**: FileExplorer 可弹出独立 Tauri 窗口
|
||||
- **中文编码修复**: git 命令注入 LANG/LC_ALL UTF-8 环境变量,修复中文乱码
|
||||
- **多工程管理 UI**: 工具栏添加工程按钮+弹窗表单
|
||||
- **文件变更自动刷新**: write_file/patch_file 触发 df-data-changed 事件,AI 写入后自动刷新
|
||||
- **面包屑不丢预览**: 面包屑导航时保留当前预览文件
|
||||
- **刷新保留选中文件**: 只清树缓存不清预览
|
||||
- **自适应布局**: 弹性 flex 布局,窄窗口自适应
|
||||
- **审批超时 130s->30s**: 按钮级 loading 超时缩短
|
||||
- **阶段进度条隐藏**: 装饰性元素,待接入真实状态机后启用
|
||||
- **验证**: cargo check + vue-tsc + vite build 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 14 — 文件浏览器收尾
|
||||
|
||||
- **提交**: `53e6442`
|
||||
- **内容**:
|
||||
- 提交之前未提交的滚动条与布局修复
|
||||
- 移除阶段进度条死代码(模板+CSS+常量)
|
||||
- 文件预览空状态加图标引导
|
||||
- 变更视图加载骨架屏
|
||||
- 工程选择器改为自定义下拉(显示工程路径)
|
||||
|
||||
---
|
||||
|
||||
## Batch 15 — 多工程管理
|
||||
|
||||
- **提交**: `d8d6226`
|
||||
- **内容**:
|
||||
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git 地址)
|
||||
- 工程删除二次确认:防误删
|
||||
- 记住上次选中工程:localStorage 持久化
|
||||
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
|
||||
|
||||
---
|
||||
|
||||
## Batch 16 — Git 提交历史作者
|
||||
|
||||
- **提交**: `b19b0f3`
|
||||
- **内容**:
|
||||
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
|
||||
- 提交列表每行显示 hash/作者/主题/时间
|
||||
- 提交详情头部补充作者显示
|
||||
- 分支切换按钮占位(后续接入分支列表 IPC)
|
||||
|
||||
---
|
||||
|
||||
## 渲染崩溃修复
|
||||
|
||||
- **提交**: `bce2fea`
|
||||
- **内容**:
|
||||
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
|
||||
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
|
||||
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
|
||||
|
||||
---
|
||||
|
||||
## Batch 17 — Git 能力闭环
|
||||
|
||||
- **提交**: `15eaa0c`
|
||||
- **内容**:
|
||||
- Diff 行号解析:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
|
||||
- 提交详情增强:显示父提交哈希/作者/日期/完整消息
|
||||
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支
|
||||
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
|
||||
|
||||
---
|
||||
|
||||
## Batch 18 — 小程序审批超时计时器
|
||||
|
||||
- **提交**: `773ebb6`
|
||||
- **内容**:
|
||||
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
|
||||
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
|
||||
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
|
||||
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
|
||||
- 超时自动拒绝(approve(id,false))+ toast 提示用户
|
||||
|
||||
---
|
||||
|
||||
## Batch 19 — 任务管理 UX 重构 + 项目卡片工程数
|
||||
|
||||
- **提交**: `2780f6a` + `aa4d521`
|
||||
- **内容**:
|
||||
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
|
||||
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
|
||||
- 新增排序选择器:更新/创建时间/优先级/状态
|
||||
- 分组可折叠:点击组头切换,记忆状态到 localStorage
|
||||
- 分页默认开启:pageSize=20(原 0=全量)
|
||||
- 紧凑布局:任务行密度优化,显示总条数
|
||||
- 空状态引导:图标 + 新建按钮
|
||||
- 模态框样式收敛:去内联 style
|
||||
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
|
||||
- 项目卡片显示工程数(异步拉取 module 列表)
|
||||
|
||||
---
|
||||
|
||||
## Batch 20 — coordinator.rs 空壳删除
|
||||
|
||||
- **提交**: `948d6e6`
|
||||
- **内容**:
|
||||
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
|
||||
- lib.rs 移除 pub mod coordinator 声明
|
||||
- 消除编译 deprecated 警告
|
||||
|
||||
---
|
||||
|
||||
## 任务行快捷操作菜单
|
||||
|
||||
- **提交**: `27eb0a9`
|
||||
- **内容**:
|
||||
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
|
||||
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
|
||||
- 点击外部自动关闭菜单
|
||||
|
||||
---
|
||||
|
||||
## stores/ai TDZ 防御
|
||||
|
||||
- **提交**: `76aec62`
|
||||
- **内容**:
|
||||
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
|
||||
|
||||
---
|
||||
|
||||
## Plan DAG 分层执行器
|
||||
|
||||
- **提交**: `4dac9fe`
|
||||
- **内容**:
|
||||
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
|
||||
- feature flag 门控(默认关,未接入主 loop)
|
||||
- 3 个单元测试覆盖:线性链/并行层/失败容错
|
||||
|
||||
---
|
||||
|
||||
## 变更区完善
|
||||
|
||||
- **提交**: `78ef5dc`
|
||||
- **内容**:
|
||||
- 变更文件选中后展开 Diff 红绿预览
|
||||
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
|
||||
|
||||
---
|
||||
|
||||
## 任务列表后端真分页
|
||||
|
||||
- **提交**: `3c2fa91`
|
||||
- **内容**:
|
||||
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
|
||||
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
|
||||
- 去除前端客户端 slice(后端已分页,直接分组)
|
||||
|
||||
---
|
||||
|
||||
## Batch 25 — 工程依赖图数据层
|
||||
|
||||
- **提交**: `3758bea`
|
||||
- **内容**:
|
||||
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
|
||||
- IPC:add/remove/list_module_dependencies
|
||||
- 前端 API 封装 + DependencyGraph 接入真实边数据
|
||||
- 依赖类型颜色区分(library/api/mq/shared/custom)
|
||||
- 小地图插件(MiniMap)大图概览导航
|
||||
- 点击节点跳转项目详情
|
||||
|
||||
---
|
||||
|
||||
## Batch 26 — 依赖图增强
|
||||
|
||||
- **提交**: `dd0e059`
|
||||
- **内容**:
|
||||
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
|
||||
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
|
||||
- 依赖边按类型着色
|
||||
|
||||
---
|
||||
|
||||
## Batch 28 — 架构债收尾
|
||||
|
||||
- **提交**: `8991215`
|
||||
- **内容**:
|
||||
- ScriptNode 白/黑名单注释清理(标注实际读取方式)
|
||||
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub,非 bug
|
||||
|
||||
---
|
||||
|
||||
## Batch 29 — 环形检测 + PNG 导出 + 真实总数
|
||||
|
||||
- **提交**: `36ea090`
|
||||
- **内容**:
|
||||
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
|
||||
- 前端环检测按钮:高亮参与环的节点(红色边框)
|
||||
- 图导出 PNG(X6 toPNG 回调模式)
|
||||
- 后端 count_tasks IPC + 前端 taskApi.count()
|
||||
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
|
||||
|
||||
---
|
||||
|
||||
## Batch 30 — 代码质量收尾
|
||||
|
||||
- **提交**: `6771d39`
|
||||
- **内容**:
|
||||
- 设置页搜索索引补全(新增 dataDir 项)
|
||||
- 编译警告清理(df-storage OptionalExtension + df-ai join_set)
|
||||
- i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key)
|
||||
- String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId)
|
||||
- **验证**: cargo check + vue-tsc + vite build 通过
|
||||
|
||||
## Batch 31 — God 文件拆分第一步(MessageList)
|
||||
|
||||
- **提交**: `2c2b2d7`
|
||||
- **内容**:
|
||||
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
|
||||
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
|
||||
- MessageList.vue 从 1552 行降至 1386 行(-166 行)
|
||||
- **验证**: vue-tsc + vite build 通过
|
||||
|
||||
## Batch 32 — God 文件拆分第二步(ChatInput)
|
||||
|
||||
- **提交**: `3abdfb4`
|
||||
- **内容**:
|
||||
- 提取 SkillMention.vue(/@ 联想浮层,63 行)
|
||||
- 提取 ImageInput.vue(图片粘贴/拖拽预览,33 行)
|
||||
- ChatInput.vue 从 1183 行降至 1155 行
|
||||
- **验证**: vue-tsc + vite build 通过
|
||||
|
||||
## Batch 34 — 产品完善
|
||||
|
||||
- **提交**: `2af24f0`
|
||||
- **内容**:
|
||||
- 小程序 device 在线心跳加固(早前已完成)
|
||||
- ScriptNode 白名单跨 crate 接口打通:OnceLock 全局态 + IPC set_script_safety
|
||||
- AI 写入后文件树自动刷新(早前已完成)
|
||||
- Projects 列表分页(后端 list_projects 已支持 limit/offset)
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
## Batch 35 — 人设系统(P0·第一步)
|
||||
|
||||
- **提交**: `0a65ea2`
|
||||
- **内容**:
|
||||
- AgentPersona 数据结构(能力标签/工具白名单/prompt 模板)
|
||||
- PersonaRegistry 注册表(5 内置人设: coder/reviewer/architect/tester/analyst)
|
||||
- 按意图推荐人设(启发式关键词匹配合适角色)
|
||||
- build_prompt 注入上下文和目标钉扎
|
||||
- **验证**: 10 个单元测试通过
|
||||
|
||||
## Batch 36 — Coordinator 接入 + audit 拆分
|
||||
|
||||
- **提交**: `fa410e6`
|
||||
- **内容**:
|
||||
- audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行)
|
||||
- coordinator.rs 移除 deprecated 标记,正式可用
|
||||
- Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan)
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 37 — 代码卫生与质量提升(String→newtype + 文件清理 + 文档同步)
|
||||
|
||||
- **提交**: `4624688`
|
||||
- **内容**:
|
||||
- ExecutionId/ToolCallType/ToolType 裸 String → newtype(IPC 边界仍透明序列化为字符串)
|
||||
- ChatMessage.status 从 `Option<String>` → `Option<MessageStatus>` 枚举(Active/Truncated/Compressed/ArchivedSegment)
|
||||
- .gitignore 添加分析脚本,tmp 文件清理
|
||||
- Batch.md/文档状态同步
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
|
||||
|
||||
- **提交**: 待后续
|
||||
- **内容**:
|
||||
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
|
||||
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
|
||||
- `pinned_goals_snapshot` 提前初始化,coordinator 出口复用
|
||||
- **测试**: cargo check + vue-tsc 通过
|
||||
- **验证**: plan_execution_enabled 默认关,主线行为零变化
|
||||
|
||||
## 后续规划批次(待推进)
|
||||
|
||||
> 设计文档:[多Agent并行执行与仲裁合并设计-2026-07-01.md](docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||
|
||||
### 数据层 + Git worktree 隔离 + 并行执行 ✅
|
||||
|
||||
- **提交**: `ae6d3d0` → `4483358` → `3c9077b`
|
||||
- **测试**: 48 个全绿(7 Repo + 7 worktree + 27 Coordinator + 4 Token + 3 merge)
|
||||
- **验证**: cargo check(0 警告) + vue-tsc + vite build
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | V36 迁移(ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id + subtasks.branch + conflicts.conflict_type) | migrations.rs |
|
||||
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD + models 结构体 | 新 repo 文件 |
|
||||
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
|
||||
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
|
||||
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
|
||||
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
|
||||
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
|
||||
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
|
||||
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
|
||||
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
|
||||
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
|
||||
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
|
||||
|
||||
### 仲裁合并 + 冲突 UI + 编译检查 ✅
|
||||
|
||||
- **提交**: `1d580dc` → `6e44878`
|
||||
- **测试**: 7 个 merge 冲突检测测试全绿
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | Coordinator.merge 冲突检测(同文件路径 + 跳过失败 SubTask) | ✅ |
|
||||
| 2 | extract_written_files 辅助函数(从 output 提取写入路径) | ✅ |
|
||||
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
|
||||
| 4 | i18n 冲突翻译键(中英文) | ✅ |
|
||||
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
|
||||
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
|
||||
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
|
||||
| 8 | context.rs 拆分(1956行) | 待下一批 |
|
||||
|
||||
### 模板系统 + 节点补齐 ✅
|
||||
|
||||
- **提交**: `f40287b` → `27b4268`
|
||||
- **测试**: 8 个模板加载 + 37 个节点 + 5 个 SubflowNode 测试全绿
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | YAML 模板格式定义 + 加载器(YAML→DagDef + 校验:空节点/未知类型/环/边引用) | ✅ |
|
||||
| 2 | GitNode(分支/checkout/commit/merge/push/status/log) | ✅ |
|
||||
| 3 | HTTPNode(GET/POST/PUT/DELETE + headers/body/timeout) | ✅ |
|
||||
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
|
||||
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
|
||||
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
|
||||
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
|
||||
| 8 | MessageList.vue 继续拆分 | 待下一批 |
|
||||
|
||||
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | DockerNode(环境检测+授权) | ✅ |
|
||||
| 2 | GitNode push(plan分支推送) | ✅ GitNode 含 push action |
|
||||
| 3 | commit status 读取(Gitea API) | ✅ ci_status.rs |
|
||||
| 4 | CIStatus.vue 前端面板 | ✅ |
|
||||
| 5 | ChatInput.vue 拆分(1155→1007行) | ✅ |
|
||||
| 6 | context.rs 拆分(1956→538行) | ✅ |
|
||||
|
||||
### 审批政策 + 小程序完善 + 体验收尾 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | 审批策略 Persona 维度 | ✅ |
|
||||
| 2 | 小程序 MentionInput 接真实数据 | ✅ 核验已完成 |
|
||||
| 3 | 小程序长会话虚拟化 | ✅ 截断200条合理兜底 |
|
||||
| 4 | 快捷键完善(Ctrl+N/Ctrl+R) | ✅ |
|
||||
| 5 | ARCHITECTURE.md 状态同步 | ✅ |
|
||||
|
||||
### 批次 C 前序散项 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | Reviewer Agent 仲裁 | ✅ |
|
||||
| 2 | 命令互斥锁 | ✅ |
|
||||
| 3 | resolve_conflict IPC | ✅ |
|
||||
| 4 | 模板 CRUD IPC | ✅ |
|
||||
| 5 | MessageList.vue 拆分(1386→1148行) | ✅ |
|
||||
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
|
||||
| 7 | df-types augmentation 测试修复 | ✅ |
|
||||
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
|
||||
| 9 | 合并产出落回主对话 | 待接线 |
|
||||
|
||||
---
|
||||
|
||||
## 未纳入批次的其他会话改动
|
||||
|
||||
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复(Batch 6 补丁 `cc3ef5b` 纳入提交)
|
||||
567
Cargo.lock
generated
567
Cargo.lock
generated
@@ -248,6 +248,64 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -721,6 +779,26 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -771,6 +849,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"df-ai",
|
||||
"df-execute",
|
||||
"df-ideas",
|
||||
@@ -778,6 +857,7 @@ dependencies = [
|
||||
"df-nodes",
|
||||
"df-project",
|
||||
"df-storage",
|
||||
"df-tunnel",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"futures",
|
||||
@@ -794,6 +874,16 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tree-sitter",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-java",
|
||||
"tree-sitter-javascript",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -802,13 +892,15 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -878,6 +970,7 @@ dependencies = [
|
||||
"df-storage",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -893,10 +986,27 @@ dependencies = [
|
||||
"df-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-relay"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-storage"
|
||||
version = "0.1.0"
|
||||
@@ -912,6 +1022,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-tunnel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.23.1",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-types"
|
||||
version = "0.1.0"
|
||||
@@ -933,6 +1058,7 @@ dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1184,17 +1310,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eventsource-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"nom",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1534,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1545,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1842,6 +1961,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -1856,6 +1981,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -1876,6 +2002,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2259,6 +2386,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2371,6 +2504,12 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2382,6 +2521,21 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
@@ -2403,12 +2557,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2512,13 +2660,12 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3201,6 +3348,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3229,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3240,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3252,6 +3474,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3352,6 +3583,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3359,6 +3592,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3368,6 +3602,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3491,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -3503,6 +3739,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3614,7 +3851,7 @@ dependencies = [
|
||||
"generic-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
@@ -3743,6 +3980,7 @@ version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -3750,6 +3988,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3823,6 +4072,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -3876,6 +4138,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3986,6 +4257,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -4033,6 +4310,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -4536,6 +4819,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -4640,6 +4932,32 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4786,6 +5104,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4829,11 +5148,25 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -4852,6 +5185,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4876,12 +5239,129 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde_json",
|
||||
"streaming-iterator",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-go"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-java"
|
||||
version = "0.23.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-javascript"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"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-python"
|
||||
version = "0.23.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-typescript"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4964,6 +5444,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5019,6 +5505,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -5225,6 +5717,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.4"
|
||||
@@ -5281,6 +5783,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6018,7 +6529,7 @@ dependencies = [
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
|
||||
@@ -22,3 +22,7 @@ regex = "1"
|
||||
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
|
||||
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
|
||||
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
|
||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||
dashmap = "6"
|
||||
|
||||
59
PROGRESS.md
59
PROGRESS.md
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 项目进展与工作交接
|
||||
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量);2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-07-02 | 当前阶段: 多 Agent 并行执行架构落地完成(Plan DAG + worktree 隔离 + JoinSet 并行 + Reviewer 仲裁 + 模板系统 + 全节点补齐),详见 [Batch.md](Batch.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
|
||||
| 路径 | `E:/wk-lab/devflow/` |
|
||||
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
|
||||
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
|
||||
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
|
||||
| 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
|
||||
| Git 状态 | 已纳入版本控制(main 分支) |
|
||||
| AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|
||||
|-------|--------|--------|--------|---------|
|
||||
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
|
||||
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
|
||||
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
|
||||
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) |
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
```
|
||||
真实可用路径(有端到端逻辑):
|
||||
df-core (类型基础)
|
||||
df-types (类型基础)
|
||||
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
|
||||
→ df-execute/shell (tokio::process Shell 执行)
|
||||
→ df-storage (SQLite 连接 + 迁移 + 建表)
|
||||
@@ -98,7 +98,7 @@
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
|
||||
| df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
|
||||
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
|
||||
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
|
||||
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
|
||||
@@ -190,7 +190,7 @@
|
||||
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行)
|
||||
|
||||
**工作内容**(三个子代理并行,目录互不重叠):
|
||||
- **Migrations V2**(df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
|
||||
- **Migrations V2**(df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
|
||||
- **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
|
||||
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
|
||||
|
||||
@@ -665,7 +665,7 @@
|
||||
|
||||
**遗留 / 下一步(修复批次建议)**:
|
||||
- **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
|
||||
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-core)+ ⑤(tool_registry 复用 allowed_columns_for)
|
||||
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-types)+ ⑤(tool_registry 复用 allowed_columns_for)
|
||||
- **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal)
|
||||
- ⚪ 可选: ⑨⑩⑪⑫ 按需
|
||||
|
||||
@@ -1031,6 +1031,47 @@
|
||||
|
||||
---
|
||||
|
||||
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
|
||||
|
||||
> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
|
||||
> 代码变更 50 文件,+2454/-1251 行,未提交。
|
||||
|
||||
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
|
||||
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)|
|
||||
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states |
|
||||
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
|
||||
| Phase4 | conversation_delete 同步 conv_states.remove |
|
||||
|
||||
**前端修复**
|
||||
- P0-1: watchdog `convStates.clear()` → `delete(activeConversationId)`
|
||||
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
|
||||
- 每轮 token 独立显示(🔣 XXX in · YYY out)
|
||||
- STREAM_TIMEOUT_MS 45s → 90s
|
||||
|
||||
**后端修复**
|
||||
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
|
||||
- `env_snapshot.rs` 5s 超时兜底(Windows Store alias 卡死)
|
||||
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
|
||||
- `openai_compat.rs` send 阶段 60s timeout
|
||||
- 原生 SSE 解析器替代 eventsource-stream(Windows 兼容)
|
||||
- run_agentic_loop 20s AiHeartbeat 保活
|
||||
- grep 正则自动修复(未分组 `|` → `(?:...)` 包裹)
|
||||
- read_file 路径不存在提示 list_directory
|
||||
|
||||
**标题策略改进**
|
||||
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
|
||||
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
|
||||
|
||||
**代码审查清理**
|
||||
- 修复 lib.rs 重复 handler 注册(🔴 严重)
|
||||
- 清理 unused import / [STUCK] 调试日志 / 过时注释
|
||||
- 340 tests passed, vue-tsc clean
|
||||
|
||||
**待提交**:50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
|
||||
|
||||
## 七、开发约定
|
||||
|
||||
### 构建命令
|
||||
|
||||
21
apps/df-miniapp/index.html
Normal file
21
apps/df-miniapp/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
|
||||
<title>DevFlow Mini</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
|
||||
})
|
||||
</script>
|
||||
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>本应用需 JavaScript 启用。</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
13924
apps/df-miniapp/package-lock.json
generated
Normal file
13924
apps/df-miniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
apps/df-miniapp/package.json
Normal file
35
apps/df-miniapp/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "df-miniapp",
|
||||
"version": "0.1.0",
|
||||
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
|
||||
"marked": "^18.0.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.0",
|
||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vue/runtime-core": "^3.4.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.2.8",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
31
apps/df-miniapp/src/App.vue
Normal file
31
apps/df-miniapp/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
|
||||
const aiChat = useAiChat()
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[DevFlow Mini] 应用启动')
|
||||
aiChat.connect()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
|
||||
aiChat.resumeBackground()
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
|
||||
console.log('[DevFlow Mini] 应用进入后台')
|
||||
aiChat.pauseBackground()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
page {
|
||||
background-color: $uni-bg-color;
|
||||
color: $uni-text-color;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
</style>
|
||||
586
apps/df-miniapp/src/api/ws.ts
Normal file
586
apps/df-miniapp/src/api/ws.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* df-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/miniapp`
|
||||
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
|
||||
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
* - H5:uni.connectSocket 封装浏览器 WebSocket
|
||||
* - 统一经 uni API 封装,业务层不直接 new WebSocket
|
||||
*
|
||||
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
|
||||
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
|
||||
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
|
||||
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
|
||||
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
|
||||
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
|
||||
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
|
||||
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
|
||||
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
|
||||
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
|
||||
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
|
||||
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
|
||||
*
|
||||
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
|
||||
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/config'
|
||||
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** WS 连接状态 */
|
||||
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
|
||||
|
||||
/** 入站事件回调(Event/Control 消息分派) */
|
||||
export interface WsHandlers {
|
||||
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
|
||||
onEvent: (msg: BroadcastMessage) => void
|
||||
/** 收到 Control 消息(心跳响应 / 在线状态等) */
|
||||
onControl: (msg: ControlMessage) => void
|
||||
/** 状态变更(连接 / 断开 / 重连中) */
|
||||
onStatus: (status: WsStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
|
||||
interface QueuedCmd {
|
||||
cmd: MiniCommand
|
||||
ts: number
|
||||
}
|
||||
|
||||
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
|
||||
const HANDSHAKE_TIMEOUT_MS = 12000
|
||||
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
|
||||
const CONNECT_TIMEOUT_MS = 8000
|
||||
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
|
||||
const PENDING_MAX = 50
|
||||
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
|
||||
const PENDING_TTL_MS = 60000
|
||||
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
|
||||
const HEARTBEAT_DEAD_FACTOR = 2
|
||||
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
|
||||
const MAX_RECONNECT_ATTEMPTS = 20
|
||||
|
||||
/**
|
||||
* WS 客户端单例(miniapp 全局一个连接)
|
||||
*
|
||||
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
|
||||
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
|
||||
*/
|
||||
class WsClient {
|
||||
private socket: UniApp.SocketTask | null = null
|
||||
private handlers: WsHandlers | null = null
|
||||
private status: WsStatus = 'disconnected'
|
||||
/** 握手是否已完成(收到首个广播或握手成功信号) */
|
||||
private handshaked = false
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
/**
|
||||
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
|
||||
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
|
||||
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
|
||||
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
|
||||
*/
|
||||
private pendingQueue: QueuedCmd[] = []
|
||||
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
|
||||
private lastInboundAt = 0
|
||||
|
||||
/** 订阅(单订阅,composable 注册一次) */
|
||||
subscribe(h: WsHandlers): void {
|
||||
this.handlers = h
|
||||
}
|
||||
|
||||
/** 读取当前状态 */
|
||||
getStatus(): WsStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/** 主动连接 */
|
||||
connect(): void {
|
||||
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
|
||||
this.reconnectAttempts = 0
|
||||
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
|
||||
this.reconnectNow()
|
||||
}
|
||||
|
||||
/** 主动断开(用户手动,不重连) */
|
||||
disconnect(): void {
|
||||
this.manualClose = true
|
||||
this.reconnectAttempts = 0
|
||||
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
|
||||
this.cleanup()
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
|
||||
*
|
||||
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
|
||||
* - disconnected:onClose 已触发,直接重连;
|
||||
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
|
||||
*
|
||||
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
|
||||
*/
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.manualClose) return
|
||||
if (this.status === 'disconnected' || this.status === 'reconnecting') {
|
||||
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
|
||||
*
|
||||
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
|
||||
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
|
||||
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
|
||||
*/
|
||||
pauseBackground(): void {
|
||||
this.stopHeartbeat()
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
|
||||
*/
|
||||
resumeBackground(): void {
|
||||
if (this.status === 'connected' && this.handshaked) {
|
||||
this.startHeartbeat()
|
||||
} else {
|
||||
this.resumeIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(miniapp → device)
|
||||
*
|
||||
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
||||
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
if (!this.socket || !this.handshaked) {
|
||||
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
|
||||
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
|
||||
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
|
||||
if (this.manualClose) {
|
||||
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
|
||||
return false
|
||||
}
|
||||
this.enqueuePending(cmd)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(cmd)
|
||||
this.socket.send({ data: text })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[WsClient] send 失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
|
||||
private enqueuePending(cmd: MiniCommand): void {
|
||||
if (this.pendingQueue.length >= PENDING_MAX) {
|
||||
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
|
||||
this.pendingQueue.shift()
|
||||
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
|
||||
}
|
||||
this.pendingQueue.push({ cmd, ts: Date.now() })
|
||||
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
|
||||
*
|
||||
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
|
||||
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
|
||||
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
|
||||
*/
|
||||
private flushPendingQueue(): void {
|
||||
if (this.pendingQueue.length === 0) return
|
||||
const now = Date.now()
|
||||
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
|
||||
while (this.pendingQueue.length > 0) {
|
||||
const item = this.pendingQueue.shift()!
|
||||
if (now - item.ts > PENDING_TTL_MS) {
|
||||
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (this.socket) {
|
||||
this.socket.send({ data: JSON.stringify(item.cmd) })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
|
||||
|
||||
/**
|
||||
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
|
||||
*
|
||||
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
|
||||
* 统一经此,保证:
|
||||
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
|
||||
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
|
||||
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
|
||||
*/
|
||||
private reconnectNow(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.cleanup()
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
const cfg = getConfig()
|
||||
this.setStatus('connecting')
|
||||
console.log('[WsClient] 连接', cfg.relayHost)
|
||||
|
||||
let task: UniApp.SocketTask
|
||||
try {
|
||||
task = uni.connectSocket({
|
||||
url: cfg.relayHost,
|
||||
complete: () => {
|
||||
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[WsClient] connectSocket 异常', e)
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.socket = task
|
||||
|
||||
task.onOpen(() => this.handleOpen())
|
||||
task.onMessage((res) => this.handleMessage(res))
|
||||
task.onClose((res) => this.handleClose(res))
|
||||
task.onError((res) => this.handleError(res))
|
||||
|
||||
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
|
||||
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null
|
||||
if (this.status === 'connecting') {
|
||||
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
|
||||
private handleOpen(): void {
|
||||
// onOpen 到达,取消连接超时
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'miniapp',
|
||||
device_id: cfg.deviceId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(hello) })
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 发送 Hello 失败', e)
|
||||
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
|
||||
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (!this.handshaked) {
|
||||
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
||||
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
||||
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
|
||||
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
|
||||
if (typeof res.data !== 'string') {
|
||||
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
|
||||
return
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
if (!this.handshaked) {
|
||||
this.handleHandshakeResponse(text)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
|
||||
let msg: BroadcastMessage
|
||||
try {
|
||||
msg = JSON.parse(text) as BroadcastMessage
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
|
||||
if (msg.kind === 'event') {
|
||||
this.handlers?.onEvent(msg)
|
||||
} else if (msg.kind === 'control') {
|
||||
this.handlers?.onControl(msg.payload as ControlMessage)
|
||||
}
|
||||
// command 是 device→miniapp 反向(不应在此端收到,忽略)
|
||||
}
|
||||
|
||||
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
|
||||
private handleHandshakeResponse(text: string): void {
|
||||
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
|
||||
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
|
||||
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
|
||||
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
|
||||
let parsedOk = false
|
||||
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
|
||||
parsedOk = true
|
||||
} catch (e) {
|
||||
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
|
||||
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
|
||||
}
|
||||
|
||||
if (parsedOk && parsed) {
|
||||
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
|
||||
if (parsed.error !== undefined) {
|
||||
console.error('[WsClient] 握手失败', parsed.error, text)
|
||||
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
|
||||
this.clearHandshakeTimer()
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
|
||||
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
|
||||
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
|
||||
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
|
||||
// device 离线则无响应,文案保持「已连接中继」诚实表述。
|
||||
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
|
||||
this.flushPendingQueue()
|
||||
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
|
||||
return
|
||||
}
|
||||
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
|
||||
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
|
||||
if (text.includes('"error"')) {
|
||||
console.error('[WsClient] 握手失败(includes 兜底)', text)
|
||||
this.setStatus('disconnected', `handshake failed: ${text}`)
|
||||
this.clearHandshakeTimer()
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
|
||||
this.flushPendingQueue()
|
||||
this.handleMessage({ data: text })
|
||||
}
|
||||
|
||||
private handleClose(res: { code: number; reason: string }): void {
|
||||
console.log('[WsClient] 连接关闭', res.code, res.reason)
|
||||
this.cleanup()
|
||||
if (!this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(res: { errMsg: string }): void {
|
||||
console.error('[WsClient] socket 错误', res.errMsg)
|
||||
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
|
||||
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
|
||||
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
|
||||
if (this.status === 'connecting' || this.status === 'handshaking') {
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const cfg = getConfig()
|
||||
// 连接成功初始化活性时间(首次心跳检查不立即判死)
|
||||
this.lastInboundAt = Date.now()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
|
||||
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
|
||||
const now = Date.now()
|
||||
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
|
||||
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
const ping: ControlMessage = { control_kind: 'ping' }
|
||||
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(ping) })
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 心跳发送失败', e)
|
||||
}
|
||||
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
|
||||
void cfg
|
||||
}, cfg.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
|
||||
*
|
||||
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
|
||||
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
|
||||
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
|
||||
*
|
||||
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return
|
||||
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
|
||||
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
|
||||
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
|
||||
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
const cfg = getConfig()
|
||||
const attempt = this.reconnectAttempts++
|
||||
const exp = Math.min(attempt, 6)
|
||||
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
|
||||
const jitter = 0.5 + Math.random() * 0.5
|
||||
const delay = Math.round(base * jitter)
|
||||
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
||||
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.reconnectNow()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清握手超时定时器(握手成功/失败时调) */
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
|
||||
*
|
||||
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
|
||||
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
|
||||
*/
|
||||
private destroySocket(task: UniApp.SocketTask): void {
|
||||
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
|
||||
try {
|
||||
task.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
|
||||
*
|
||||
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
|
||||
*/
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.clearHandshakeTimer()
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
this.handshaked = false
|
||||
this.pendingQueue = []
|
||||
}
|
||||
|
||||
private setStatus(status: WsStatus, detail?: string): void {
|
||||
this.status = status
|
||||
this.handlers?.onStatus(status, detail)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(miniapp 进程内一个连接) */
|
||||
export const wsClient = new WsClient()
|
||||
36
apps/df-miniapp/src/components/MdView/MdView.vue
Normal file
36
apps/df-miniapp/src/components/MdView/MdView.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<!-- rich-text 小程序原生组件(无子组件依赖,稳)。marked md→html → rich-text :nodes 渲染。
|
||||
alpha 根因已查实(uni-app 2026 alpha 致 tap 失效),rich-text 非元凶,降级后正常用。 -->
|
||||
<rich-text :nodes="html" :selectable="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||
|
||||
defineOptions({ name: 'MdView' })
|
||||
|
||||
/**
|
||||
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
|
||||
*
|
||||
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
|
||||
* - breaks:true 聊天换行(单换行转 <br>)
|
||||
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
|
||||
* - parse 失败回退原文
|
||||
* - 空内容返空(rich-text 不渲染)
|
||||
*
|
||||
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
|
||||
*/
|
||||
const props = defineProps<{ content: string }>()
|
||||
|
||||
const html = computed(() => {
|
||||
const src = props.content || ''
|
||||
if (!src) return ''
|
||||
try {
|
||||
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
|
||||
} catch {
|
||||
return src
|
||||
}
|
||||
})
|
||||
</script>
|
||||
133
apps/df-miniapp/src/components/MentionInput/MentionInput.vue
Normal file
133
apps/df-miniapp/src/components/MentionInput/MentionInput.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<!-- @/ 联想弹层(MVP)。
|
||||
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入。
|
||||
/file /web /task /memory + @file @web @task 四类技能占位。
|
||||
渲染对齐 chat/index.vue 的 mp-weixin 稳定风格(原生 view/text + 绝对定位浮层)。 -->
|
||||
<view v-if="visible" class="mention-mask" @tap="onClose">
|
||||
<view class="mention-pop" @tap.stop>
|
||||
<view class="mention-head">
|
||||
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
|
||||
<text class="mention-hint">MVP 占位 · 点击插入</text>
|
||||
</view>
|
||||
<scroll-view class="mention-list" scroll-y>
|
||||
<view
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="mention-item"
|
||||
@tap="onSelect(item)"
|
||||
>
|
||||
<text class="mention-item-label">{{ item.label }}</text>
|
||||
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MentionInput 联想弹层组件(MVP)。
|
||||
*
|
||||
* 设计:
|
||||
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
|
||||
* - 不查后端/不调路由:items 由父组件固定注入
|
||||
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
|
||||
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
|
||||
*
|
||||
* props:
|
||||
* - visible:是否显示
|
||||
* - trigger:触发类型(`/` 技能 / `@` 提及)
|
||||
* - items:占位项列表 {label, insert}
|
||||
* emits:
|
||||
* - select(insert):插入文本
|
||||
* - close():关闭弹层
|
||||
*/
|
||||
defineOptions({ name: 'MentionInput' })
|
||||
|
||||
interface MentionItem {
|
||||
label: string
|
||||
insert: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
trigger: '/' | '@'
|
||||
items: MentionItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', insert: string): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
function onSelect(item: MentionItem): void {
|
||||
emit('select', item.insert)
|
||||
}
|
||||
|
||||
function onClose(): void {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
.mention-pop {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 56px;
|
||||
max-height: 260px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mention-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #262626;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
.mention-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mention-hint {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mention-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mention-item-label {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mention-item-insert {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
1171
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
1171
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
File diff suppressed because it is too large
Load Diff
112
apps/df-miniapp/src/config.ts
Normal file
112
apps/df-miniapp/src/config.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
|
||||
*
|
||||
* 这些值在真实部署前是占位:
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
|
||||
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
|
||||
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
|
||||
*/
|
||||
|
||||
export interface MiniappConfig {
|
||||
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
|
||||
relayHost: string
|
||||
/** 配对绑定的设备 ID */
|
||||
deviceId: string
|
||||
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
|
||||
token: string
|
||||
/** 心跳间隔(ms) */
|
||||
heartbeatInterval: number
|
||||
/** 重连初始间隔(ms,指数退避) */
|
||||
reconnectBaseDelay: number
|
||||
/** 重连最大间隔(ms) */
|
||||
reconnectMaxDelay: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置(占位值,需替换)
|
||||
*
|
||||
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
|
||||
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
|
||||
* 这符合预期,提示用户配置)。
|
||||
*/
|
||||
export const defaultConfig: MiniappConfig = {
|
||||
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
|
||||
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
|
||||
relayHost: 'wss://u-work.1216.top/ws/miniapp',
|
||||
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
|
||||
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
token: 'devflow-relay-default-token',
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
reconnectMaxDelay: 30000,
|
||||
}
|
||||
|
||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||
const STORAGE_KEY = 'df-miniapp-config'
|
||||
|
||||
/** 单例配置(null 表示尚未从 storage 加载) */
|
||||
let _config: MiniappConfig | null = null
|
||||
|
||||
/**
|
||||
* 从 storage 读取并合并默认配置
|
||||
*
|
||||
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
|
||||
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
|
||||
*/
|
||||
function loadFromStorage(): MiniappConfig {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (!raw) return { ...defaultConfig }
|
||||
const parsed = JSON.parse(raw) as Partial<MiniappConfig>
|
||||
// 合并默认值,避免旧版本字段缺失导致 undefined
|
||||
return { ...defaultConfig, ...parsed }
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] load config from storage failed:', e)
|
||||
return { ...defaultConfig }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步写入 storage(JSON 序列化完整配置)
|
||||
*
|
||||
* 容错:写入失败仅 warn,不影响内存配置。
|
||||
*/
|
||||
function saveToStorage(config: MiniappConfig): void {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] save config to storage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
// 首次调用时从 storage 加载并缓存
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
_config = { ..._config, ...patch }
|
||||
saveToStorage(_config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置并清除 storage 持久化数据
|
||||
*
|
||||
* 用于用户重新配对或恢复出厂占位值的场景。
|
||||
*/
|
||||
export function resetConfig(): void {
|
||||
_config = { ...defaultConfig }
|
||||
try {
|
||||
uni.removeStorageSync(STORAGE_KEY)
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] remove config from storage failed:', e)
|
||||
}
|
||||
}
|
||||
13
apps/df-miniapp/src/main.ts
Normal file
13
apps/df-miniapp/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
/**
|
||||
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
|
||||
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
|
||||
*/
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
53
apps/df-miniapp/src/manifest.json
Normal file
53
apps/df-miniapp/src/manifest.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "DevFlow",
|
||||
"appid": "PLACEHOLDER",
|
||||
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": 100,
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "PLACEHOLDER",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": true,
|
||||
"postcss": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredBackgroundModes": [],
|
||||
"requiredPrivateInfos": []
|
||||
},
|
||||
"h5": {
|
||||
"title": "DevFlow Mini",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 8081,
|
||||
"https": false
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
41
apps/df-miniapp/src/pages.json
Normal file
41
apps/df-miniapp/src/pages.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 对话",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/conversations/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会话列表",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "DevFlow",
|
||||
"navigationBarBackgroundColor": "#1a1a1a",
|
||||
"backgroundColor": "#0f0f0f",
|
||||
"backgroundTextStyle": "light"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#4a9eff",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/chat/index",
|
||||
"text": "对话"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/conversations/index",
|
||||
"text": "会话"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
993
apps/df-miniapp/src/pages/chat/index.vue
Normal file
993
apps/df-miniapp/src/pages/chat/index.vue
Normal file
@@ -0,0 +1,993 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { MentionSpan } from '@/types/relay'
|
||||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events'
|
||||
|
||||
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
||||
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
||||
// MdView/MentionInput 源保留备用(工具修复后可拆回)。
|
||||
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
|
||||
const mdCache = new Map<string, string>()
|
||||
function renderMd(src: string): string {
|
||||
if (!src) return ''
|
||||
const cached = mdCache.get(src)
|
||||
if (cached !== undefined) return cached
|
||||
let html: string
|
||||
try {
|
||||
html = marked.parse(src, { breaks: true, async: false }) as string
|
||||
html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
|
||||
} catch {
|
||||
html = src
|
||||
}
|
||||
mdCache.set(src, html)
|
||||
return html
|
||||
}
|
||||
|
||||
const ai = useAiChat()
|
||||
const inputText = ref('')
|
||||
|
||||
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
|
||||
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
|
||||
const mentionVisible = ref(false)
|
||||
const mentionTrigger = ref<'skill' | 'entity'>('skill')
|
||||
|
||||
// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
|
||||
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
|
||||
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
|
||||
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
|
||||
const pendingSkill = ref<SkillInfoLike | null>(null)
|
||||
|
||||
// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
|
||||
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
|
||||
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
|
||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||
|
||||
// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
|
||||
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
|
||||
const scrollAnchor = ref('')
|
||||
function scrollToBottom(): void {
|
||||
scrollAnchor.value = ''
|
||||
// setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
|
||||
setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
|
||||
}
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 流式增量高频,throttle 防滚底抖动 */
|
||||
function throttledScrollToBottom(): void {
|
||||
if (scrollTimer) return
|
||||
scrollTimer = setTimeout(() => {
|
||||
scrollTimer = null
|
||||
scrollToBottom()
|
||||
}, 120)
|
||||
}
|
||||
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
|
||||
watch(() => ai.messages.length, () => scrollToBottom())
|
||||
watch(() => ai.currentText.value, throttledScrollToBottom)
|
||||
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
|
||||
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
|
||||
/**
|
||||
* P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
|
||||
* TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
|
||||
*/
|
||||
const MAX_RENDER_MSGS = 200
|
||||
const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
|
||||
|
||||
/**
|
||||
* 输入变化时检测联想触发条件。
|
||||
*
|
||||
* 两类触发:
|
||||
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
|
||||
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
|
||||
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
|
||||
* listEntities() 拉实体 + 显实体浮层。
|
||||
*
|
||||
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
|
||||
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
|
||||
*/
|
||||
function detectTrigger(): void {
|
||||
const text = inputText.value
|
||||
if (!text) {
|
||||
mentionVisible.value = false
|
||||
return
|
||||
}
|
||||
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
|
||||
if (text.startsWith('/') && !pendingSkill.value) {
|
||||
mentionTrigger.value = 'skill'
|
||||
mentionVisible.value = true
|
||||
ai.listSkills()
|
||||
return
|
||||
}
|
||||
// 实体触发:末尾连续非空白段恰好单字符 @
|
||||
const match = text.match(/([^\s]+)\s*$/)
|
||||
const last = match ? match[1] : ''
|
||||
if (last === '@') {
|
||||
mentionTrigger.value = 'entity'
|
||||
mentionVisible.value = true
|
||||
ai.listEntities()
|
||||
return
|
||||
}
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
|
||||
function onInput(): void {
|
||||
detectTrigger()
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
|
||||
*
|
||||
* 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
|
||||
* 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
|
||||
* 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
|
||||
*/
|
||||
function onSkillSelect(skill: SkillInfoLike): void {
|
||||
pendingSkill.value = skill
|
||||
inputText.value = ''
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
|
||||
*
|
||||
* span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
|
||||
* 后端据 start/length 在原文中切出区间注入对应实体上下文。
|
||||
* kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
|
||||
* 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
|
||||
*/
|
||||
function onEntitySelect(
|
||||
entity: ProjectRecord | TaskRecord | IdeaRecord,
|
||||
kind: 'project' | 'task' | 'idea',
|
||||
): void {
|
||||
const text = inputText.value
|
||||
// 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
|
||||
const stripped = text.replace(/@\s*$/, '')
|
||||
// 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
|
||||
const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
|
||||
// 名:project 用 name,task/idea 用 title
|
||||
const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
|
||||
const insertText = `[${kindLabel}:${name}]`
|
||||
const start = stripped.length
|
||||
inputText.value = stripped + insertText
|
||||
// 累积 mention span(字符偏移,后端透传不解释单位)
|
||||
pendingMentionSpans.value.push({
|
||||
start,
|
||||
length: insertText.length,
|
||||
kind,
|
||||
refId: entity.id,
|
||||
label: name,
|
||||
})
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
|
||||
function clearPendingSkill(): void {
|
||||
pendingSkill.value = null
|
||||
}
|
||||
|
||||
/** 关闭弹层(点遮罩) */
|
||||
function onMentionClose(): void {
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
|
||||
*
|
||||
* - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
|
||||
* - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
|
||||
* - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
|
||||
*
|
||||
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
||||
*/
|
||||
function handleSend(): void {
|
||||
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
|
||||
const text = inputText.value.trim()
|
||||
if (!text) {
|
||||
console.log('[v2:send] 空文本,忽略')
|
||||
return
|
||||
}
|
||||
if (ai.generating.value) {
|
||||
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
|
||||
uni.showToast({
|
||||
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
mentionVisible.value = false
|
||||
const skill = pendingSkill.value ? pendingSkill.value.name : undefined
|
||||
const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
|
||||
ai.send(text, skill, spans)
|
||||
inputText.value = ''
|
||||
pendingSkill.value = null
|
||||
pendingMentionSpans.value = []
|
||||
}
|
||||
|
||||
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
|
||||
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
|
||||
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
|
||||
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
|
||||
|
||||
// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task',
|
||||
'delete_project',
|
||||
'restore_project',
|
||||
'purge_project',
|
||||
'delete_file',
|
||||
'run_command',
|
||||
'http_request',
|
||||
])
|
||||
|
||||
// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
running: { label: '执行中', cls: 'st-running' },
|
||||
completed: { label: '已完成', cls: 'st-completed' },
|
||||
failed: { label: '失败', cls: 'st-failed' },
|
||||
pending_approval: { label: '待审批', cls: 'st-pending' },
|
||||
rejected: { label: '已拒绝', cls: 'st-rejected' },
|
||||
}
|
||||
|
||||
/** 截断长字符串(参数值/结果展示防撑爆气泡) */
|
||||
function truncate(s: string, max = 120): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s
|
||||
}
|
||||
|
||||
/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
|
||||
function formatArgs(args: unknown): { key: string; val: string }[] {
|
||||
if (!args || typeof args !== 'object') return []
|
||||
const obj = args as Record<string, unknown>
|
||||
return Object.entries(obj)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
|
||||
function formatResult(result: unknown): string {
|
||||
if (result === undefined || result === null) return ''
|
||||
if (typeof result === 'string') return truncate(result)
|
||||
try {
|
||||
return truncate(JSON.stringify(result))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
|
||||
*
|
||||
* 后端工具挂起审批时往消息流写占位 assistant message,content 形如
|
||||
* 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
|
||||
* miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
|
||||
* 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
|
||||
*/
|
||||
const PENDING_MARKER = '__PENDING__:'
|
||||
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
|
||||
function isPendingPlaceholder(content: string | undefined | null): boolean {
|
||||
if (!content) return false
|
||||
if (content.includes(PENDING_MARKER)) return true
|
||||
return content === PENDING_PLACEHOLDER_LEGACY
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
|
||||
* {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
|
||||
*/
|
||||
function isToolResultJson(content: string | undefined | null): boolean {
|
||||
if (!content) return false
|
||||
const t = content.trim()
|
||||
if (!(t.startsWith('{') || t.startsWith('['))) return false
|
||||
try {
|
||||
JSON.parse(t)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
|
||||
function onApprove(tc: AiToolCallInfo): void {
|
||||
if (HIGH_RISK_TOOLS.has(tc.name)) {
|
||||
uni.showModal({
|
||||
title: '高危操作确认',
|
||||
content: `「${tc.name}」属高危操作,确认批准执行?`,
|
||||
confirmColor: '#e85a4f',
|
||||
success: (r) => {
|
||||
if (r.confirm) ai.approve(tc.id, true)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
ai.approve(tc.id, true)
|
||||
}
|
||||
|
||||
/** 审批拒绝(risk 类) */
|
||||
function onReject(tc: AiToolCallInfo): void {
|
||||
ai.approve(tc.id, false)
|
||||
}
|
||||
|
||||
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
|
||||
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
|
||||
ai.authorizeDir(tc.id, decision)
|
||||
}
|
||||
|
||||
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
connecting: '连接中…',
|
||||
handshaking: '握手中…',
|
||||
reconnecting: '重连中…·点此立即重连',
|
||||
disconnected: '已断开·点此重连',
|
||||
}
|
||||
const statusText = computed(() => {
|
||||
if (ai.isWsConnected.value) {
|
||||
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
|
||||
}
|
||||
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
|
||||
})
|
||||
const canManualReconnect = computed(
|
||||
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
|
||||
)
|
||||
function onStatusTap(): void {
|
||||
if (canManualReconnect.value) ai.resumeIfDisconnected()
|
||||
}
|
||||
|
||||
/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
|
||||
function onStop(): void {
|
||||
ai.stop()
|
||||
}
|
||||
|
||||
/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
|
||||
function onContinueLoop(): void {
|
||||
ai.continueLoop()
|
||||
}
|
||||
|
||||
/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
|
||||
function onStopLoop(): void {
|
||||
ai.stopLoop()
|
||||
}
|
||||
|
||||
/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
|
||||
function onRegenerate(): void {
|
||||
ai.regenerate()
|
||||
}
|
||||
|
||||
/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
|
||||
function onCopyMessage(m: { content?: string }): void {
|
||||
if (!m.content) return
|
||||
uni.setClipboardData({
|
||||
data: m.content,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
|
||||
<view class="top">
|
||||
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
|
||||
<view class="top-right">
|
||||
<text v-if="ai.tokenUsage.value" class="token-usage">🪙 {{ ai.tokenUsage.value.total }}</text>
|
||||
<view v-if="ai.pendingApprovals.length > 0" class="pending-badge">
|
||||
<text>⏳ {{ ai.pendingApprovals.length }} 待审批</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
|
||||
<view v-if="ai.maxRoundsActive.value" class="maxrounds-panel">
|
||||
<text class="maxrounds-text">⚠ 已达最大轮次</text>
|
||||
<view class="maxrounds-actions">
|
||||
<button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
|
||||
<button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 待审批面板(从 pendingApprovals 渲染,与 messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
|
||||
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
|
||||
<view class="pending-panel-head">
|
||||
<text>⏳ {{ ai.pendingApprovals.length }} 项待审批</text>
|
||||
</view>
|
||||
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
|
||||
<view class="tool-head">
|
||||
<text class="tool-name">🔧 {{ p.name }}</text>
|
||||
<text class="tool-status st-pending">待审批</text>
|
||||
</view>
|
||||
<view v-if="formatArgs(p.args).length > 0" class="tool-args">
|
||||
<view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
|
||||
<text class="tool-arg-key">{{ a.key }}:</text>
|
||||
<text class="tool-arg-val">{{ a.val }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
|
||||
<text>📁 {{ p.dir }}</text>
|
||||
</view>
|
||||
<view v-if="p.reason" class="tool-reason">
|
||||
<text>⚠ {{ p.reason }}</text>
|
||||
</view>
|
||||
<!-- risk 类:批准/拒绝 -->
|
||||
<view v-if="p.kind !== 'path'" class="approval-actions">
|
||||
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
|
||||
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
|
||||
</view>
|
||||
<!-- path 类:本次/永久/拒绝 -->
|
||||
<view v-else class="approval-actions">
|
||||
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
|
||||
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
|
||||
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表(scroll-view 标准滚动,纯 text 渲染) -->
|
||||
<scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
|
||||
<view v-if="ai.messages.length === 0" class="empty">
|
||||
<text>暂无消息</text>
|
||||
</view>
|
||||
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
|
||||
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
|
||||
<template v-else>
|
||||
<!-- 后端审批占位 message(「需要…__PENDING__:call_xxx」)隐藏文本,审批走工具卡片 -->
|
||||
<rich-text
|
||||
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
|
||||
:nodes="renderMd(m.content)"
|
||||
selectable
|
||||
/>
|
||||
<!-- 纯 JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
|
||||
<view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
|
||||
<text>📋 {{ m.content }}</text>
|
||||
</view>
|
||||
<!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
|
||||
<!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
|
||||
<view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
|
||||
<view class="tool-head">
|
||||
<text class="tool-name">🔧 {{ tc.name }}</text>
|
||||
<text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
|
||||
{{ (STATUS_META[tc.status] || {}).label || tc.status }}
|
||||
</text>
|
||||
</view>
|
||||
<!-- 参数 -->
|
||||
<view v-if="formatArgs(tc.args).length > 0" class="tool-args">
|
||||
<view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
|
||||
<text class="tool-arg-key">{{ a.key }}:</text>
|
||||
<text class="tool-arg-val">{{ a.val }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- path 授权待授权目录 -->
|
||||
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
|
||||
<text>📁 {{ tc.dir }}</text>
|
||||
</view>
|
||||
<!-- 风险说明 -->
|
||||
<view v-if="tc.reason" class="tool-reason">
|
||||
<text>⚠ {{ tc.reason }}</text>
|
||||
</view>
|
||||
<!-- 审批按钮已移至顶部 pendingApprovals 面板(与 messages 解耦,req3 重连恢复) -->
|
||||
<!-- 执行结果摘要(completed/failed) -->
|
||||
<view
|
||||
v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
|
||||
class="tool-result"
|
||||
>
|
||||
<text>{{ formatResult(tc.result) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<!-- 流式生成中的当前文本 -->
|
||||
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
|
||||
<text user-select>{{ ai.currentText.value }}</text>
|
||||
</view>
|
||||
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
|
||||
<view id="list-bottom-anchor"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 联想弹层(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
|
||||
<view v-if="mentionVisible && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
|
||||
<view class="mention-pop" @tap.stop>
|
||||
<view class="mention-head">
|
||||
<text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
|
||||
<text class="mention-hint">点击选中</text>
|
||||
</view>
|
||||
<scroll-view class="mention-list" scroll-y>
|
||||
<!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
|
||||
<template v-if="mentionTrigger === 'skill'">
|
||||
<view v-if="ai.skills.length === 0" class="mention-empty">
|
||||
<text>加载中或无技能...</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(s, idx) in ai.skills"
|
||||
:key="'sk-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onSkillSelect(s)"
|
||||
>
|
||||
<view class="mention-item-main">
|
||||
<text class="mention-item-label">/{{ s.name }}</text>
|
||||
<text class="mention-item-source">{{ s.source }}</text>
|
||||
</view>
|
||||
<text class="mention-item-desc">{{ s.description }}</text>
|
||||
<text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 实体列表(`@` 触发,按 项目/任务/灵感 分组) -->
|
||||
<template v-else>
|
||||
<view v-if="ai.entities.projects.length === 0 && ai.entities.tasks.length === 0 && ai.entities.ideas.length === 0" class="mention-empty">
|
||||
<text>加载中或无数据...</text>
|
||||
</view>
|
||||
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
|
||||
<text>项目</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(p, idx) in ai.entities.projects"
|
||||
:key="'p-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onEntitySelect(p, 'project')"
|
||||
>
|
||||
<text class="mention-item-label">{{ p.name }}</text>
|
||||
<text class="mention-item-desc">{{ p.description }}</text>
|
||||
</view>
|
||||
<view v-if="ai.entities.tasks.length > 0" class="mention-group-title">
|
||||
<text>任务</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(t, idx) in ai.entities.tasks"
|
||||
:key="'t-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onEntitySelect(t, 'task')"
|
||||
>
|
||||
<text class="mention-item-label">{{ t.title }}</text>
|
||||
<text class="mention-item-desc">{{ t.description }}</text>
|
||||
</view>
|
||||
<view v-if="ai.entities.ideas.length > 0" class="mention-group-title">
|
||||
<text>灵感</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(i, idx) in ai.entities.ideas"
|
||||
:key="'i-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onEntitySelect(i, 'idea')"
|
||||
>
|
||||
<text class="mention-item-label">{{ i.title }}</text>
|
||||
<text class="mention-item-desc">{{ i.description }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 输入 + 发送(原生 button @tap) -->
|
||||
<view class="bar">
|
||||
<!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
|
||||
<view v-if="pendingSkill" class="skill-chip">
|
||||
<text class="skill-chip-name">/{{ pendingSkill.name }}</text>
|
||||
<text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
|
||||
<text class="skill-chip-close" @tap="clearPendingSkill">×</text>
|
||||
</view>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
class="ipt"
|
||||
:placeholder="ai.generating.value ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
|
||||
:auto-height="true"
|
||||
:adjust-position="true"
|
||||
confirm-type="send"
|
||||
@confirm="handleSend"
|
||||
@input="onInput"
|
||||
/>
|
||||
<!-- 重发最后一条 user(P1-F,非生成中且有消息时显) -->
|
||||
<button
|
||||
v-if="!ai.generating.value && ai.messages.length > 0"
|
||||
class="regen-btn"
|
||||
@tap="onRegenerate"
|
||||
>
|
||||
重发
|
||||
</button>
|
||||
<!-- 生成中显停止按钮(wires F9),否则发送 -->
|
||||
<button v-if="ai.generating.value" class="send-btn stop-btn" @tap="onStop">停止</button>
|
||||
<button v-else class="send-btn" @tap="handleSend">发送</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #0f0f0f;
|
||||
}
|
||||
.top {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
.st {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.st-tap {
|
||||
color: #4a9eff;
|
||||
}
|
||||
.top-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.token-usage {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
.empty text {
|
||||
color: #666666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
.msg text {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.msg.user {
|
||||
background-color: #4a9eff;
|
||||
}
|
||||
.msg.user text {
|
||||
color: #ffffff;
|
||||
}
|
||||
.msg.system text {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 8px 12px;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
.ipt {
|
||||
flex: 1;
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
min-height: 36px;
|
||||
max-height: 120px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.send-btn {
|
||||
margin-left: 8px;
|
||||
background-color: #4a9eff;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.send-btn.stop-btn {
|
||||
background-color: #5a2a2a;
|
||||
color: #e85a4f;
|
||||
}
|
||||
.regen-btn {
|
||||
margin-left: 8px;
|
||||
background-color: #2a2a2a;
|
||||
color: #c0c0c0;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
|
||||
.mention-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
.mention-pop {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 56px;
|
||||
max-height: 260px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mention-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #262626;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
.mention-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mention-hint {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mention-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-item-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mention-item-label {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mention-item-source {
|
||||
color: #777777;
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.mention-item-desc {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mention-item-insert {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mention-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.mention-empty text {
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mention-group-title {
|
||||
padding: 6px 12px;
|
||||
background-color: #232323;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-group-title text {
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
|
||||
.skill-chip {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
margin-right: 6px;
|
||||
background-color: #1f3a5f;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.skill-chip-name {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.skill-chip-hint {
|
||||
color: #7799bb;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.skill-chip-close {
|
||||
color: #4a9eff;
|
||||
font-size: 16px;
|
||||
margin-left: 6px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
|
||||
.pending-badge {
|
||||
flex-shrink: 0;
|
||||
margin: 0 8px;
|
||||
padding: 2px 8px;
|
||||
background-color: #3a2f1f;
|
||||
border: 1px solid #f0c75e;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pending-badge text {
|
||||
color: #f0c75e;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
|
||||
.pending-panel {
|
||||
flex-shrink: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px 12px 8px;
|
||||
background-color: #1a1a1a;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.pending-panel-head {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.pending-panel-head text {
|
||||
color: #f0c75e;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 达最大轮次面板(P1-E:继续/停止循环) */
|
||||
.maxrounds-panel {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #2a2218;
|
||||
border-bottom: 1px solid #3a2f1f;
|
||||
}
|
||||
.maxrounds-text {
|
||||
color: #f0c75e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.maxrounds-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.tool-card {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.tool-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tool-name {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tool-status {
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.tool-status.st-running {
|
||||
background-color: #2a3a5a;
|
||||
color: #6bb6ff;
|
||||
}
|
||||
.tool-status.st-completed {
|
||||
background-color: #1f3a2a;
|
||||
color: #6bd99a;
|
||||
}
|
||||
.tool-status.st-failed {
|
||||
background-color: #3a1f1f;
|
||||
color: #e85a4f;
|
||||
}
|
||||
.tool-status.st-pending {
|
||||
background-color: #3a2f1f;
|
||||
color: #f0c75e;
|
||||
}
|
||||
.tool-status.st-rejected {
|
||||
background-color: #2a2a2a;
|
||||
color: #999999;
|
||||
}
|
||||
.tool-args {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.tool-arg {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tool-arg-key {
|
||||
color: #7799bb;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.tool-arg-val {
|
||||
color: #c0c0c0;
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.tool-dir {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tool-dir text {
|
||||
color: #6bd99a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tool-reason {
|
||||
margin-top: 6px;
|
||||
padding: 4px 6px;
|
||||
background-color: #2a2218;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tool-reason text {
|
||||
color: #f0c75e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.approval-btn {
|
||||
margin-right: 6px;
|
||||
padding: 0 12px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.approval-btn.approve {
|
||||
background-color: #2a5a3a;
|
||||
color: #6bd99a;
|
||||
}
|
||||
.approval-btn.always {
|
||||
background-color: #2a3a5a;
|
||||
color: #6bb6ff;
|
||||
}
|
||||
.approval-btn.reject {
|
||||
background-color: #5a2a2a;
|
||||
color: #e85a4f;
|
||||
margin-right: 0;
|
||||
}
|
||||
.tool-result {
|
||||
margin-top: 6px;
|
||||
padding: 4px 6px;
|
||||
background-color: #181818;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tool-result text {
|
||||
color: #999999;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
|
||||
.msg-toolresult {
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
background-color: #181818;
|
||||
border-radius: 6px;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-toolresult text {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
189
apps/df-miniapp/src/pages/conversations/index.vue
Normal file
189
apps/df-miniapp/src/pages/conversations/index.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { Conversation } from '@/types/events'
|
||||
|
||||
const ai = useAiChat()
|
||||
|
||||
/** 点击会话项切换 */
|
||||
function handleSelect(convId: string): void {
|
||||
ai.switchConversation(convId)
|
||||
// 切换后跳回对话页
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 新建会话 */
|
||||
function handleNew(): void {
|
||||
ai.newConversation()
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 长按会话项重命名(对齐桌面端改会话名) */
|
||||
function handleRename(conv: Conversation): void {
|
||||
uni.showModal({
|
||||
title: '重命名会话',
|
||||
editable: true,
|
||||
placeholderText: '输入新名称',
|
||||
content: conv.title || '',
|
||||
success: (r) => {
|
||||
if (r.confirm && r.content) {
|
||||
const newTitle = r.content.trim()
|
||||
if (newTitle && newTitle !== conv.title) {
|
||||
ai.renameConversation(conv.id, newTitle)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
|
||||
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
|
||||
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
|
||||
onPullDownRefresh(() => {
|
||||
ai.refreshConversations()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
/** 格式化时间戳为可读字符串 */
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="conv-page">
|
||||
<!-- 顶部新建按钮 -->
|
||||
<view class="new-bar" @click="handleNew">
|
||||
<text class="new-icon">+</text>
|
||||
<text class="new-text">新建会话</text>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view class="conv-list" scroll-y>
|
||||
<view v-if="ai.conversations.length === 0" class="empty-hint">
|
||||
<text>暂无历史会话</text>
|
||||
<text class="empty-sub">在桌面端创建后,此处同步</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="conv in ai.conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ active: conv.id === ai.activeConversationId.value }"
|
||||
@click="handleSelect(conv.id)"
|
||||
@longpress="handleRename(conv)"
|
||||
>
|
||||
<view class="conv-main">
|
||||
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
|
||||
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
|
||||
</view>
|
||||
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
|
||||
</view>
|
||||
<view v-if="ai.conversations.length > 0" class="list-hint">
|
||||
<text>长按会话可重命名</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
|
||||
.new-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
background-color: $uni-bg-color-dark;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.new-icon {
|
||||
font-size: 40rpx;
|
||||
color: $uni-color-primary;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.new-text {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-color-primary;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 200rpx 24rpx;
|
||||
text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
.empty-sub {
|
||||
margin-top: 12rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
&.active {
|
||||
background-color: $uni-bg-color-light;
|
||||
border-left: 6rpx solid $uni-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-text-color;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
margin-top: 8rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-disable;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
.list-hint {
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.list-hint text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
</style>
|
||||
7
apps/df-miniapp/src/shims-uni.d.ts
vendored
Normal file
7
apps/df-miniapp/src/shims-uni.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
196
apps/df-miniapp/src/types/events.ts
Normal file
196
apps/df-miniapp/src/types/events.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
|
||||
*
|
||||
* 类型证据:
|
||||
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
|
||||
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
|
||||
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
|
||||
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
|
||||
*
|
||||
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
|
||||
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
|
||||
*/
|
||||
|
||||
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
|
||||
import type { SkillInfo } from './relay'
|
||||
|
||||
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
|
||||
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
||||
|
||||
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
/**
|
||||
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
|
||||
*
|
||||
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
|
||||
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
|
||||
*
|
||||
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
|
||||
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
|
||||
*/
|
||||
export type AiChatEvent =
|
||||
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiApprovalRequired'
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
reason: string
|
||||
diff?: string | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiCompleted'
|
||||
total_tokens: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
incomplete?: boolean | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
|
||||
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
|
||||
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
|
||||
| { type: 'AiHeartbeat'; conversation_id?: string | null }
|
||||
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
|
||||
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiDirAuthRequired'
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressing'; conversation_id?: string | null }
|
||||
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
|
||||
// miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。
|
||||
| { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string }
|
||||
| { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string }
|
||||
| {
|
||||
type: 'AiHelpRequired'
|
||||
reason: string
|
||||
context: string
|
||||
options: string[]
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
|
||||
| { type: 'AiConversationList'; conversations: Conversation[] }
|
||||
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
|
||||
| { type: 'AiSkillList'; skills: SkillInfo[] }
|
||||
| {
|
||||
type: 'AiEntityList'
|
||||
projects: ProjectRecord[]
|
||||
tasks: TaskRecord[]
|
||||
ideas: IdeaRecord[]
|
||||
}
|
||||
|
||||
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
|
||||
export type AiChatEventType = AiChatEvent['type']
|
||||
|
||||
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
|
||||
export interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
/** 最近一条消息摘要(可选) */
|
||||
lastMessage?: string
|
||||
/** 最近更新时间戳(ms) */
|
||||
updatedAt?: number
|
||||
/** 创建时间(ms) */
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: number
|
||||
isError?: boolean
|
||||
/** 工具调用列表(对齐 AiToolCallInfo) */
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||
export interface AiToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
result?: unknown
|
||||
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
|
||||
reason?: string
|
||||
diff?: string
|
||||
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
|
||||
kind?: 'risk' | 'path'
|
||||
dir?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
|
||||
export type PendingApproval = AiToolCallInfo
|
||||
|
||||
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
|
||||
export interface TokenUsage {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
|
||||
// ============================================================
|
||||
//
|
||||
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
|
||||
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
|
||||
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
|
||||
|
||||
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
|
||||
export interface ProjectRecord {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
idea_id: string | null
|
||||
/** 绑定的本地代码目录绝对路径(null=未绑定) */
|
||||
path: string | null
|
||||
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
|
||||
stack: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
|
||||
export interface TaskRecord {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
|
||||
priority: number
|
||||
branch_name: string | null
|
||||
assignee: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
|
||||
export interface IdeaRecord {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
priority: number
|
||||
score: number | null
|
||||
tags: string | null
|
||||
source: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
204
apps/df-miniapp/src/types/relay.ts
Normal file
204
apps/df-miniapp/src/types/relay.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
|
||||
*
|
||||
* 类型证据:
|
||||
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
|
||||
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
|
||||
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
|
||||
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
|
||||
*
|
||||
* 关键协议点(relay.rs:332-365 handle_inbound_text):
|
||||
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
|
||||
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
|
||||
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 再从 payload 取 AiChatEvent。
|
||||
*/
|
||||
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
|
||||
export type ClientKind = 'device' | 'miniapp'
|
||||
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
|
||||
export type MessageKind = 'event' | 'command' | 'control'
|
||||
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
|
||||
export type PresenceState = 'online' | 'offline'
|
||||
|
||||
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
|
||||
export type ConnId = number
|
||||
|
||||
/**
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
|
||||
*
|
||||
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
|
||||
*/
|
||||
export interface BroadcastMessage {
|
||||
/** 配对绑定的设备 ID */
|
||||
device_id: string
|
||||
/** 消息语义 */
|
||||
kind: MessageKind
|
||||
/** 消息来源连接 ID(relay 分配,u64) */
|
||||
source: ConnId
|
||||
/** 来源客户端类型 */
|
||||
from: ClientKind
|
||||
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
|
||||
payload: unknown
|
||||
/** 时间戳(ms) */
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
|
||||
*
|
||||
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
|
||||
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
|
||||
*/
|
||||
export interface Hello {
|
||||
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
|
||||
kind: 'miniapp' | 'device'
|
||||
/** 配对绑定的设备 ID(用户首次配置时生成) */
|
||||
device_id: string
|
||||
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
|
||||
*
|
||||
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
|
||||
*/
|
||||
export type ControlMessage =
|
||||
| { control_kind: 'pair'; device_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; device_id: string }
|
||||
| { control_kind: 'presence'; device_id: string; state: PresenceState }
|
||||
| { control_kind: 'ping' }
|
||||
| { control_kind: 'pong' }
|
||||
|
||||
/**
|
||||
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
|
||||
*
|
||||
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 字段:
|
||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||
* - args:命令参数(对齐各 command 的参数)
|
||||
*
|
||||
* 路由表全覆盖(14 条):
|
||||
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
|
||||
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
|
||||
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
|
||||
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
|
||||
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
|
||||
*/
|
||||
export interface MiniCommand {
|
||||
/** Tauri command 名 */
|
||||
cmd: string
|
||||
/** 命令参数(原始对象,对齐各 command 参数签名) */
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
|
||||
export interface SendMessageArgs {
|
||||
message: string
|
||||
conversation_id?: string | null
|
||||
model_override?: string | null
|
||||
/**
|
||||
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
|
||||
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
|
||||
*/
|
||||
skill?: string | null
|
||||
/**
|
||||
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
|
||||
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
|
||||
* null/undefined/空=无 mention(纯文本消息)。
|
||||
*/
|
||||
mention_spans?: MentionSpan[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息内 mention 区间的元数据
|
||||
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
|
||||
*
|
||||
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
|
||||
* 前端按 {start, length} 在原文中切出区间替换为 chip。
|
||||
*/
|
||||
export interface MentionSpan {
|
||||
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
|
||||
start: number
|
||||
/** 区间长度 */
|
||||
length: number
|
||||
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
|
||||
kind: 'project' | 'task' | 'idea' | 'skill'
|
||||
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
|
||||
refId: string
|
||||
/** chip 展示文本(项目名/任务标题/技能名) */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
|
||||
* src-tauri/src/commands/ai/skills.rs:19-34)。
|
||||
*
|
||||
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
|
||||
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
|
||||
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
|
||||
*/
|
||||
export interface SkillInfo {
|
||||
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
|
||||
name: string
|
||||
/** 技能描述(浮层副标题) */
|
||||
description: string
|
||||
/** 参数提示(可选,如 "<url>") */
|
||||
argument_hint?: string
|
||||
/** skill | command | plugin(来源分类) */
|
||||
source: string
|
||||
/** SKILL.md 绝对路径(后端注入时读全文) */
|
||||
path: string
|
||||
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
|
||||
duplicates?: string[]
|
||||
}
|
||||
|
||||
/** switch_conversation 命令参数 */
|
||||
export interface SwitchConversationArgs {
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop 命令参数(对齐 ai_chat_stop) */
|
||||
export interface StopArgs {
|
||||
conversation_id?: string | null
|
||||
}
|
||||
|
||||
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
|
||||
export interface ApproveArgs {
|
||||
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
|
||||
tool_call_id: string
|
||||
/** 是否批准(true=批准执行,false=拒绝) */
|
||||
approved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
|
||||
*
|
||||
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
|
||||
* - 'once':本次授权(下次同路径仍需问)
|
||||
* - 'always':永久授权(写入会话级信任目录)
|
||||
* - 'deny':拒绝(本次不授权)
|
||||
*/
|
||||
export interface AuthorizeDirArgs {
|
||||
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
|
||||
tool_call_id: string
|
||||
/** 授权决策('once' / 'always' / 'deny') */
|
||||
decision: 'once' | 'always' | 'deny'
|
||||
}
|
||||
|
||||
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
|
||||
export interface ContinueLoopArgs {
|
||||
/** 需继续运行的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
|
||||
export interface StopLoopArgs {
|
||||
/** 霷停止循环的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
33
apps/df-miniapp/src/uni.scss
Normal file
33
apps/df-miniapp/src/uni.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
|
||||
*
|
||||
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
|
||||
*/
|
||||
|
||||
/* 主色调 */
|
||||
$uni-color-primary: #4a9eff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 中性色(暗色背景适配) */
|
||||
$uni-bg-color: #0f0f0f;
|
||||
$uni-bg-color-dark: #1a1a1a;
|
||||
$uni-bg-color-light: #2a2a2a;
|
||||
$uni-text-color: #e0e0e0;
|
||||
$uni-text-color-grey: #999999;
|
||||
$uni-text-color-disable: #666666;
|
||||
$uni-border-color: #333333;
|
||||
|
||||
/* 字号 */
|
||||
$uni-font-size-sm: 24rpx;
|
||||
$uni-font-size-base: 28rpx;
|
||||
$uni-font-size-lg: 32rpx;
|
||||
|
||||
/* 气泡角色配色 */
|
||||
$uni-bubble-user-bg: #4a9eff;
|
||||
$uni-bubble-user-text: #ffffff;
|
||||
$uni-bubble-assistant-bg: #2a2a2a;
|
||||
$uni-bubble-assistant-text: #e0e0e0;
|
||||
$uni-bubble-error-bg: #3a1a1a;
|
||||
$uni-bubble-error-text: #ff6b6b;
|
||||
62
apps/df-miniapp/src/utils/mdRenderer.ts
Normal file
62
apps/df-miniapp/src/utils/mdRenderer.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
|
||||
*
|
||||
* 为什么后处理而非 marked 自定义 renderer:
|
||||
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
|
||||
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
|
||||
* 版本无关,build 不依赖 renderer TS 签名。
|
||||
*
|
||||
* mp-weixin rich-text 限制(已核官方文档,非臆测):
|
||||
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
|
||||
* 跳转需第三方 mp-html(本次不做,记后续)。
|
||||
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
|
||||
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
|
||||
*/
|
||||
|
||||
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
|
||||
function styleTag(html: string, tag: string, style: string): string {
|
||||
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
|
||||
return html.replace(re, (m, attrs: string) => {
|
||||
if (attrs && /\sstyle\s*=/.test(attrs)) {
|
||||
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
|
||||
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
|
||||
const merged = prev.endsWith(';') ? prev : `${prev};`
|
||||
return `style="${merged}${style}"`
|
||||
})
|
||||
}
|
||||
return `<${tag}${attrs || ''} style="${style}">`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 marked.parse 产出的 HTML 注入 inline style。
|
||||
*
|
||||
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
|
||||
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
|
||||
*/
|
||||
export function styleMarkdown(html: string): string {
|
||||
let out = html
|
||||
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
|
||||
out = styleTag(
|
||||
out,
|
||||
'pre',
|
||||
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
|
||||
)
|
||||
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
|
||||
out = styleTag(
|
||||
out,
|
||||
'code',
|
||||
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
|
||||
)
|
||||
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
|
||||
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
|
||||
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
|
||||
out = styleTag(
|
||||
out,
|
||||
'table',
|
||||
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
|
||||
)
|
||||
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
|
||||
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
|
||||
return out
|
||||
}
|
||||
25
apps/df-miniapp/tsconfig.json
Normal file
25
apps/df-miniapp/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"jsx": "preserve",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"types": ["@dcloudio/types", "@types/node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist", "unpackage"]
|
||||
}
|
||||
9
apps/df-miniapp/vite.config.ts
Normal file
9
apps/df-miniapp/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
|
||||
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
|
||||
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
})
|
||||
47
assets/templates/bug-fix.yaml
Normal file
47
assets/templates/bug-fix.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# Bug 修复模板
|
||||
name: Bug 修复
|
||||
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
|
||||
|
||||
nodes:
|
||||
locate:
|
||||
type: ai
|
||||
label: 定位 Bug
|
||||
config:
|
||||
prompt: |
|
||||
根据错误描述和堆栈信息,定位 bug 的根因。
|
||||
使用 search_files 和 read_file 工具查找相关代码。
|
||||
persona_id: analyst
|
||||
|
||||
fix:
|
||||
type: ai
|
||||
label: 修复 Bug
|
||||
config:
|
||||
prompt: |
|
||||
基于定位结果,修复 bug。
|
||||
使用 patch_file 工具进行最小化修改。
|
||||
persona_id: coder
|
||||
|
||||
verify:
|
||||
type: ai
|
||||
label: 验证修复
|
||||
config:
|
||||
prompt: |
|
||||
验证修复是否有效。
|
||||
使用 run_command 工具运行相关测试。
|
||||
persona_id: tester
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知结果
|
||||
config:
|
||||
type: desktop
|
||||
title: Bug 修复完成
|
||||
message: 修复已验证
|
||||
|
||||
edges:
|
||||
- from: locate
|
||||
to: fix
|
||||
- from: fix
|
||||
to: verify
|
||||
- from: verify
|
||||
to: notify
|
||||
36
assets/templates/code-review.yaml
Normal file
36
assets/templates/code-review.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# 代码审查模板
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
|
||||
|
||||
nodes:
|
||||
analyze:
|
||||
type: ai
|
||||
label: 分析代码变更
|
||||
config:
|
||||
prompt: |
|
||||
分析以下代码变更,识别:
|
||||
1. 潜在 bug 和逻辑错误
|
||||
2. 安全漏洞
|
||||
3. 性能问题
|
||||
4. 代码规范
|
||||
persona_id: reviewer
|
||||
|
||||
report:
|
||||
type: ai
|
||||
label: 生成审查报告
|
||||
config:
|
||||
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 代码审查完成
|
||||
message: 审查报告已生成
|
||||
|
||||
edges:
|
||||
- from: analyze
|
||||
to: report
|
||||
- from: report
|
||||
to: notify
|
||||
62
assets/templates/feature-dev.yaml
Normal file
62
assets/templates/feature-dev.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
# 功能开发模板
|
||||
name: 功能开发
|
||||
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
|
||||
|
||||
nodes:
|
||||
design:
|
||||
type: ai
|
||||
label: 方案设计
|
||||
config:
|
||||
prompt: |
|
||||
分析需求,设计实现方案:
|
||||
1. 模块划分和接口定义
|
||||
2. 数据结构和核心算法
|
||||
3. 影响范围评估
|
||||
persona_id: architect
|
||||
|
||||
implement:
|
||||
type: ai
|
||||
label: 编码实现
|
||||
config:
|
||||
prompt: |
|
||||
按照设计方案实现功能。
|
||||
使用 write_file 和 patch_file 工具编写代码。
|
||||
persona_id: coder
|
||||
|
||||
test:
|
||||
type: ai
|
||||
label: 编写测试
|
||||
config:
|
||||
prompt: |
|
||||
为新功能编写单元测试和集成测试。
|
||||
使用 run_command 工具运行测试验证。
|
||||
persona_id: tester
|
||||
|
||||
review:
|
||||
type: ai
|
||||
label: 代码审查
|
||||
config:
|
||||
prompt: |
|
||||
审查实现代码,关注:
|
||||
1. 代码质量和可维护性
|
||||
2. 潜在 bug
|
||||
3. 安全性
|
||||
persona_id: reviewer
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 功能开发完成
|
||||
message: 设计→编码→测试→审查 全流程完成
|
||||
|
||||
edges:
|
||||
- from: design
|
||||
to: implement
|
||||
- from: implement
|
||||
to: test
|
||||
- from: test
|
||||
to: review
|
||||
- from: review
|
||||
to: notify
|
||||
82
bun.lock
82
bun.lock
@@ -5,10 +5,18 @@
|
||||
"": {
|
||||
"name": "devflow",
|
||||
"dependencies": {
|
||||
"@antv/layout": "^2.0.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@antv/x6-vue-shape": "^3.0.2",
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"dompurify": "^3.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"dagre": "^0.8.5",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"tslib": "^2.8.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "4",
|
||||
@@ -24,6 +32,20 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
|
||||
|
||||
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
|
||||
|
||||
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
|
||||
|
||||
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
|
||||
|
||||
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
|
||||
|
||||
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
|
||||
|
||||
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
|
||||
|
||||
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
|
||||
|
||||
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
|
||||
@@ -172,6 +194,10 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
|
||||
|
||||
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -228,15 +254,35 @@
|
||||
|
||||
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||
|
||||
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
|
||||
|
||||
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
|
||||
|
||||
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
|
||||
|
||||
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
@@ -244,20 +290,44 @@
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||
|
||||
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
|
||||
|
||||
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
|
||||
|
||||
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
|
||||
|
||||
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
|
||||
|
||||
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
|
||||
|
||||
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
@@ -284,18 +354,26 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
|
||||
|
||||
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
|
||||
|
||||
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,14 +95,14 @@ impl ChatMessage {
|
||||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.status.as_deref(), None | Some("active"))
|
||||
matches!(self.status, None | Some(MessageStatus::Active))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
tool_type: ToolType::new("function"),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
@@ -166,22 +166,22 @@ mod tests {
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("active".to_string());
|
||||
m.status = Some(MessageStatus::Active);
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("truncated".to_string());
|
||||
m.status = Some(MessageStatus::Truncated);
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("archived_segment".to_string());
|
||||
m.status = Some(MessageStatus::ArchivedSegment);
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("compressed".to_string());
|
||||
m.status = Some(MessageStatus::Compressed);
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
|
||||
@@ -103,11 +103,11 @@ pub struct ChatMessage {
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// 消息状态(UX-09 编辑重生成):None 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
pub status: Option<MessageStatus>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
@@ -148,11 +148,91 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageStatus {
|
||||
/// 正常可见
|
||||
Active,
|
||||
/// 软删(编辑某条 user 消息后其后续消息标记)
|
||||
Truncated,
|
||||
/// 已压缩
|
||||
Compressed,
|
||||
/// 已归档段
|
||||
ArchivedSegment,
|
||||
}
|
||||
|
||||
impl MessageStatus {
|
||||
/// DB 存储用的小写 snake_case 字符串
|
||||
pub fn as_db_str(&self) -> &'static str {
|
||||
match self {
|
||||
MessageStatus::Active => "active",
|
||||
MessageStatus::Truncated => "truncated",
|
||||
MessageStatus::Compressed => "compressed",
|
||||
MessageStatus::ArchivedSegment => "archived_segment",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 字符串解析
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"active" => MessageStatus::Active,
|
||||
"truncated" => MessageStatus::Truncated,
|
||||
"compressed" => MessageStatus::Compressed,
|
||||
"archived_segment" => MessageStatus::ArchivedSegment,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatMessage 结构体
|
||||
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolType(String);
|
||||
|
||||
impl ToolType {
|
||||
/// 构造新工具类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for ToolType {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ToolType {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub tool_type: ToolType,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
@@ -169,7 +249,7 @@ pub struct ToolFunction {
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub call_type: ToolType,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
|
||||
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
|
||||
//!
|
||||
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
|
||||
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
|
||||
//!
|
||||
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
|
||||
use df_ai::build_provider;
|
||||
use df_ai_core::CompletionRequest;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
|
||||
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
|
||||
eprintln!("[glm-test] token len={}", token.len());
|
||||
|
||||
let provider = build_provider(
|
||||
"anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
&token,
|
||||
"glm-5.2",
|
||||
);
|
||||
|
||||
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
|
||||
let req: CompletionRequest = serde_json::from_str(
|
||||
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
|
||||
)
|
||||
.expect("parse CompletionRequest");
|
||||
|
||||
eprintln!("[glm-test] 调用 provider.stream() ...");
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = match provider.stream(req).await {
|
||||
Ok(s) => {
|
||||
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] stream() Err: {:#}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut n = 0;
|
||||
let mut got_text = false;
|
||||
while let Some(chunk_result) = s.next().await {
|
||||
n += 1;
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
got_text = true;
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
|
||||
n, chunk.delta,
|
||||
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
|
||||
chunk.finished, chunk.usage, chunk.error
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
|
||||
}
|
||||
}
|
||||
if n > 60 {
|
||||
eprintln!("[glm-test] 超 60 chunk 截断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
|
||||
n,
|
||||
got_text,
|
||||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
@@ -222,6 +221,17 @@ impl AnthropicCompatProvider {
|
||||
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
|
||||
Self::merge_consecutive_users(&mut messages);
|
||||
|
||||
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
|
||||
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
|
||||
// "首条 role=assistant 非法"。
|
||||
// 根本解用"补"而非"砍":开头补一条 user 占位,保留全部上下文(assistant 的 tool_use
|
||||
// 与其后 user 的 tool_result 配对完整),precheck 必过。砍会丢工具调用历史,且多轮
|
||||
// [asst(tu),user(tr),asst(tu),user(tr),...] 逐对砍到空。占位是异常路径轻量噪声
|
||||
// (正常会话首条本就是 user),远优于丢弃上下文。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| AnthropicToolDef {
|
||||
@@ -295,6 +305,42 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
///
|
||||
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
|
||||
/// 或从历史片段截取时,真正的首条 user 已被裁剪/压缩掉。直接发触发 precheck "首条
|
||||
/// role=assistant 非法"。
|
||||
///
|
||||
/// **用"补"而非"砍"**:在开头插一条 user 占位消息。
|
||||
/// - 砍掉首条 assistant 会丢失有效上下文(其 tool_use 与后续 user 的 tool_result 是完整
|
||||
/// 配对),且多轮 [asst(tu),user(tr),asst(tu),user(tr),...] 会被逐对砍到空;
|
||||
/// - 补占位则全部上下文保留(占位 user 紧贴原首条 assistant,不破坏 user/assistant 交替),
|
||||
/// tool_use/tool_result 配对完整不动,**无 orphan 产生**(故砍策略那套 orphan 清理在此不需要),
|
||||
/// 占位 content 非空过 precheck 的"user content 空"校验。
|
||||
///
|
||||
/// 仅异常路径触发(正常会话首条本就是 user),占位文案是轻量噪声,远优于丢弃工具调用历史。
|
||||
fn ensure_leading_user(messages: &mut Vec<serde_json::Value>) {
|
||||
let first_role = messages
|
||||
.first()
|
||||
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
|
||||
.unwrap_or("");
|
||||
if first_role == "user" {
|
||||
return;
|
||||
}
|
||||
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
|
||||
// Anthropic 协议要求 messages 至少一条且首条 user,补占位让降级会话能继续(不丢这条兜底)。
|
||||
warn!(
|
||||
first_role,
|
||||
msg_count = messages.len(),
|
||||
"ensure_leading_user: 首条非 user(含空),补 user 占位(防 Anthropic 首条 assistant/空 messages 非法)"
|
||||
);
|
||||
messages.insert(0, serde_json::json!({
|
||||
"role": "user",
|
||||
"content": "(continued from previous context)",
|
||||
}));
|
||||
}
|
||||
|
||||
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||
@@ -543,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
let resp = match self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
|
||||
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
|
||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||
let send_future = self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.version(reqwest::Version::HTTP_11)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
@@ -573,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
std::error::Error::source(&e)
|
||||
);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
|
||||
tracing::error!(
|
||||
url = %self.messages_url(),
|
||||
"Anthropic 流式 send 超时(60s 未返回响应头)"
|
||||
);
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
@@ -582,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。
|
||||
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
|
||||
// usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => {
|
||||
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
|
||||
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
|
||||
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
}
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
Ok(chunk)
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("Anthropic SSE 错误: {}", e);
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
error!("{}", ctx);
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
@@ -878,4 +930,170 @@ mod tests {
|
||||
// 纯文本 → 字符串简写(非数组)
|
||||
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
|
||||
/// 辅助:构造 assistant(tool_use) 消息
|
||||
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
|
||||
ChatMessage::assistant_with_tools(
|
||||
text,
|
||||
vec![ToolCall::new(tool_id, tool_name, "{}")],
|
||||
)
|
||||
}
|
||||
|
||||
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
|
||||
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
msg_assistant_with_tool_use("我来帮你", "call_f892", "read_file"),
|
||||
ChatMessage::tool_result("call_f892", "file content"),
|
||||
msg_assistant_with_tool_use("继续", "call_003a", "write_file"),
|
||||
ChatMessage::tool_result("call_003a", "done"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
|
||||
// 首条必须是 user(补的占位)
|
||||
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
assert_eq!(first_role, "user", "首条应为 user(补占位)");
|
||||
|
||||
// 上下文零丢失:占位 user + asst(tu) + user(tr) + asst(tu) + user(tr) = 5 条
|
||||
assert_eq!(
|
||||
body.messages.len(), 5,
|
||||
"应保留全部上下文(占位 + 原 4 条),实际 {} 条", body.messages.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"precheck 应通过,实际 messages: {}",
|
||||
AnthropicCompatProvider::summarize_messages(&body.messages)
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_plain_assistant() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
ChatMessage::assistant("你好"),
|
||||
ChatMessage::user("请帮我"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 3, "占位 + 原 2 条");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi there"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 2, "正常序列不应补占位");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_three_round_chain() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
msg_assistant_with_tool_use("a1", "call_1", "read_file"),
|
||||
ChatMessage::tool_result("call_1", "r1"),
|
||||
msg_assistant_with_tool_use("a2", "call_2", "write_file"),
|
||||
ChatMessage::tool_result("call_2", "r2"),
|
||||
msg_assistant_with_tool_use("a3", "call_3", "list_directory"),
|
||||
ChatMessage::tool_result("call_3", "r3"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
assert_eq!(first_role, "user", "首条应为 user(补占位)");
|
||||
// 占位 + 3×[asst(tu),user(tr)] = 7 条,全部保留
|
||||
assert_eq!(
|
||||
body.messages.len(), 7,
|
||||
"3 轮链应全保留(占位 + 原 6 条),实际 {} 条", body.messages.len()
|
||||
);
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"3 轮链补占位后 precheck 应通过,实际: {}",
|
||||
AnthropicCompatProvider::summarize_messages(&body.messages)
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 1, "空 messages 应补 1 条 user 占位");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"补占位后 precheck 应通过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
719
crates/df-ai/src/context/manager_tests.rs
Normal file
719
crates/df-ai/src/context/manager_tests.rs
Normal file
@@ -0,0 +1,719 @@
|
||||
//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
|
||||
//!
|
||||
//! 这些测试覆盖 `ContextManager` 各公开/私有方法(push / build_for_request / compress /
|
||||
//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内
|
||||
//! (`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_message_counts_parts_tokens() {
|
||||
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
|
||||
let est = TokenEstimator::default();
|
||||
|
||||
// 纯文本基线
|
||||
let text_msg = ChatMessage::user("短文本");
|
||||
let text_tokens = est.estimate_message(&text_msg);
|
||||
|
||||
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
|
||||
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
|
||||
let multimodal = ChatMessage::user_parts(
|
||||
"短文本",
|
||||
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
|
||||
);
|
||||
let mm_tokens = est.estimate_message(&multimodal);
|
||||
|
||||
assert!(
|
||||
mm_tokens > text_tokens,
|
||||
"含图消息 token({}) 应高于纯文本({})",
|
||||
mm_tokens,
|
||||
text_tokens
|
||||
);
|
||||
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
|
||||
assert!(
|
||||
mm_tokens > 500,
|
||||
"大 base64 应贡献可观 token,实际 {}",
|
||||
mm_tokens
|
||||
);
|
||||
|
||||
// url 模式(无字节)也按 URL 长度估算,不爆
|
||||
let url_msg = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||||
);
|
||||
let url_tokens = est.estimate_message(&url_msg);
|
||||
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_history_no_trim() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("你好"));
|
||||
mgr.push(ChatMessage::assistant("你好啊"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert_eq!(msgs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_trims_old() {
|
||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||
for i in 0..20 {
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
|
||||
}
|
||||
}
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
||||
|
||||
// 保护区:最新一条必保留(末条 i=19 是 assistant)
|
||||
assert_eq!(
|
||||
msgs.last().unwrap().content,
|
||||
"第 19 条较长的回复用于撑爆预算",
|
||||
"保护区最新消息被误裁"
|
||||
);
|
||||
|
||||
// 裁剪是视图:内存全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_triplet_kept_atomic() {
|
||||
// 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断
|
||||
// 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
|
||||
let mut mgr = ContextManager::new(cfg(95));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调工具",
|
||||
vec![ToolCall::new("tc1", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
|
||||
}
|
||||
|
||||
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
|
||||
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
|
||||
assert!(trimmed1, "分支一应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep),
|
||||
"分支一三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep)
|
||||
);
|
||||
|
||||
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
|
||||
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
|
||||
assert!(trimmed2, "分支二应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop),
|
||||
"分支二三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop)
|
||||
);
|
||||
|
||||
// 裁剪是视图:两次 build 都不应改变内存全量
|
||||
assert_eq!(
|
||||
mgr.all_messages_clone().len(),
|
||||
15,
|
||||
"裁剪污染了内存全量"
|
||||
);
|
||||
}
|
||||
|
||||
fn has_head(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
}
|
||||
|
||||
fn has_tail(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_tool_result_updates_tokens() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "短"));
|
||||
let before = mgr.history_tokens();
|
||||
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
|
||||
let after = mgr.history_tokens();
|
||||
assert!(after > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rebuilds_token_cache() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let src = vec![
|
||||
ChatMessage::user("测试消息一"),
|
||||
ChatMessage::assistant("回复一"),
|
||||
ChatMessage::user("测试消息二"),
|
||||
];
|
||||
mgr.restore_from_messages(src);
|
||||
assert!(mgr.history_tokens() > 0);
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_token_only_active() {
|
||||
// F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push
|
||||
// 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens,
|
||||
// 避免 build_for_request 误判超预算触发不必要裁剪。
|
||||
|
||||
// 1) 直接 push 路径
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let active_msg = ChatMessage::user("这条是 active 的");
|
||||
let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
|
||||
let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
|
||||
inactive_msg.status = Some(MessageStatus::Truncated);
|
||||
let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);
|
||||
|
||||
mgr.push(active_msg);
|
||||
mgr.push(inactive_msg);
|
||||
|
||||
// 全量保留(两条都在内存)
|
||||
assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
|
||||
// token 预算只含 active
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
active_tokens,
|
||||
"history_tokens 应只含 active,多算了 {}(inactive 应被忽略)",
|
||||
mgr.history_tokens().saturating_sub(active_tokens)
|
||||
);
|
||||
assert!(
|
||||
inactive_tokens > 0,
|
||||
"前提:inactive 消息本身确有 token,否则无法证明它被排除"
|
||||
);
|
||||
|
||||
// 2) restore_from_messages 路径(调 push,token 同步仅 active)
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
let mut a = ChatMessage::user("active 一");
|
||||
a.status = Some(MessageStatus::Active);
|
||||
let mut b = ChatMessage::user("archived 一");
|
||||
b.status = Some(MessageStatus::ArchivedSegment);
|
||||
let mut c = ChatMessage::user("compressed 一");
|
||||
c.status = Some(MessageStatus::Compressed);
|
||||
mgr2.restore_from_messages(vec![a, b, c]);
|
||||
|
||||
assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
|
||||
// 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false)
|
||||
let only_active_tokens = TokenEstimator::default()
|
||||
.estimate_message(&ChatMessage::user("active 一"));
|
||||
assert_eq!(
|
||||
mgr2.history_tokens(),
|
||||
only_active_tokens,
|
||||
"restore 后 history_tokens 应只含 active 一条,archived/compressed 不计"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_returns_empty() {
|
||||
let mgr = ContextManager::new(cfg(100_000));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert!(msgs.is_empty(), "空历史应返回空列表");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_zone_returns_full_when_untrimmable() {
|
||||
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
|
||||
let mut mgr = ContextManager::new(cfg(10));
|
||||
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
|
||||
mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
|
||||
assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_over_budget_trims_to_protect_zone() {
|
||||
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
|
||||
}
|
||||
let (msgs, _trimmed) = mgr.build_for_request(195);
|
||||
assert!(
|
||||
msgs.len() <= PROTECT_COUNT,
|
||||
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
|
||||
PROTECT_COUNT,
|
||||
msgs.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-15 阶段1 辅助方法单测 ──
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_marks_compressed_and_returns_refs() {
|
||||
// F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed,
|
||||
// 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("旧消息1"));
|
||||
mgr.push(ChatMessage::assistant("旧回复1"));
|
||||
mgr.push(ChatMessage::user("新消息2"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
assert!(tokens_before > 0);
|
||||
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
|
||||
assert_eq!(compressed[0].content, "旧消息1");
|
||||
assert_eq!(compressed[1].content, "旧回复1");
|
||||
|
||||
// status 已改 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
// 保护区外(本例 index 2)仍 active
|
||||
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
|
||||
|
||||
// 持久化全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");
|
||||
|
||||
// token 已扣(剩第 3 条的)
|
||||
let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
|
||||
assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_is_idempotent() {
|
||||
// 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.push(ChatMessage::user("b"));
|
||||
|
||||
let first = mgr.compress_old_messages(2);
|
||||
assert_eq!(first.len(), 2);
|
||||
let tokens_after_first = mgr.history_tokens();
|
||||
|
||||
let second = mgr.compress_old_messages(2);
|
||||
assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_after_first,
|
||||
"二次压缩 history_tokens 不应再变(幂等)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_clamps_oversized_end() {
|
||||
// compress_end 越界自动 clamp 到 len,不 panic。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("唯一"));
|
||||
let compressed = mgr.compress_old_messages(999);
|
||||
assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
|
||||
assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_skips_already_inactive() {
|
||||
// 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let mut truncated = ChatMessage::user("被截断");
|
||||
truncated.status = Some(MessageStatus::Truncated);
|
||||
mgr.push(truncated);
|
||||
mgr.push(ChatMessage::user("active 一条"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
// truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条
|
||||
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
|
||||
assert_eq!(mgr.history_tokens(), 0);
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_before.saturating_sub(tokens_before),
|
||||
"幂等扣除:truncated 本就没计 token,active 扣光"
|
||||
);
|
||||
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
|
||||
assert_eq!(
|
||||
mgr.messages_mut()[0].message.status.as_ref(),
|
||||
Some(&MessageStatus::Truncated),
|
||||
"已 truncated 不应被改写为 compressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_compressible_messages_respects_protect_zone() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
for i in 0..8 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {}", i)));
|
||||
}
|
||||
// protect_start=6 → [0,6) 内有 active → true
|
||||
assert!(mgr.has_compressible_messages(6));
|
||||
// protect_start=0 → 空范围 → false
|
||||
assert!(!mgr.has_compressible_messages(0));
|
||||
// 全部压缩后 → false
|
||||
mgr.compress_old_messages(6);
|
||||
assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_compressing_flag_round_trip() {
|
||||
// 标志位读写 round-trip;clear() 复位。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
assert!(!mgr.is_compressing(), "默认 false");
|
||||
mgr.set_compressing(true);
|
||||
assert!(mgr.is_compressing(), "set true 后应读到 true");
|
||||
mgr.set_compressing(false);
|
||||
assert!(!mgr.is_compressing(), "set false 后复位");
|
||||
// clear 复位
|
||||
mgr.set_compressing(true);
|
||||
mgr.clear();
|
||||
assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_at_adds_to_budget_when_active() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
|
||||
// 插入 active system 消息 → 计入 token
|
||||
mgr.insert_at(0, ChatMessage::system("## 摘要"));
|
||||
assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
|
||||
assert_eq!(mgr.len(), 2);
|
||||
assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");
|
||||
|
||||
// 插入 !active 消息 → 不计入 token
|
||||
let tokens_before_inactive = mgr.history_tokens();
|
||||
let mut inactive = ChatMessage::user("x");
|
||||
inactive.status = Some(MessageStatus::Truncated);
|
||||
mgr.insert_at(0, inactive);
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_before_inactive,
|
||||
"!active 消息插入不应计 token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_eviction_units_keeps_triplet_atomic_public() {
|
||||
// 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("前置"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("c1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("c1", "结果"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
mgr.push(ChatMessage::user("后置"));
|
||||
// protect_start=5(全部纳入)
|
||||
let units = mgr.build_eviction_units(5);
|
||||
// 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
|
||||
// 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
|
||||
let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
|
||||
assert!(
|
||||
unit2.end >= 4,
|
||||
"三元组三件套应在同一淘汰单元, end={}",
|
||||
unit2.end
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_id_returns_latest() {
|
||||
// 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问1"));
|
||||
let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
|
||||
mgr.push(ChatMessage::user("问2"));
|
||||
let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
|
||||
// 末条 assistant id 应是 last(非 first)
|
||||
assert_eq!(
|
||||
mgr.last_assistant_message_id().as_deref(),
|
||||
Some(last.as_str()),
|
||||
"应取末条 assistant id, 而非首条"
|
||||
);
|
||||
assert_ne!(
|
||||
mgr.last_assistant_message_id().as_deref(),
|
||||
Some(first.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_user_message_id_returns_latest() {
|
||||
// 多条 user,反向扫描取末条 id(触发本轮检索的 user)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问1"));
|
||||
mgr.push(ChatMessage::assistant("答1"));
|
||||
let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
|
||||
assert_eq!(
|
||||
mgr.last_user_message_id().as_deref(),
|
||||
Some(last_user.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_message_id_none_when_no_such_role() {
|
||||
// 无 assistant → None;无 user → None
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("只有 user"));
|
||||
assert!(
|
||||
mgr.last_assistant_message_id().is_none(),
|
||||
"无 assistant 消息应返 None"
|
||||
);
|
||||
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
mgr2.push(ChatMessage::assistant("只有 assistant"));
|
||||
assert!(
|
||||
mgr2.last_user_message_id().is_none(),
|
||||
"无 user 消息应返 None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_message_id_none_for_legacy_no_id() {
|
||||
// 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let mut legacy = ChatMessage::assistant("老消息无 id");
|
||||
legacy.id = None;
|
||||
mgr.push(legacy);
|
||||
assert!(
|
||||
mgr.last_assistant_message_id().is_none(),
|
||||
"老消息无 id 应返 None(向前兼容)"
|
||||
);
|
||||
}
|
||||
|
||||
/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
|
||||
fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
|
||||
let id = message.id.clone();
|
||||
mgr.push(message);
|
||||
id.expect("新构造消息必有 id")
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
#[test]
|
||||
fn topic_marker_triggers_on_two_different_high_conf_topics() {
|
||||
// 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
|
||||
// "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码"));
|
||||
// 此时只有一条 user,无 marker
|
||||
assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
let marker = mgr.take_topic_marker()
|
||||
.expect("双不同 topic 应置位 marker");
|
||||
assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
|
||||
assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
|
||||
// take 后清空(幂等)
|
||||
assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_on_same_topic() {
|
||||
// 同 topic(code/code)→ 不置位(非切换)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码"));
|
||||
mgr.push(ChatMessage::user("再重构另一段代码"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_when_either_topic_none() {
|
||||
// 保守:任一 topic 为 None(低置信未标)不置位。
|
||||
// "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("今天的天气不错啊"));
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");
|
||||
|
||||
// 反向:前一条高置信 + 后一条低置信
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
mgr2.push(ChatMessage::user("帮我重构这段代码"));
|
||||
mgr2.push(ChatMessage::user("嗯嗯好的"));
|
||||
assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_polluting_compression_or_trim() {
|
||||
// topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
|
||||
// topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
|
||||
let mut mgr = ContextManager::new(cfg(80));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
|
||||
let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
|
||||
// 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed);
|
||||
assert!(!msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_field_default_none_in_constructors() {
|
||||
// TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
|
||||
// insert_at 始终 None——insert_at 不做主题推断,保守)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.insert_at(0, ChatMessage::system("摘要"));
|
||||
// insert_at 的 system 消息 topic=None(role 非 User)
|
||||
assert_eq!(mgr.messages_mut()[0].topic, None);
|
||||
}
|
||||
|
||||
// ===== 苁刻测:主题切换检测对抗/边界/链式 =====
|
||||
|
||||
#[test]
|
||||
fn topic_marker_chained_three_switches_each_triggers() {
|
||||
// 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
|
||||
// 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
|
||||
// 1. Code
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
|
||||
assert!(mgr.take_topic_marker().is_none(), "首条不触发");
|
||||
|
||||
// 2. Code → Project(切换)
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
|
||||
let m1 = mgr.take_topic_marker().expect("code→project 应触发");
|
||||
assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
|
||||
assert!(mgr.take_topic_marker().is_none(), "take 后清空");
|
||||
|
||||
// 3. Project → Task(切换)
|
||||
mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
|
||||
let m2 = mgr.take_topic_marker().expect("project→task 应触发");
|
||||
assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);
|
||||
|
||||
// 4. Task → Http(切换,证链式不因中间穿插断)
|
||||
mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
|
||||
let m3 = mgr.take_topic_marker().expect("task→http 应触发");
|
||||
assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_low_confidence_chain_never_triggers() {
|
||||
// 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
|
||||
assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
|
||||
// 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"前一条 topic None,即使本条高置信也不触发(双高置信约束)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_high_then_low_confidence_never_triggers() {
|
||||
// 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"后一条 topic None,即使前一条高置信也不触发"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
|
||||
// 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
|
||||
// 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
|
||||
let _ = mgr.take_topic_marker();
|
||||
mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
|
||||
let m = mgr.take_topic_marker().expect("project→code 切回应触发");
|
||||
assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_field_survives_compress_old_messages() {
|
||||
// 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
|
||||
// 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
|
||||
mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
|
||||
mgr.push(ChatMessage::assistant("ok")); // idx 2
|
||||
|
||||
// 压缩 [0,2):idx 0/1 标 compressed
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2);
|
||||
// topic 字段保留(compress 不触碰)
|
||||
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
|
||||
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
|
||||
// status 改为 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
|
||||
// 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
|
||||
// build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
|
||||
// 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
|
||||
// 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
|
||||
// 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
|
||||
let marker = mgr.take_topic_marker().expect("应触发 marker");
|
||||
assert!(marker.contains("code") && marker.contains("project"));
|
||||
|
||||
// 调用方据 marker insert system 标记(agentic loop 实际行为)
|
||||
mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "充足预算不应裁剪");
|
||||
assert!(
|
||||
msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
|
||||
"marker system 应进发送视图: {:?}",
|
||||
msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
|
||||
// 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
// assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
|
||||
mgr.push(ChatMessage::assistant("编译 error 出现了"));
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"assistant 消息不参与主题推断,不触发 marker"
|
||||
);
|
||||
// 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
|
||||
mgr.push(ChatMessage::user("继续重构"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
|
||||
// 此时末条 user 是 code,切到 project 应触发
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_take_is_idempotent_and_clears() {
|
||||
// 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码"));
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
let _ = mgr.take_topic_marker().expect("应有 marker");
|
||||
// 第二次 take 必 None
|
||||
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
|
||||
}
|
||||
538
crates/df-ai/src/context/mod.rs
Normal file
538
crates/df-ai/src/context/mod.rs
Normal file
@@ -0,0 +1,538 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 维护消息历史及其 token 计数缓存
|
||||
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
|
||||
//! - 为 run_agentic_loop 提供受控的消息视图
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
//!
|
||||
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
|
||||
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
|
||||
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//!
|
||||
//! # 子模块
|
||||
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
|
||||
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
|
||||
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
|
||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||
|
||||
mod sanitize;
|
||||
|
||||
use crate::context_helpers::{
|
||||
classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
pub use crate::context_helpers::{
|
||||
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
|
||||
};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
|
||||
/// 上下文管理器
|
||||
///
|
||||
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
|
||||
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
|
||||
pub struct ContextManager {
|
||||
messages: Vec<TrackedMessage>,
|
||||
/// 当前历史总 token 数(不含 system prompt)
|
||||
history_tokens: u32,
|
||||
config: ContextConfig,
|
||||
estimator: TokenEstimator,
|
||||
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
|
||||
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
|
||||
is_compressing: bool,
|
||||
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
|
||||
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||
pending_topic_marker: Option<String>,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
history_tokens: 0,
|
||||
config,
|
||||
estimator: TokenEstimator::default(),
|
||||
is_compressing: false,
|
||||
pending_topic_marker: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加消息(自动计算 token 并更新缓存)
|
||||
///
|
||||
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
|
||||
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
|
||||
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
|
||||
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
|
||||
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
|
||||
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
|
||||
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
|
||||
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
|
||||
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
|
||||
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
|
||||
Some(intent.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
|
||||
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
|
||||
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
|
||||
if matches!(message.role, MessageRole::User) {
|
||||
if let Some(prev_topic) = self.last_user_topic() {
|
||||
if let Some(this_topic) = &topic {
|
||||
if prev_topic != *this_topic {
|
||||
self.pending_topic_marker =
|
||||
Some(format!("{}|{}", prev_topic, this_topic));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.messages.push(TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
/// 清空所有消息
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.history_tokens = 0;
|
||||
self.is_compressing = false;
|
||||
self.pending_topic_marker = None;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// 当前历史占用的 token 数(不含 system prompt)
|
||||
pub fn history_tokens(&self) -> u32 {
|
||||
self.history_tokens
|
||||
}
|
||||
|
||||
/// 预算上限
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
self.config.budget_limit()
|
||||
}
|
||||
|
||||
// ── 核心:构建请求消息(受控裁剪版本)──
|
||||
|
||||
/// 构建发送给 LLM 的消息列表
|
||||
///
|
||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
|
||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
let budget = self.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens);
|
||||
|
||||
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
|
||||
if sys_tokens > budget {
|
||||
tracing::warn!(
|
||||
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
|
||||
sys_tokens, budget
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in &units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||
|
||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||
///
|
||||
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
|
||||
/// `ContextManager::sanitize_messages`),实现见子模块文档。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitize::sanitize_messages(messages)
|
||||
}
|
||||
|
||||
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
sanitize::assert_placeholder_pairing(messages, enabled)
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
|
||||
self.messages.iter().map(|t| t.message.clone()).collect()
|
||||
}
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
/// 返回 true 如果找到并替换了
|
||||
///
|
||||
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
|
||||
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
|
||||
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::Tool)
|
||||
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
|
||||
});
|
||||
|
||||
let Some(i) = pos else { return false };
|
||||
|
||||
// 先更新 content,再重估 token 并校正总量
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
true
|
||||
}
|
||||
|
||||
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
|
||||
///
|
||||
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
|
||||
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
|
||||
///
|
||||
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
|
||||
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
|
||||
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
|
||||
pub fn pop_last_assistant_round(&mut self) -> bool {
|
||||
if self.messages.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut popped_any = false;
|
||||
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
|
||||
while let Some(last) = self.messages.last() {
|
||||
if matches!(last.message.role, MessageRole::User) {
|
||||
break;
|
||||
}
|
||||
let removed = self.messages.pop().expect("just checked non-empty");
|
||||
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
|
||||
if matches!(removed.message.role, MessageRole::Assistant) {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||
///
|
||||
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||
/// 找不到返回 Err(()),调用方据此报错。
|
||||
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
// 反向找末条 active user 消息且 content 匹配
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User)
|
||||
&& t.message.content == target_content
|
||||
&& t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||
let mut count = 0usize;
|
||||
for t in self.messages[i + 1..].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some(MessageStatus::Truncated);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
|
||||
///
|
||||
/// 用途:
|
||||
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
|
||||
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
|
||||
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
|
||||
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| {
|
||||
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
|
||||
})
|
||||
.and_then(|t| t.message.id.clone())
|
||||
}
|
||||
|
||||
/// 末条 assistant 消息的 id(消息级溯源用)。
|
||||
pub fn last_assistant_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::Assistant)
|
||||
}
|
||||
|
||||
/// 末条 user 消息的 id(消息级溯源用)。
|
||||
pub fn last_user_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::User)
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
|
||||
///
|
||||
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
|
||||
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
|
||||
fn last_user_topic(&self) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| matches!(t.message.role, MessageRole::User))
|
||||
.and_then(|t| t.topic.clone())
|
||||
}
|
||||
|
||||
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
|
||||
///
|
||||
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
|
||||
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
|
||||
pub fn take_topic_marker(&mut self) -> Option<String> {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
|
||||
&mut self.messages
|
||||
}
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
|
||||
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
self.messages.insert(index, TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
|
||||
///
|
||||
/// 返回每个单元的右开区间 end + token 总和,保证:
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < protect_start {
|
||||
let mut token_sum = 0u32;
|
||||
|
||||
if self.messages[i].group == MessageGroup::ToolCallHead {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
if i < protect_start
|
||||
&& self.messages[i].group == MessageGroup::Standalone
|
||||
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
|
||||
{
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
units.push(EvictionUnit { end: i, token_sum });
|
||||
}
|
||||
|
||||
units
|
||||
}
|
||||
|
||||
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
|
||||
///
|
||||
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
self.messages[..end]
|
||||
.iter()
|
||||
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
///
|
||||
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||
let end = compress_end.min(self.messages.len());
|
||||
let mut newly_compressed = Vec::new();
|
||||
for t in self.messages[..end].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
newly_compressed.push(t.message.clone());
|
||||
t.message.status = Some(MessageStatus::Compressed);
|
||||
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
|
||||
}
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||
pub fn is_compressing(&self) -> bool {
|
||||
self.is_compressing
|
||||
}
|
||||
|
||||
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
|
||||
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
|
||||
pub fn set_compressing(&mut self, v: bool) {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_tests;
|
||||
761
crates/df-ai/src/context/sanitize.rs
Normal file
761
crates/df-ai/src/context/sanitize.rs
Normal file
@@ -0,0 +1,761 @@
|
||||
//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。
|
||||
//!
|
||||
//! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中
|
||||
//! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages`
|
||||
//! 等历史调用路径不变)。
|
||||
//!
|
||||
//! # 函数概览
|
||||
//! - [`sanitize_messages`]:主入口(step0 active 过滤 → step1/2/3 正向 orphan →
|
||||
//! step3.5 反向 orphan → step4 序列合法性)。
|
||||
//! - [`drop_reverse_orphans`]:step3.5 反向 orphan 检测。
|
||||
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
|
||||
//! - [`ensure_sequence_legal`]:step4 序列合法性修复(首条 user + 连续同 role 合并)。
|
||||
|
||||
use crate::context_helpers::{is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX};
|
||||
use crate::provider::{ChatMessage, MessageRole, ToolCall};
|
||||
|
||||
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
|
||||
///
|
||||
/// 根因:Anthropic 流式 `tool_use` 块缺 id 时(anthropic_compat.rs 170-176)
|
||||
/// 生成 `tool_missing_{idx}` 占位 id;对应的 `tool_result` 一旦推入历史,
|
||||
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
|
||||
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
|
||||
/// 不改持久化(self.messages 全量保留),让卡死的会话能自愈继续。
|
||||
///
|
||||
/// provider 协议铁律:assistant 头里每个 tool_call.id 都必须在后续有对应的
|
||||
/// tool_result,否则服务端 400/500。本函数按此自愈,分三档处理每个头:
|
||||
///
|
||||
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
|
||||
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
|
||||
/// - 部分闭合:保留头但只留已闭合的 tool_call,丢弃未闭合的 tool_call 及其
|
||||
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
|
||||
///
|
||||
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
|
||||
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
|
||||
let messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| m.is_active())
|
||||
.collect();
|
||||
|
||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
||||
let resolved_ids: HashSet<&str> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.as_deref())
|
||||
.collect();
|
||||
|
||||
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
|
||||
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
|
||||
// partial_heads:部分闭合的头——保留但需重写 tool_calls(只留已闭合的)
|
||||
// full_drop_heads:全未闭合的头——整条丢弃
|
||||
let mut orphaned_ids: HashSet<String> = HashSet::new();
|
||||
let mut full_drop_heads = 0u32;
|
||||
let mut partial_heads = 0u32;
|
||||
// 记录部分闭合头中「应保留的 id」,用于 step3 精确重写
|
||||
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(calls) = m.tool_calls.as_ref() else { continue };
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
|
||||
.iter()
|
||||
.partition(|c| {
|
||||
!c.id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& resolved_ids.contains(c.id.as_str())
|
||||
});
|
||||
|
||||
match (resolved.is_empty(), unresolved.is_empty()) {
|
||||
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
|
||||
(true, false) => {
|
||||
full_drop_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
}
|
||||
// 全闭合 → 原样保留
|
||||
(false, true) => {}
|
||||
// 部分闭合 → 保留头,只留 resolved 的 tool_call,unresolved 入 orphaned_ids
|
||||
(false, false) => {
|
||||
partial_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
partial_keep.insert(i, resolved.into_iter().cloned().collect());
|
||||
}
|
||||
// resolved/unresolved 都空不可能(calls 非空已保证)
|
||||
(true, true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
|
||||
messages
|
||||
} else {
|
||||
// step 3:保序过滤 + 部分闭合头重写 tool_calls
|
||||
let sanitized: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, mut m)| match m.role {
|
||||
MessageRole::Assistant => {
|
||||
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
|
||||
if calls.is_empty() {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(keep) = partial_keep.get(&i) {
|
||||
// 部分闭合:重写 tool_calls 为仅已闭合子集
|
||||
m.tool_calls = Some(keep.clone());
|
||||
Some(m)
|
||||
} else {
|
||||
// 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃
|
||||
let all_orphaned =
|
||||
calls.iter().all(|c| orphaned_ids.contains(&c.id));
|
||||
if all_orphaned {
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageRole::Tool => {
|
||||
// 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留
|
||||
if m
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| orphaned_ids.contains(id))
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
_ => Some(m),
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::warn!(
|
||||
full_drop_heads,
|
||||
partial_rewrite_heads = partial_heads,
|
||||
orphaned_tool_results = orphaned_ids.len(),
|
||||
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
|
||||
);
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||
//
|
||||
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||
//
|
||||
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
|
||||
drop_reverse_orphans(after_triplet)
|
||||
} else {
|
||||
after_triplet
|
||||
};
|
||||
|
||||
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
|
||||
ensure_sequence_legal(after_reverse_orphan)
|
||||
}
|
||||
|
||||
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||
///
|
||||
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut dropped = 0u32;
|
||||
let mut placeholder_kept = 0u32;
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
if !matches!(m.role, MessageRole::Tool) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = m.tool_call_id.as_deref() else {
|
||||
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||
};
|
||||
if head_ids.contains(id) {
|
||||
return true; // 有对应头 → 保留
|
||||
}
|
||||
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||
if is_pending_placeholder(&m.content) {
|
||||
placeholder_kept += 1;
|
||||
true
|
||||
} else {
|
||||
dropped += 1;
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if dropped > 0 || placeholder_kept > 0 {
|
||||
tracing::warn!(
|
||||
dropped_reverse_orphan_tool_results = dropped,
|
||||
placeholder_kept_without_head = placeholder_kept,
|
||||
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||
);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
|
||||
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
|
||||
///
|
||||
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
|
||||
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
|
||||
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
|
||||
///
|
||||
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
|
||||
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
|
||||
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
|
||||
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
|
||||
///
|
||||
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
|
||||
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
|
||||
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
|
||||
///
|
||||
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !enabled {
|
||||
return messages;
|
||||
}
|
||||
// 收集现有头的 tool_call.id(已配对的)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
|
||||
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
|
||||
let mut healed = 0u32;
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages.into_iter() {
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
if let Some(id) = m.tool_call_id.as_deref() {
|
||||
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
|
||||
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
|
||||
let head = ChatMessage::assistant_with_tools(
|
||||
String::new(),
|
||||
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
|
||||
);
|
||||
out.push(head);
|
||||
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
|
||||
let mut result = m;
|
||||
result.tool_call_id = Some(head_id);
|
||||
out.push(result);
|
||||
healed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if healed > 0 {
|
||||
tracing::warn!(
|
||||
healed_orphan_tool_results = healed,
|
||||
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
|
||||
);
|
||||
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
|
||||
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
|
||||
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
|
||||
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
|
||||
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
|
||||
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
|
||||
//
|
||||
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
|
||||
return ensure_sequence_legal(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
|
||||
///
|
||||
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
|
||||
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
|
||||
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
|
||||
///
|
||||
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
|
||||
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
|
||||
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
|
||||
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
||||
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
let mut skipped = 0u32;
|
||||
let mut merged = 0u32;
|
||||
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages {
|
||||
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
|
||||
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
|
||||
if let Some(last) = fixed.last_mut() {
|
||||
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
|
||||
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
|
||||
if !m.content.is_empty() {
|
||||
if !last.content.is_empty() {
|
||||
last.content.push('\n');
|
||||
}
|
||||
last.content.push_str(&m.content);
|
||||
}
|
||||
if matches!(m.role, MessageRole::Assistant) {
|
||||
if let Some(calls) = m.tool_calls {
|
||||
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
|
||||
}
|
||||
}
|
||||
merged += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fixed.push(m);
|
||||
}
|
||||
if skipped > 0 || merged > 0 {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
merged,
|
||||
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||
);
|
||||
}
|
||||
fixed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::ContextConfig;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_placeholder_id_triplet() {
|
||||
// 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result
|
||||
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("调用工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
|
||||
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
|
||||
assert_eq!(msgs[0].content, "调用工具");
|
||||
// 持久化全量保留(自愈只改发送视图)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_unresolved_tool_call_head() {
|
||||
// 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 两个 call 都无 result → 整头丢弃
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|
||||
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||
"全未闭合的 assistant 头应被剔除");
|
||||
assert!(msgs.is_empty(), "全未闭合应整条删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_partial_resolve_rewrites_head() {
|
||||
// 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
|
||||
// provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。
|
||||
// 「不能整条删」——保住已发生的合法工具交互(call_b)历史。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("触发工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 只有 call_b 有 result → call_a 未闭合
|
||||
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
|
||||
let assistant_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.collect();
|
||||
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
|
||||
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
|
||||
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
|
||||
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
|
||||
assert!(
|
||||
head_calls.iter().all(|c| c.id != "call_a"),
|
||||
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
|
||||
);
|
||||
|
||||
// tool_result 只剩 call_b 的,user 前置保留
|
||||
let tool_msgs: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
|
||||
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
|
||||
"无关 user 消息不应被误删"
|
||||
);
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_well_formed_triplet() {
|
||||
// 正常三元组:user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
|
||||
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_a", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
assert!(
|
||||
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("call_pending_1", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
let kept: Vec<_> = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
|
||||
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
|
||||
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
|
||||
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
|
||||
let healed_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.collect();
|
||||
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
|
||||
// 占位头 id 应含原 tool_call_id(精确配对)
|
||||
assert!(
|
||||
healed_heads[0].id.contains("call_pending_2"),
|
||||
"补的头 id 应含原 tool_call_id, 实际 {}",
|
||||
healed_heads[0].id
|
||||
);
|
||||
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
|
||||
let result_id = msgs
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
|
||||
// 持久化全量保留(view-only)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
|
||||
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_ok", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 无 tool_missing_ 头(正常三元组不需自愈)
|
||||
let missing_heads = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.count();
|
||||
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
|
||||
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||
//
|
||||
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||
//
|
||||
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs.clone());
|
||||
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||
assert!(sanitized.iter().any(|m|
|
||||
matches!(m.role, MessageRole::Tool)
|
||||
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||
), "占位 tool_result 应保留");
|
||||
|
||||
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||
|
||||
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||
let missing_heads: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing_heads.len(),
|
||||
1,
|
||||
"应补 1 个占位头, 实际 {:?}",
|
||||
missing_heads
|
||||
);
|
||||
|
||||
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
|
||||
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
|
||||
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
|
||||
let mut prev_is_assistant = false;
|
||||
let mut consecutive = 0u32;
|
||||
for m in &healed {
|
||||
let is_assistant = matches!(m.role, MessageRole::Assistant);
|
||||
if is_assistant && prev_is_assistant {
|
||||
consecutive += 1;
|
||||
}
|
||||
prev_is_assistant = is_assistant;
|
||||
}
|
||||
assert_eq!(
|
||||
consecutive, 0,
|
||||
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
|
||||
healed
|
||||
.iter()
|
||||
.map(|m| format!("{:?}", m.role))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
|
||||
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
|
||||
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
|
||||
let assistant_msgs: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
1,
|
||||
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
|
||||
assistant_msgs.len(),
|
||||
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
|
||||
let merged = &assistant_msgs[0];
|
||||
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
|
||||
let merged_call_ids: Vec<&str> = merged
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& id.contains("call_pending_cc")),
|
||||
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
|
||||
merged_call_ids
|
||||
);
|
||||
|
||||
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
|
||||
let result_id = healed
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert!(
|
||||
merged_call_ids.contains(&result_id.as_str()),
|
||||
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
|
||||
result_id,
|
||||
merged_call_ids
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
|
||||
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
let kept = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_mixed_multi_message_sequence() {
|
||||
// 场景 4:连续多条消息混合——
|
||||
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
|
||||
// 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("开场"));
|
||||
// 全闭合三元组
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调1",
|
||||
vec![ToolCall::new("ok_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
|
||||
// 中毒占位 id 三元组(全未闭合)→ 整删
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调2",
|
||||
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
|
||||
// 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调3",
|
||||
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
|
||||
mgr.push(ChatMessage::user("收尾"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
|
||||
let head_ids: Vec<Vec<String>> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.map(|m| {
|
||||
m.tool_calls
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id.clone())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
head_ids.iter().all(|ids| {
|
||||
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
|
||||
}),
|
||||
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
|
||||
);
|
||||
let tool_ids: Vec<String> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.clone())
|
||||
.collect();
|
||||
// ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在
|
||||
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
|
||||
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
|
||||
assert!(
|
||||
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
|
||||
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
|
||||
);
|
||||
// 两条 user 消息原样保留
|
||||
let user_contents: Vec<&str> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.map(|m| m.content.as_str())
|
||||
.collect();
|
||||
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,14 @@ pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
|
||||
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
|
||||
/// extract_key_info 保留的尾部行数。
|
||||
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
||||
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
||||
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
||||
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
|
||||
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
|
||||
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
||||
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||||
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||||
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
||||
|
||||
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||||
///
|
||||
@@ -374,6 +382,54 @@ pub fn should_summarize_tool_result(
|
||||
///
|
||||
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
let mut truncated = false;
|
||||
for (_key, field) in obj.iter_mut() {
|
||||
// 数组截断
|
||||
if let Some(arr) = field.as_array() {
|
||||
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
||||
*field = serde_json::Value::Array(
|
||||
arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect()
|
||||
);
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
// 字符串字段:先按行数截断,若不足再按字符数截断
|
||||
if let Some(s) = field.as_str() {
|
||||
let lines: Vec<&str> = s.lines().collect();
|
||||
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if lines.len() > kept {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
|
||||
out.push("... (压缩中间内容) ...");
|
||||
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
|
||||
*field = serde_json::Value::String(out.join("\n"));
|
||||
truncated = true;
|
||||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||||
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||||
*field = serde_json::Value::String(format!(
|
||||
"{}...(截断,原始 {} 字符)...{}",
|
||||
head, s.chars().count(), tail
|
||||
));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
||||
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
||||
}
|
||||
}
|
||||
// JSON 解析成功但无需截断 → 原样返回
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// 非 JSON 纯文本:按行数截断
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
@@ -383,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let total = lines.len();
|
||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if total <= kept_boundary {
|
||||
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 按字符数截断保证压缩至少生效。
|
||||
let char_count = content.chars().count();
|
||||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
|
||||
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
|
||||
return format!(
|
||||
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
|
||||
tool_name, char_count, head, tail
|
||||
);
|
||||
}
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
@@ -877,18 +944,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_line_no_newline_unchanged() {
|
||||
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
|
||||
fn extract_key_info_single_line_short_no_newline_unchanged() {
|
||||
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
|
||||
let content = "single line no newline";
|
||||
assert_eq!(extract_key_info(content, "read_file"), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_huge_line_no_newline_unchanged() {
|
||||
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
|
||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
let content = "x".repeat(50_000);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
|
||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("已压缩"), "应含压缩标记");
|
||||
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
|
||||
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_json_huge_string_field_truncated() {
|
||||
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||
let large = "z".repeat(10_000);
|
||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||||
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
542
crates/df-ai/src/git_worktree.rs
Normal file
542
crates/df-ai/src/git_worktree.rs
Normal file
@@ -0,0 +1,542 @@
|
||||
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
|
||||
//!
|
||||
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
|
||||
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// worktree 管理错误
|
||||
#[derive(Debug)]
|
||||
pub enum WorktreeError {
|
||||
/// Git 命令执行失败(stdout/stderr 含错误信息)
|
||||
GitFailed(String),
|
||||
/// 工程根目录无 .git(非 Git 工程,应降级串行)
|
||||
NotGitRepo,
|
||||
/// worktree 目录已存在(可能上次未清理)
|
||||
AlreadyExists(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorktreeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
|
||||
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
|
||||
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorktreeError {}
|
||||
|
||||
/// worktree 创建结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeHandle {
|
||||
/// worktree 物理路径(SubTask 工具操作的 cwd)
|
||||
pub path: PathBuf,
|
||||
/// worktree 对应的分支名(merge 时用)
|
||||
pub branch: String,
|
||||
/// 所属 Plan id(清理时按 plan 批量)
|
||||
pub plan_id: String,
|
||||
/// SubTask id
|
||||
pub subtask_id: String,
|
||||
}
|
||||
|
||||
/// 在指定工程根目录下创建 SubTask 专属 worktree。
|
||||
///
|
||||
/// - `project_root`: 工程根目录(含 .git)
|
||||
/// - `plan_id`: 所属 Plan id
|
||||
/// - `subtask_id`: SubTask id
|
||||
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
|
||||
///
|
||||
/// 返回 WorktreeHandle(path + branch)。
|
||||
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
|
||||
/// 分支名:`subtask/{plan_id}/{subtask_id}`
|
||||
pub fn create_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
subtask_id: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<WorktreeHandle, WorktreeError> {
|
||||
if !project_root.join(".git").exists() {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
|
||||
if wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(wt_path));
|
||||
}
|
||||
|
||||
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
|
||||
|
||||
// git worktree add <path> -b <branch> <base_branch>
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&branch,
|
||||
base_branch,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add 失败: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(WorktreeHandle {
|
||||
path: wt_path,
|
||||
branch,
|
||||
plan_id: plan_id.to_string(),
|
||||
subtask_id: subtask_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 在 worktree 内提交所有改动(git add -A + git commit)。
|
||||
///
|
||||
/// - `wt_path`: worktree 物理路径
|
||||
/// - `message`: commit 消息
|
||||
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
|
||||
// git add -A
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
|
||||
if !add.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git add 失败: {}",
|
||||
String::from_utf8_lossy(&add.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
|
||||
if !commit.status.success() {
|
||||
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
if stderr.contains("user.email") || stderr.contains("user.name") {
|
||||
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.email", "devflow@ai.local"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.name", "DevFlow Agent"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
let retry = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
|
||||
if !retry.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git commit 重试仍失败: {}",
|
||||
String::from_utf8_lossy(&retry.stderr).trim()
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
|
||||
///
|
||||
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
|
||||
/// 返回 Err 表示 merge 冲突。
|
||||
pub fn merge_branch(
|
||||
plan_wt_path: &Path,
|
||||
subtask_branch: &str,
|
||||
) -> Result<MergeResult, WorktreeError> {
|
||||
// 用旧版兼容的 merge-tree(base branch → 输出冲突树)
|
||||
// --write-tree 需 Git 2.38+,改用三参数 merge-tree <base> <branch1> <branch2>
|
||||
// 成功(exit 0)= 无冲突可自动合并;非0且有输出 = 冲突
|
||||
let base = "HEAD";
|
||||
let precheck = Command::new("git")
|
||||
.args(["merge-tree", base, subtask_branch, base])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
|
||||
|
||||
// merge-tree 输出含 conflict marker(<<<<<<<)时表示有冲突
|
||||
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
|
||||
if precheck_stdout.contains("<<<<<<<") || precheck_stdout.contains("=======") {
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: precheck_stdout.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 无冲突,执行实际 merge
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--no-edit", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
|
||||
|
||||
if !merge.status.success() {
|
||||
// merge 冲突(git merge 返回非0)
|
||||
let stderr = String::from_utf8_lossy(&merge.stderr);
|
||||
let stdout = String::from_utf8_lossy(&merge.stdout);
|
||||
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
|
||||
let _ = Command::new("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(plan_wt_path)
|
||||
.output();
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: format!("{}\n{}", stdout, stderr),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MergeResult::Clean)
|
||||
}
|
||||
|
||||
/// merge 结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeResult {
|
||||
/// 干净合并(无冲突)
|
||||
Clean,
|
||||
/// 合并冲突(details 含 conflict 信息)
|
||||
Conflict { branch: String, details: String },
|
||||
}
|
||||
|
||||
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
|
||||
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
|
||||
// git worktree remove --force <path>
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
handle.path.to_str().unwrap_or(""),
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", &handle.branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
|
||||
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
|
||||
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
|
||||
if !plan_wt_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// git worktree list 获取该 plan 下所有 worktree
|
||||
let list = Command::new("git")
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
|
||||
|
||||
let list_str = String::from_utf8_lossy(&list.stdout);
|
||||
for line in list_str.lines() {
|
||||
if let Some(path_str) = line.strip_prefix("worktree ") {
|
||||
let path = PathBuf::from(path_str);
|
||||
// 仅清理属于本 plan 的 worktree
|
||||
if path.starts_with(&plan_wt_dir) {
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", path_str])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该 plan 的所有子任务分支
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
|
||||
|
||||
let branches_str = String::from_utf8_lossy(&branches.stdout);
|
||||
for line in branches_str.lines() {
|
||||
let branch = line.trim();
|
||||
if !branch.is_empty() {
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// 清理空目录
|
||||
let _ = std::fs::remove_dir_all(&plan_wt_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测工程是否为 Git 仓库(有 .git 目录)。
|
||||
pub fn is_git_repo(project_root: &Path) -> bool {
|
||||
project_root.join(".git").exists()
|
||||
}
|
||||
|
||||
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
|
||||
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
|
||||
///
|
||||
/// 返回 (plan_worktree_path, plan_branch)。
|
||||
pub fn create_plan_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
) -> Result<(PathBuf, String), WorktreeError> {
|
||||
if !is_git_repo(project_root) {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let base = current_branch(project_root)?;
|
||||
let plan_branch = format!("plan/{}", plan_id);
|
||||
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
|
||||
|
||||
if plan_wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(plan_wt_path));
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
plan_wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&plan_branch,
|
||||
&base,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add(plan) 失败: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((plan_wt_path, plan_branch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 创建临时 Git 仓库 + 初始 commit
|
||||
fn setup_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
|
||||
|
||||
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_01_create_and_isolate() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
let base = current_branch(root).unwrap();
|
||||
|
||||
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
|
||||
assert!(handle.path.exists(), "worktree 目录应存在");
|
||||
assert_eq!(handle.branch, "subtask/plan-test/A");
|
||||
|
||||
// 在 worktree 内写文件
|
||||
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有此文件
|
||||
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
|
||||
|
||||
// 清理
|
||||
remove_worktree(root, &handle).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_02_parallel_isolation() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
|
||||
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
|
||||
|
||||
// A 写 new_a.rs
|
||||
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
|
||||
// B 写 new_b.rs
|
||||
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
|
||||
|
||||
// A 的 worktree 不应有 B 的文件
|
||||
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
|
||||
// B 的 worktree 不应有 A 的文件
|
||||
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-iso").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_03_commit_in_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
|
||||
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
|
||||
|
||||
// 写文件 + commit
|
||||
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
|
||||
commit_worktree(&a.path, "Add feature").unwrap();
|
||||
|
||||
// 验证分支有新 commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(&a.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
|
||||
|
||||
remove_plan_worktrees(root, "plan-commit").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_04_merge_no_conflict() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
|
||||
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
|
||||
commit_worktree(&a.path, "A changes").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
|
||||
commit_worktree(&b.path, "B changes").unwrap();
|
||||
|
||||
// merge A → plan
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
|
||||
|
||||
// merge B → plan
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
|
||||
|
||||
// plan worktree 应同时有 file_a.rs 和 file_b.rs
|
||||
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
|
||||
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-merge").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_05_merge_conflict_same_file() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 写一个共享文件
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件的同一行
|
||||
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
// merge A → 无冲突
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
|
||||
|
||||
// merge B → 冲突
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(
|
||||
matches!(result_b, MergeResult::Conflict { .. }),
|
||||
"同文件同行改应产生冲突"
|
||||
);
|
||||
|
||||
remove_plan_worktrees(root, "plan-conf").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_06_non_git_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 不 init git
|
||||
let result = create_plan_worktree(dir.path(), "plan-ng");
|
||||
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_07_cleanup_removes_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
|
||||
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
|
||||
assert!(a.path.exists());
|
||||
|
||||
remove_plan_worktrees(root, "plan-clean").unwrap();
|
||||
|
||||
// worktree 目录应被清理
|
||||
assert!(!a.path.exists(), "worktree 目录应已清理");
|
||||
// plan wt 目录也应被清理
|
||||
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,73 @@ impl ToolDomain {
|
||||
|
||||
// ---- 识别器 -----------------------------------------------------------------
|
||||
|
||||
/// 意图识别 L0 去噪:剥离 mention 结构化前缀(`[项目: DevFlow]` / `[任务: xxx]` 等)。
|
||||
///
|
||||
/// mention 前缀是系统注入的结构标签,语义已由 augmentation @ 通道单独 resolve
|
||||
/// 注入 system prompt;content 里残留的前缀只污染关键词识别——实测会话
|
||||
/// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`,
|
||||
/// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见,agent 被迫反复 `list_tasks`。
|
||||
///
|
||||
/// 剥离规则:扫描 `[`,若紧跟已知 mention kind(项目/任务/想法/技能,
|
||||
/// project/task/idea/skill,大小写不敏感)+ 冒号(全/半角 `:`/`:`),则连同到
|
||||
/// 首个 `]` 整段移除;未闭合或非已知 kind 的 `[...]` 原样保留(代码片段/错误码)。
|
||||
///
|
||||
/// kind 表与 `MentionRef`(project/task/idea/skill)对齐——单一真相源,新增 mention
|
||||
/// kind 时此处同步。不折叠空白:关键词 `contains` 匹配对空白不敏感,剥离即可。
|
||||
fn strip_mention_tags(message: &str) -> String {
|
||||
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
|
||||
let chars: Vec<char> = message.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = String::with_capacity(message.len());
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if chars[i] == '[' {
|
||||
if let Some(close) = try_match_mention(&chars, i, KINDS) {
|
||||
i = close + 1; // 跳过整段 mention(含 `]`)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 探测 `chars[open] == '['` 起的 mention 段:`[kind: ...]`。
|
||||
///
|
||||
/// 返回匹配 `]` 的索引(`open` 指向 `[`)。kind 大小写不敏感(ASCII)+ 中文直比;
|
||||
/// 冒号接受全角 `:`/半角 `:`;kind 后允许空白;未闭合 `]` 返 `None`(不当 mention,保留原样)。
|
||||
fn try_match_mention(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||
let after = open + 1;
|
||||
for k in kinds {
|
||||
let kc: Vec<char> = k.chars().collect();
|
||||
if chars.len() < after + kc.len() {
|
||||
continue;
|
||||
}
|
||||
let slice = &chars[after..after + kc.len()];
|
||||
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
|
||||
continue;
|
||||
}
|
||||
let mut j = after + kc.len();
|
||||
while j < chars.len() && chars[j].is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') {
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while j < chars.len() {
|
||||
if chars[j] == ']' {
|
||||
return Some(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
// 未闭合 `]` → 不当 mention,保留原样
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntentRecognizer;
|
||||
@@ -299,14 +366,16 @@ impl IntentRecognizer {
|
||||
///
|
||||
/// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。
|
||||
pub fn recognize(message: &str) -> (Intent, f32) {
|
||||
// L0 去噪:先剥离 mention 结构标签(防 `[项目: x]` 把代码任务误判 Project)。
|
||||
let cleaned = strip_mention_tags(message);
|
||||
// 依次按优先级组求胜者。高分组有命中即提前返回。
|
||||
if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) {
|
||||
if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
if let Some(hit) = best_in_group(message, ENTITY_GROUP) {
|
||||
if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
if let Some(hit) = best_in_group(message, GENERIC_GROUP) {
|
||||
if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
(Intent::Unknown, 0.0)
|
||||
@@ -347,7 +416,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||
///
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data];
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
|
||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
||||
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
||||
@@ -357,9 +426,12 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
||||
Intent::File => &[ToolDomain::File],
|
||||
Intent::Project => &[ToolDomain::Data],
|
||||
Intent::Task => &[ToolDomain::Data],
|
||||
Intent::Idea => &[ToolDomain::Data],
|
||||
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
|
||||
// (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
|
||||
// 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
|
||||
Intent::Project => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Task => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Conversation => &[],
|
||||
Intent::Search => &[ToolDomain::File],
|
||||
Intent::Http => &[ToolDomain::Http],
|
||||
@@ -608,6 +680,47 @@ mod tests {
|
||||
assert_eq!(i, Intent::Search);
|
||||
}
|
||||
|
||||
// --- strip_mention_tags(L0 去噪)---
|
||||
|
||||
#[test]
|
||||
fn strip_removes_all_mention_kinds() {
|
||||
assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码");
|
||||
assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看");
|
||||
assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊");
|
||||
// 英文 kind + 大小写不敏感
|
||||
assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this");
|
||||
assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it");
|
||||
// 全角冒号
|
||||
assert_eq!(strip_mention_tags("[项目:DevFlow] 阅读"), " 阅读");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_preserves_non_mention_brackets() {
|
||||
// 非 mention 的 [...] 原样保留(代码片段/错误码)
|
||||
assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和");
|
||||
assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind
|
||||
assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号
|
||||
// 未闭合的 mention-like 不剥离(防误吞)
|
||||
assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_mention_prefix_not_misread_as_project() {
|
||||
// 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project)
|
||||
// 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。
|
||||
let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码");
|
||||
assert_eq!(i, Intent::Code);
|
||||
assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_real_project_word_still_hits_project() {
|
||||
// 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。
|
||||
// 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。
|
||||
let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
|
||||
assert_eq!(i, Intent::Project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_http_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
|
||||
@@ -713,11 +826,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_project_is_data() {
|
||||
fn subset_project_is_data_and_file() {
|
||||
// L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。
|
||||
// Data(create_project/bind_directory)+ File 只读(read_file)共存。
|
||||
let s = tool_subset_for(&Intent::Project);
|
||||
assert!(s.contains(&"create_project"));
|
||||
assert!(s.contains(&"bind_directory"));
|
||||
assert!(!s.contains(&"read_file"));
|
||||
assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -829,7 +944,7 @@ mod tests {
|
||||
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
|
||||
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
|
||||
df_ai_core::types::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
tool_type: "function".into(),
|
||||
function: df_ai_core::types::ToolFunction {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
|
||||
@@ -5,10 +5,12 @@ pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||
pub mod git_worktree;
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
@@ -28,12 +30,18 @@ pub mod openai_helpers;
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
|
||||
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
|
||||
pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -105,7 +104,7 @@ impl OpenAICompatProvider {
|
||||
req.model
|
||||
};
|
||||
|
||||
let messages: Vec<OpenAiMessage> = req
|
||||
let mut messages: Vec<OpenAiMessage> = req
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
@@ -177,6 +176,37 @@ impl OpenAICompatProvider {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
|
||||
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
|
||||
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
|
||||
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
|
||||
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
||||
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
|
||||
for i in 0..messages.len() {
|
||||
let role = messages[i].role.clone();
|
||||
if role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let has_tc = messages[i].tool_calls.is_some();
|
||||
if !has_tc {
|
||||
continue;
|
||||
}
|
||||
let next_is_tool = i + 1 < messages.len()
|
||||
&& matches!(messages[i + 1].role.as_str(), "tool");
|
||||
if !next_is_tool {
|
||||
messages[i].tool_calls = None;
|
||||
tracing::warn!(
|
||||
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
|
||||
i, role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
@@ -201,6 +231,36 @@ impl OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
///
|
||||
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
|
||||
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
|
||||
///
|
||||
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
|
||||
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
|
||||
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
|
||||
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
|
||||
if first_role == "user" || first_role == "system" {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
first_role,
|
||||
msg_count = messages.len(),
|
||||
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
|
||||
);
|
||||
messages.insert(
|
||||
0,
|
||||
OpenAiMessage {
|
||||
role: "user".into(),
|
||||
content: serde_json::Value::String("(continued from previous context)".into()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析同步响应中的工具调用
|
||||
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
|
||||
calls
|
||||
@@ -326,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
let resp = self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||
let send_future = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -342,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
|
||||
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
|
||||
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
|
||||
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
|
||||
let mut last_usage: Option<TokenUsage> = None;
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let chunk = apply_openai_sse(&data, &mut last_usage);
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("SSE 流错误: {}", e);
|
||||
error!("{}", ctx);
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
@@ -594,4 +671,53 @@ mod tests {
|
||||
let msg = &out.messages[0];
|
||||
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||
|
||||
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![
|
||||
ChatMessage::assistant("我来帮你"),
|
||||
ChatMessage::user("继续"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
|
||||
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
|
||||
assert_eq!(out.messages[1].role.as_str(), "assistant");
|
||||
assert_eq!(out.messages[2].role.as_str(), "user");
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_normal_unchanged() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
|
||||
assert_eq!(out.messages[0].role.as_str(), "user");
|
||||
}
|
||||
}
|
||||
|
||||
385
crates/df-ai/src/persona.rs
Normal file
385
crates/df-ai/src/persona.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
|
||||
//!
|
||||
//! 构成 AI Native 的基础:角色划分是分工的前提。
|
||||
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
|
||||
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 内置人设 ID 常量
|
||||
pub const PERSONA_CODER: &str = "coder";
|
||||
pub const PERSONA_REVIEWER: &str = "reviewer";
|
||||
pub const PERSONA_ARCHITECT: &str = "architect";
|
||||
pub const PERSONA_TESTER: &str = "tester";
|
||||
pub const PERSONA_ANALYST: &str = "analyst";
|
||||
|
||||
/// 人设能力标签枚举
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum PersonaCapability {
|
||||
/// 代码生成与修改
|
||||
CodeGeneration,
|
||||
/// Code Review
|
||||
CodeReview,
|
||||
/// 架构设计与分析
|
||||
ArchitectureDesign,
|
||||
/// 测试编写与执行
|
||||
Testing,
|
||||
/// 数据分析与搜索
|
||||
Analysis,
|
||||
/// 文件读写
|
||||
FileOperation,
|
||||
/// 命令执行
|
||||
CommandExecution,
|
||||
/// Git 操作
|
||||
GitOperation,
|
||||
/// 知识库操作
|
||||
KnowledgeOperation,
|
||||
/// 项目管理
|
||||
ProjectManagement,
|
||||
}
|
||||
|
||||
impl PersonaCapability {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CodeGeneration => "代码生成",
|
||||
Self::CodeReview => "代码审查",
|
||||
Self::ArchitectureDesign => "架构设计",
|
||||
Self::Testing => "测试",
|
||||
Self::Analysis => "分析",
|
||||
Self::FileOperation => "文件操作",
|
||||
Self::CommandExecution => "命令执行",
|
||||
Self::GitOperation => "Git 操作",
|
||||
Self::KnowledgeOperation => "知识库",
|
||||
Self::ProjectManagement => "项目管理",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设定义
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentPersona {
|
||||
/// 唯一标识(如 "coder", "reviewer")
|
||||
pub id: String,
|
||||
/// 人设名称(如 "程序员", "审查员")
|
||||
pub name: String,
|
||||
/// 人设描述(LLM 理解用)
|
||||
pub description: String,
|
||||
/// 能力标签集
|
||||
pub capabilities: Vec<PersonaCapability>,
|
||||
/// 工具白名单(空=不限,非空=仅允许列出的工具)
|
||||
pub tool_whitelist: Vec<String>,
|
||||
/// system prompt 模板(注入 {context} {goals} 等占位符)
|
||||
pub system_prompt_template: String,
|
||||
/// 优先级(选人设时冲突排名,0=最高)
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
impl AgentPersona {
|
||||
/// 检查此人设是否具备某项能力
|
||||
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
|
||||
self.capabilities.contains(cap)
|
||||
}
|
||||
|
||||
/// 检查某工具是否在此人设的 whitelist 中
|
||||
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
|
||||
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
|
||||
}
|
||||
|
||||
/// 构建带上下文的 system prompt
|
||||
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
|
||||
let goals_text = if goals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
|
||||
};
|
||||
self.system_prompt_template
|
||||
.replace("{context}", context)
|
||||
.replace("{goals}", &goals_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设注册表 — 管理内置 + 自定义人设
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersonaRegistry {
|
||||
personae: HashMap<String, AgentPersona>,
|
||||
/// 默认人设 id(兜底)
|
||||
default_id: String,
|
||||
}
|
||||
|
||||
impl PersonaRegistry {
|
||||
/// 构造默认注册表(含 5 内置人设)
|
||||
pub fn new() -> Self {
|
||||
let mut reg = Self {
|
||||
personae: HashMap::new(),
|
||||
default_id: PERSONA_CODER.to_string(),
|
||||
};
|
||||
reg.register(Self::builtin_coder());
|
||||
reg.register(Self::builtin_reviewer());
|
||||
reg.register(Self::builtin_architect());
|
||||
reg.register(Self::builtin_tester());
|
||||
reg.register(Self::builtin_analyst());
|
||||
reg
|
||||
}
|
||||
|
||||
// ── 5 内置人设 ──
|
||||
|
||||
fn builtin_coder() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_CODER.to_string(),
|
||||
name: "程序员".to_string(),
|
||||
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeGeneration,
|
||||
PersonaCapability::FileOperation,
|
||||
PersonaCapability::CommandExecution,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限(全部工具可用)
|
||||
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_reviewer() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_REVIEWER.to_string(),
|
||||
name: "审查员".to_string(),
|
||||
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeReview,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"git_diff".into(), "git_log".into(), "git_status".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_architect() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ARCHITECT.to_string(),
|
||||
name: "架构师".to_string(),
|
||||
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::ArchitectureDesign,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"write_file".into(), // 写架构文档
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_tester() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_TESTER.to_string(),
|
||||
name: "测试员".to_string(),
|
||||
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Testing,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::FileOperation,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限
|
||||
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_analyst() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ANALYST.to_string(),
|
||||
name: "分析师".to_string(),
|
||||
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Analysis,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::KnowledgeOperation,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "grep_search".into(),
|
||||
"run_command".into(), "git_log".into(),
|
||||
"knowledge_search".into(), "knowledge_inject".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册表操作 ──
|
||||
|
||||
/// 注册/覆盖人设
|
||||
pub fn register(&mut self, persona: AgentPersona) {
|
||||
self.personae.insert(persona.id.clone(), persona);
|
||||
}
|
||||
|
||||
/// 按 id 查人设
|
||||
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
|
||||
self.personae.get(id)
|
||||
}
|
||||
|
||||
/// 取默认人设
|
||||
pub fn default(&self) -> &AgentPersona {
|
||||
self.personae.get(&self.default_id).expect("默认人设必存在")
|
||||
}
|
||||
|
||||
/// 设置默认人设 id
|
||||
pub fn set_default(&mut self, id: &str) {
|
||||
if self.personae.contains_key(id) {
|
||||
self.default_id = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按意图推荐人设(启发式规则)
|
||||
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
|
||||
let lower = intent.to_lowercase();
|
||||
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
|
||||
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
|
||||
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("测试") || lower.contains("test") {
|
||||
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
|
||||
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
|
||||
} else {
|
||||
// 默认走 coder(代码生成是最常见场景)
|
||||
self.default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取全部人设列表
|
||||
pub fn all(&self) -> Vec<&AgentPersona> {
|
||||
let mut list: Vec<_> = self.personae.values().collect();
|
||||
list.sort_by_key(|p| p.priority);
|
||||
list
|
||||
}
|
||||
|
||||
/// 人设数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.personae.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.personae.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PersonaRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_has_5_builtin() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.len(), 5);
|
||||
assert!(reg.get(PERSONA_CODER).is_some());
|
||||
assert!(reg.get(PERSONA_REVIEWER).is_some());
|
||||
assert!(reg.get(PERSONA_ARCHITECT).is_some());
|
||||
assert!(reg.get(PERSONA_TESTER).is_some());
|
||||
assert!(reg.get(PERSONA_ANALYST).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.default().id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_reviewer() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("review the code changes");
|
||||
assert_eq!(p.id, PERSONA_REVIEWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_architect() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("设计新的模块架构");
|
||||
assert_eq!(p.id, PERSONA_ARCHITECT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_tester() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("write tests for this module");
|
||||
assert_eq!(p.id, PERSONA_TESTER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_default_to_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("implement a new feature");
|
||||
assert_eq!(p.id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_whitelist_filters() {
|
||||
let reviewer = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec!["read_file".into(), "search_files".into()],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(reviewer.is_tool_allowed("read_file"));
|
||||
assert!(!reviewer.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_whitelist_allows_all() {
|
||||
let coder = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(coder.is_tool_allowed("run_command"));
|
||||
assert!(coder.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_replaces_placeholders() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let coder = reg.get(PERSONA_CODER).unwrap();
|
||||
let prompt = coder.build_prompt("some context", &["goal1".into()]);
|
||||
assert!(prompt.contains("程序员"));
|
||||
assert!(prompt.contains("goal1"));
|
||||
assert!(prompt.contains("some context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_custom_persona() {
|
||||
let mut reg = PersonaRegistry::new();
|
||||
let custom = AgentPersona {
|
||||
id: "custom".into(),
|
||||
name: "自定义".into(),
|
||||
description: "test".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "You are custom".into(),
|
||||
priority: 0,
|
||||
};
|
||||
reg.register(custom);
|
||||
assert_eq!(reg.len(), 6);
|
||||
assert!(reg.get("custom").is_some());
|
||||
}
|
||||
}
|
||||
197
crates/df-ai/src/plan_executor.rs
Normal file
197
crates/df-ai/src/plan_executor.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Plan 执行器(Phase 2 · DAG 分层调度)
|
||||
//!
|
||||
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
|
||||
//! - 层间串行:上层全部完成才进下一层(deps 保证)
|
||||
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行
|
||||
//!
|
||||
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
|
||||
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
|
||||
//!
|
||||
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
|
||||
///
|
||||
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
|
||||
/// 默认关,翻 true 后 process_tool_calls 内以 JoinSet 并行执行同层工具。
|
||||
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 设置 Plan 执行开关。
|
||||
pub fn set_plan_execution(enabled: bool) {
|
||||
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
|
||||
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
|
||||
}
|
||||
|
||||
/// 读取 Plan 执行开关。
|
||||
pub fn plan_execution_enabled() -> bool {
|
||||
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 单个 SubTask 的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubTaskResult {
|
||||
/// 对应 SubTask id
|
||||
pub id: String,
|
||||
/// 执行是否成功
|
||||
pub success: bool,
|
||||
/// 执行产出(工具结果摘要 / LLM 回复)
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Plan 执行器:按 DAG 层级调度子任务。
|
||||
///
|
||||
/// 泛型参数 `F` 为子任务执行函数:
|
||||
/// - 输入:`&SubTask`(子任务定义)
|
||||
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
|
||||
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
|
||||
///
|
||||
/// 调用方(Phase 3 对接 agentic loop 时)传入真实的执行闭包,
|
||||
/// 每个 SubTask 独立跑一轮 agentic loop,共享父上下文快照。
|
||||
pub struct PlanExecutor;
|
||||
|
||||
impl PlanExecutor {
|
||||
/// 按 Plan DAG 分层执行。
|
||||
///
|
||||
/// - 层间串行:上层全部完成才进下一层
|
||||
/// - 层内并行:同层 SubTask 用 JoinSet 并发
|
||||
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
|
||||
/// 若需中断策略,调用方据返回结果自行判断。
|
||||
///
|
||||
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
|
||||
pub async fn execute<F, Fut>(
|
||||
plan: &Plan,
|
||||
mut run_subtask: F,
|
||||
) -> anyhow::Result<Vec<SubTaskResult>>
|
||||
where
|
||||
F: FnMut(Arc<SubTask>, String) -> Fut,
|
||||
Fut: std::future::Future<Output = anyhow::Result<String>>,
|
||||
{
|
||||
let layers = plan
|
||||
.to_layers()
|
||||
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
|
||||
|
||||
let mut results: Vec<SubTaskResult> = Vec::new();
|
||||
let mut parent_summary = String::new();
|
||||
|
||||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||||
tracing::info!(
|
||||
layer_idx,
|
||||
task_count = layer.len(),
|
||||
"[plan-exec] 执行第 {} 层, {} 个子任务",
|
||||
layer_idx,
|
||||
layer.len()
|
||||
);
|
||||
|
||||
// 层内并行:JoinSet 收集
|
||||
let _join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
|
||||
let summary_clone = parent_summary.clone();
|
||||
|
||||
for task in layer {
|
||||
let task = Arc::new(task.clone());
|
||||
let summary = summary_clone.clone();
|
||||
// 注意:run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用)。
|
||||
// Phase 3 对接时,run_subtask 改为 Arc<Fn> 或 channel 模式。
|
||||
// 当前骨架用串行执行模拟(层内不并行),验证分层逻辑正确性。
|
||||
let result = run_subtask(task.clone(), summary).await;
|
||||
let sr = match result {
|
||||
Ok(output) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: true,
|
||||
output,
|
||||
},
|
||||
Err(e) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: false,
|
||||
output: e.to_string(),
|
||||
},
|
||||
};
|
||||
results.push(sr);
|
||||
}
|
||||
|
||||
// 汇总本层结果作为下层 parent_summary
|
||||
let layer_summary: Vec<String> = results
|
||||
.iter()
|
||||
.filter(|r| layer.iter().any(|t| t.id == r.id))
|
||||
.map(|r| format!("[{}] {}", r.id, r.output))
|
||||
.collect();
|
||||
parent_summary = layer_summary.join("\n");
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_simple_linear_plan() {
|
||||
// a → b → c (三层,各一个任务)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
assert_eq!(results[0].id, "a");
|
||||
assert_eq!(results[1].id, "b");
|
||||
assert_eq!(results[2].id, "c");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_parallel_layer() {
|
||||
// a → {b, c} → d (三层,第二层两个并行)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_with_failure_continues() {
|
||||
// a(fail) → b: a 失败,b 仍执行(容错策略)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
if task.id == "a" {
|
||||
anyhow::bail!("task-a failed")
|
||||
}
|
||||
Ok("ok".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(!results[0].success); // a 失败
|
||||
assert!(results[1].success); // b 仍执行
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,15 @@
|
||||
|
||||
// ---- feature flag 占位 ------------------------------------------------------
|
||||
|
||||
/// 规划能力总开关(Phase 1 接入用占位)。
|
||||
/// 规划能力总开关(主 loop 规划开关,门控 filter_tool_defs_planned 接入)。
|
||||
///
|
||||
/// **当前恒 `false`** = 单链 ReAct 旧行为(主 loop 未走 Plan 规划)。
|
||||
/// Phase 1(plan_hint 接入主 loop)落地时翻为 `true` + 条件编译门控。
|
||||
/// **当前 `true`** = 主 loop 走 Plan 规划(agentic/mod.rs:633 调 filter_tool_defs_planned)。
|
||||
/// Phase 1(plan_hint 接入主 loop)已落地,翻 true 启用。
|
||||
/// 注:`PLAN_HINT_ENABLED`(plan_hint 函数 flag,Phase0a 就绪 true)单一定义在 `plan_hint.rs`,
|
||||
/// 本模块仅 `PLANNING_ENABLED`(主 loop 规划开关),消除此前双定义矛盾。
|
||||
pub const PLANNING_ENABLED: bool = false;
|
||||
/// 双层开关:即使 PLANNING_ENABLED=true,若 plan_hint 内部 PLAN_HINT_ENABLED 关闭,
|
||||
/// filter_tool_defs_planned 仍退扁平(任一关闭退旧行为,可回退)。
|
||||
pub const PLANNING_ENABLED: bool = true;
|
||||
|
||||
/// Plan 层级硬上限(防爆深,防规划 LLM 退化成超长链)。
|
||||
///
|
||||
@@ -632,10 +634,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_flags_default_off() {
|
||||
// PLANNING_ENABLED 当前关(主 loop 未走规划),Phase 1 接入时翻 true。
|
||||
fn feature_flags_default_on() {
|
||||
// PLANNING_ENABLED 当前开(主 loop 走 filter_tool_defs_planned 规划),Phase 1 已落地。
|
||||
// PLAN_HINT_ENABLED(plan_hint 函数 flag)在 plan_hint.rs 单一定义,此处不再断言。
|
||||
assert!(!PLANNING_ENABLED);
|
||||
assert!(PLANNING_ENABLED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
|
||||
const JITTER_RATIO: f64 = 1.0;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
|
||||
let ms = (base_ms as f64 * factor).max(0.0) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
118
crates/df-ai/src/sse_parser.rs
Normal file
118
crates/df-ai/src/sse_parser.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
|
||||
//! 即报错(且不可恢复):
|
||||
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
|
||||
//! - 缺少结束的 \n\n(连接断开常见)
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
|
||||
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<S> SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::with_capacity(8192),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
|
||||
if sep_pos.is_none() {
|
||||
break;
|
||||
}
|
||||
let sep_pos = sep_pos.unwrap();
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
|
||||
// 去掉末尾的 \n\n
|
||||
let body_end = event_bytes.len().saturating_sub(2);
|
||||
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_parts.push(rest);
|
||||
}
|
||||
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
|
||||
}
|
||||
data_parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Vec<SseEvent>, String>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
// 先尝试从 buffer 解析完整事件
|
||||
let events = self.parse_events();
|
||||
if !events.is_empty() {
|
||||
return Poll::Ready(Some(Ok(events)));
|
||||
}
|
||||
|
||||
// buffer 不足以解析出完整事件,从 inner 读更多数据
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
|
||||
if !self.buffer.is_empty() {
|
||||
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
|
||||
self.buffer.clear();
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
}
|
||||
}
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
456
crates/df-execute/src/env_snapshot.rs
Normal file
456
crates/df-execute/src/env_snapshot.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
|
||||
//!
|
||||
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
|
||||
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
|
||||
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
|
||||
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
|
||||
//!
|
||||
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
|
||||
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
|
||||
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
|
||||
//! 阻断主流程)。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 环境快照 — 系统环境的不可变视图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EnvSnapshot {
|
||||
/// 操作系统族:"windows" / "macos" / "linux"
|
||||
pub os: String,
|
||||
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
|
||||
pub os_version: String,
|
||||
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
|
||||
pub shell: String,
|
||||
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
|
||||
pub path_sep: String,
|
||||
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
|
||||
pub encoding: String,
|
||||
/// 工具版本(逐个探测,缺失为 None)
|
||||
pub tools: ToolVersions,
|
||||
}
|
||||
|
||||
/// 各开发工具的版本信息。
|
||||
#[derive(Debug, Clone, serde::Serialize, Default)]
|
||||
pub struct ToolVersions {
|
||||
pub python: Option<String>,
|
||||
pub node: Option<String>,
|
||||
pub rust: Option<String>,
|
||||
pub go: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub git: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvSnapshot {
|
||||
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
|
||||
///
|
||||
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
|
||||
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
|
||||
pub async fn detect() -> &'static EnvSnapshot {
|
||||
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
|
||||
if let Some(snap) = SNAPSHOT.get() {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
|
||||
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
|
||||
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
|
||||
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
|
||||
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
|
||||
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
|
||||
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
|
||||
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
|
||||
let snap = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
|
||||
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
|
||||
);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
};
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
}
|
||||
|
||||
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
|
||||
fn do_detect() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: detect_os_version(),
|
||||
shell: detect_shell(),
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: detect_encoding(),
|
||||
tools: ToolVersions {
|
||||
python: probe_version("python", &["--version"]),
|
||||
node: probe_version("node", &["--version"]),
|
||||
rust: probe_version("rustc", &["--version"]),
|
||||
go: probe_version("go", &["version"]),
|
||||
docker: probe_version("docker", &["--version"]),
|
||||
git: probe_version("git", &["--version"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
|
||||
fn fallback() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: String::new(),
|
||||
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
|
||||
///
|
||||
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
|
||||
pub fn to_prompt(&self) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"## 系统环境".to_string(),
|
||||
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
|
||||
format!("- Shell: {}", self.shell),
|
||||
format!("- 路径分隔符: {}", self.path_sep),
|
||||
format!("- 终端编码: {}", self.encoding),
|
||||
];
|
||||
if let Some(v) = &self.tools.python {
|
||||
lines.push(format!("- Python: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.node {
|
||||
lines.push(format!("- Node: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.rust {
|
||||
lines.push(format!("- Rust: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.go {
|
||||
lines.push(format!("- Go: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.docker {
|
||||
lines.push(format!("- Docker: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.git {
|
||||
lines.push(format!("- Git: {}", v));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("注意: 请生成本平台兼容的命令。".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
|
||||
fn detect_os_version() -> String {
|
||||
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
|
||||
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(v) = read_windows_version() {
|
||||
return v;
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// macOS:sw_vers -productVersion 输出如 "12.5"
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return rest.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_windows_version() -> Option<String> {
|
||||
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
|
||||
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
|
||||
let out = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v",
|
||||
"DisplayVersion",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 输出形如: " DisplayVersion REG_SZ 22H2"
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(idx) = trimmed.find("REG_SZ") {
|
||||
let val = trimmed[idx + "REG_SZ".len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(format!("Windows {}", val));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
|
||||
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "pwsh".to_string();
|
||||
}
|
||||
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "powershell".to_string();
|
||||
}
|
||||
return "cmd".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
|
||||
if let Ok(sh) = std::env::var("SHELL") {
|
||||
// 取 basename(/bin/zsh → zsh)
|
||||
let name = sh.rsplit('/').next().unwrap_or(&sh);
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
"sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
|
||||
fn detect_encoding() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
|
||||
if let Ok(out) = std::process::Command::new("chcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(code) = extract_codepage(&text) {
|
||||
return match code.as_str() {
|
||||
"65001" => "utf-8".to_string(),
|
||||
"936" => "gbk".to_string(),
|
||||
"950" => "big5".to_string(),
|
||||
other => format!("cp{}", other),
|
||||
};
|
||||
}
|
||||
}
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
if lang.to_ascii_uppercase().contains("UTF-8") {
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
}
|
||||
"utf-8".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extract_codepage(text: &str) -> Option<String> {
|
||||
// 提取末尾数字("...936" / "...: 65001")。
|
||||
let mut num = String::new();
|
||||
for c in text.chars().rev() {
|
||||
if c.is_ascii_digit() {
|
||||
num.insert(0, c);
|
||||
} else if !num.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
|
||||
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
|
||||
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
|
||||
///
|
||||
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let first_line = text.lines().next()?;
|
||||
Some(extract_version_token(first_line))
|
||||
}
|
||||
|
||||
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
|
||||
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
|
||||
///
|
||||
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
|
||||
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
|
||||
fn extract_version_token(line: &str) -> String {
|
||||
for token in line.split_whitespace() {
|
||||
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
|
||||
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
|
||||
// "2.41.0.windows.1",实际应止于 "2.41.0")。
|
||||
let bytes = token.as_bytes();
|
||||
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
// 收数字段
|
||||
let seg_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
head.push_str(&token[seg_start..i]);
|
||||
// 点号:仅当下一字符为数字时才续,否则收尾
|
||||
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
{
|
||||
head.push('.');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if head.contains('.') {
|
||||
return head;
|
||||
}
|
||||
}
|
||||
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
|
||||
line.trim().to_string()
|
||||
}
|
||||
|
||||
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_version_python_style() {
|
||||
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_node_style() {
|
||||
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_git_style() {
|
||||
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_go_style() {
|
||||
assert_eq!(
|
||||
extract_version_token("go version go1.21.0 windows/amd64"),
|
||||
"1.21.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_no_match_returns_line() {
|
||||
assert_eq!(extract_version_token("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn extract_codepage_parsing() {
|
||||
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
|
||||
assert_eq!(
|
||||
extract_codepage("Active code page: 65001"),
|
||||
Some("65001".into())
|
||||
);
|
||||
assert_eq!(extract_codepage("no digits here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_cached_static_ref() {
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_prompt_contains_os_and_shell() {
|
||||
let snap = EnvSnapshot {
|
||||
os: "test_os".into(),
|
||||
os_version: "v1".into(),
|
||||
shell: "test_shell".into(),
|
||||
path_sep: "/".into(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions {
|
||||
python: Some("3.11".into()),
|
||||
node: None,
|
||||
rust: None,
|
||||
go: None,
|
||||
docker: None,
|
||||
git: None,
|
||||
},
|
||||
};
|
||||
let prompt = snap.to_prompt();
|
||||
assert!(prompt.contains("test_os"));
|
||||
assert!(prompt.contains("test_shell"));
|
||||
assert!(prompt.contains("Python: 3.11"));
|
||||
assert!(!prompt.contains("Node"));
|
||||
assert!(prompt.contains("请生成本平台兼容的命令"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知
|
||||
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
@@ -22,18 +22,68 @@ pub struct ShellResult {
|
||||
pub enum ShellType {
|
||||
/// Windows cmd.exe (默认 Windows)
|
||||
Cmd,
|
||||
/// PowerShell
|
||||
/// PowerShell 5.x(Windows 自带,不支持 && 运算符)
|
||||
PowerShell,
|
||||
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
|
||||
Pwsh,
|
||||
/// Unix sh (默认非 Windows)
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
|
||||
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
||||
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
|
||||
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
|
||||
if cfg!(windows) {
|
||||
if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
|
||||
fn probe_pwsh_cached() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
CACHE.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||
///
|
||||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
///
|
||||
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
|
||||
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
|
||||
async fn probe_pwsh() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if let Some(cached) = CACHE.get() {
|
||||
return *cached;
|
||||
}
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
let mut cmd = std::process::Command::new("pwsh");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
// 多任务竞态时以先到者为准,均等价
|
||||
let _ = CACHE.set(result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellRequest {
|
||||
@@ -57,6 +107,10 @@ pub struct ShellRequest {
|
||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
|
||||
#[cfg(windows)]
|
||||
let _ = probe_pwsh().await;
|
||||
|
||||
let shell_type = request.shell_type.unwrap_or_default();
|
||||
let mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
@@ -65,6 +119,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Pwsh => {
|
||||
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
|
||||
let mut c = tokio::process::Command::new("pwsh");
|
||||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Cmd => {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(&request.command);
|
||||
|
||||
@@ -5,3 +5,4 @@ mod adversarial_helpers;
|
||||
pub mod capture;
|
||||
pub mod promotion;
|
||||
pub mod scoring;
|
||||
pub mod scoring_keywords;
|
||||
|
||||
@@ -11,6 +11,15 @@ pub use crate::capture::IdeaScores;
|
||||
|
||||
use df_types::types::Priority;
|
||||
|
||||
// ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
|
||||
//
|
||||
// 关键词常量已拆至独立文件 `scoring_keywords.rs`。
|
||||
// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
|
||||
pub use crate::scoring_keywords::{
|
||||
FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
|
||||
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
|
||||
};
|
||||
|
||||
/// 评分权重配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringWeights {
|
||||
@@ -71,14 +80,10 @@ impl ScoringEngine {
|
||||
score -= 0.5;
|
||||
}
|
||||
// 可行性正向信号
|
||||
let pos = count_any(desc, &[
|
||||
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
|
||||
]);
|
||||
let pos = count_any(desc, FEASIBILITY_POSITIVE_KEYWORDS);
|
||||
score += (pos as f64) * 0.5;
|
||||
// 复杂度负向信号
|
||||
let neg = count_any(desc, &[
|
||||
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
|
||||
]);
|
||||
let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
|
||||
score -= (neg as f64) * 0.6;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
@@ -97,9 +102,7 @@ impl ScoringEngine {
|
||||
score += (idea.tags.len().min(4) as f64) * 0.25;
|
||||
}
|
||||
let desc = idea.description.trim();
|
||||
let value_hits = count_any(desc, &[
|
||||
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
|
||||
]);
|
||||
let value_hits = count_any(desc, IMPACT_KEYWORDS);
|
||||
score += (value_hits as f64) * 0.5;
|
||||
if desc.chars().count() > 100 {
|
||||
score += 0.5;
|
||||
@@ -116,9 +119,7 @@ impl ScoringEngine {
|
||||
Priority::Low => 3.0,
|
||||
};
|
||||
let desc = idea.description.trim();
|
||||
let time_hits = count_any(desc, &[
|
||||
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
|
||||
]);
|
||||
let time_hits = count_any(desc, URGENCY_KEYWORDS);
|
||||
score += (time_hits as f64) * 0.5;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
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", "截止", "先行", "阻塞",
|
||||
];
|
||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
||||
use df_types::types::new_id;
|
||||
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -235,10 +235,11 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
let status = arg_str_or(&args, "status", "planning");
|
||||
medium_audit("create_project", &name);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name,
|
||||
@@ -269,7 +270,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
};
|
||||
let name = arg_str_or(&args, "name", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
let status = arg_str_or(&args, "status", "planning");
|
||||
medium_audit("update_project", &id);
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
@@ -280,6 +281,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
id: id.clone(),
|
||||
name,
|
||||
@@ -322,6 +324,12 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
|
||||
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
|
||||
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
|
||||
if has_traversal {
|
||||
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
|
||||
}
|
||||
let norm = normalize_path(&path);
|
||||
// 路径冲突检测
|
||||
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
|
||||
@@ -330,8 +338,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
conflict.name, conflict.id
|
||||
));
|
||||
}
|
||||
// 仅更新 path 字段(用 update_field,保留其它)
|
||||
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
|
||||
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
|
||||
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
|
||||
return CallToolResult::error(format!("项目不存在: {id}"));
|
||||
}
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
@@ -355,7 +363,7 @@ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
list.retain(|t| t.project_id == *pid);
|
||||
}
|
||||
if let Some(st) = &status_filter {
|
||||
list.retain(|t| t.status == *st);
|
||||
list.retain(|t| t.status.as_str() == st.as_str());
|
||||
}
|
||||
json_ok(json!({ "tasks": list, "count": list.len() }))
|
||||
}
|
||||
@@ -384,7 +392,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status: "todo".to_owned(),
|
||||
status: TaskStatus::Todo,
|
||||
priority,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -393,6 +401,9 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -446,6 +457,9 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
review_rounds: existing.review_rounds,
|
||||
output_json: existing.output_json,
|
||||
idea_id: existing.idea_id,
|
||||
queue: existing.queue,
|
||||
parent_id: existing.parent_id,
|
||||
content_json: existing.content_json,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -523,7 +537,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
id: new_id(),
|
||||
title,
|
||||
description,
|
||||
status: "draft".to_owned(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
score: None,
|
||||
tags: None,
|
||||
|
||||
@@ -15,3 +15,4 @@ tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
@@ -273,6 +273,7 @@ mod tests {
|
||||
use df_storage::crud::{ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
use serde_json::json;
|
||||
|
||||
// ============================================================
|
||||
@@ -287,7 +288,7 @@ mod tests {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -302,7 +303,7 @@ mod tests {
|
||||
project_id: "p1".to_string(),
|
||||
title: "t1".to_string(),
|
||||
description: "实现登录接口".to_string(),
|
||||
status: "testing".to_string(),
|
||||
status: TaskStatus::Testing,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -311,6 +312,9 @@ mod tests {
|
||||
review_rounds: 0,
|
||||
output_json: task_output_json.map(String::from),
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
|
||||
419
crates/df-nodes/src/docker_node.rs
Normal file
419
crates/df-nodes/src/docker_node.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! Docker 节点 — 在 Docker 容器内执行命令
|
||||
//!
|
||||
//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地
|
||||
//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。
|
||||
//!
|
||||
//! 执行流程:
|
||||
//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败
|
||||
//! 时退出码语义混淆)。
|
||||
//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。
|
||||
//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Docker 节点
|
||||
pub struct DockerNode;
|
||||
|
||||
/// 卷挂载配置项(volumes 数组元素)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VolumeMount {
|
||||
pub host: String,
|
||||
pub container: String,
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 DockerNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockerParams {
|
||||
/// Docker 镜像名(必填,如 "rust:latest")
|
||||
pub image: String,
|
||||
/// 容器内执行命令(必填)
|
||||
pub command: String,
|
||||
/// 容器内工作目录(默认 "/workspace")
|
||||
pub working_dir: String,
|
||||
/// 超时秒数(默认 300)
|
||||
pub timeout_secs: u64,
|
||||
/// 卷挂载列表(可选)
|
||||
pub volumes: Vec<VolumeMount>,
|
||||
/// 环境变量(可选)
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<DockerParams> {
|
||||
let image = config
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))?
|
||||
.to_string();
|
||||
|
||||
let command = config
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))?
|
||||
.to_string();
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "/workspace".to_string());
|
||||
|
||||
let timeout_secs = config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(300);
|
||||
|
||||
// volumes: JSON 数组,每项 {"host": "...", "container": "..."}
|
||||
// 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。
|
||||
let volumes = config
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| {
|
||||
let host = item.get("host")?.as_str()?.to_string();
|
||||
let container = item.get("container")?.as_str()?.to_string();
|
||||
Some(VolumeMount { host, container })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// env: JSON 对象 { KEY: VALUE },值统一转字符串。
|
||||
let env = config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DockerParams {
|
||||
image,
|
||||
command,
|
||||
working_dir,
|
||||
timeout_secs,
|
||||
volumes,
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。
|
||||
async fn check_docker_available() -> anyhow::Result<()> {
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command: "docker --version".to_string(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(15),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
let result = df_execute::shell::execute(request).await;
|
||||
match result {
|
||||
Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()),
|
||||
_ => anyhow::bail!("Docker 未安装或未运行"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 docker run 命令字符串。
|
||||
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
|
||||
fn build_command(params: &DockerParams) -> String {
|
||||
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
|
||||
|
||||
for v in ¶ms.volumes {
|
||||
parts.push(format!(
|
||||
"-v {}:{}",
|
||||
shell_quote(&v.host),
|
||||
shell_quote(&v.container)
|
||||
));
|
||||
}
|
||||
|
||||
for (k, val) in ¶ms.env {
|
||||
parts.push(format!("-e {}={}", k, shell_quote(val)));
|
||||
}
|
||||
|
||||
parts.push(format!("-w {}", shell_quote(¶ms.working_dir)));
|
||||
parts.push(shell_quote(¶ms.image));
|
||||
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
|
||||
// 与脚本节点一致由 shell 解释器解析。
|
||||
parts.push(params.command.clone());
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
|
||||
{
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for DockerNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("DockerNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// 1. Docker 可用性探测(未装/未运行直接 fail-fast)。
|
||||
if let Err(e) = check_docker_available().await {
|
||||
anyhow::bail!(e.to_string());
|
||||
}
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms);
|
||||
|
||||
tracing::info!(
|
||||
image = %params.image,
|
||||
working_dir = %params.working_dir,
|
||||
timeout_secs = params.timeout_secs,
|
||||
"DockerNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
// 宿主机工作目录对 docker run 无意义,置 None。
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(params.timeout_secs),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
|
||||
tracing::info!(
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"DockerNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"image": params.image,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" },
|
||||
"command": { "type": "string", "description": "容器内执行命令" },
|
||||
"working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" },
|
||||
"timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" },
|
||||
"volumes": {
|
||||
"type": "array",
|
||||
"description": "卷挂载 [{host, container}]",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"container": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": { "type": "object", "description": "环境变量键值对" }
|
||||
},
|
||||
"required": ["image", "command"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"docker"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── parse_params: 必填缺失 ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_image_errors() {
|
||||
let err = parse_params(&json!({ "command": "ls" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("image"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_missing_command_errors() {
|
||||
let err = parse_params(&json!({ "image": "rust:latest" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("command"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params: 默认值 ──
|
||||
|
||||
#[test]
|
||||
fn params_minimal_uses_defaults() {
|
||||
let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap();
|
||||
assert_eq!(p.image, "alpine");
|
||||
assert_eq!(p.command, "echo hi");
|
||||
assert_eq!(p.working_dir, "/workspace");
|
||||
assert_eq!(p.timeout_secs, 300);
|
||||
assert!(p.volumes.is_empty());
|
||||
assert!(p.env.is_empty());
|
||||
}
|
||||
|
||||
// ── parse_params: 完整字段 ──
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "rust:latest",
|
||||
"command": "cargo test",
|
||||
"working_dir": "/app",
|
||||
"timeout_secs": 120
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.image, "rust:latest");
|
||||
assert_eq!(p.command, "cargo test");
|
||||
assert_eq!(p.working_dir, "/app");
|
||||
assert_eq!(p.timeout_secs, 120);
|
||||
}
|
||||
|
||||
// ── parse_params: volumes 解析 ──
|
||||
|
||||
#[test]
|
||||
fn params_volumes_parsed() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "node",
|
||||
"command": "npm ci",
|
||||
"volumes": [
|
||||
{ "host": "/host/a", "container": "/c/a" },
|
||||
{ "host": "/host/b", "container": "/c/b" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.volumes.len(), 2);
|
||||
assert_eq!(
|
||||
p.volumes[0],
|
||||
VolumeMount {
|
||||
host: "/host/a".into(),
|
||||
container: "/c/a".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(p.volumes[1].container, "/c/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_volumes_skips_malformed_items() {
|
||||
// 缺 container / 非 object 项应被跳过,不整体失败。
|
||||
let p = parse_params(&json!({
|
||||
"image": "node",
|
||||
"command": "ls",
|
||||
"volumes": [
|
||||
{ "host": "/ok", "container": "/ok" },
|
||||
{ "host": "/no-container" },
|
||||
"not-an-object",
|
||||
{ "container": "/no-host" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.volumes.len(), 1);
|
||||
assert_eq!(p.volumes[0].host, "/ok");
|
||||
}
|
||||
|
||||
// ── parse_params: env 解析 ──
|
||||
|
||||
#[test]
|
||||
fn params_env_parsed() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "python",
|
||||
"command": "pytest",
|
||||
"env": {
|
||||
"FOO": "bar",
|
||||
"DEBUG": "1"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.env.len(), 2);
|
||||
assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar"));
|
||||
assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_env_empty_when_non_object() {
|
||||
// env 非 object(误传字符串)时回退空 map,不报错。
|
||||
let p = parse_params(&json!({
|
||||
"image": "python",
|
||||
"command": "ls",
|
||||
"env": "should-be-object"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(p.env.is_empty());
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_minimal_shape() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "echo hello"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
|
||||
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
|
||||
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_includes_volumes_and_env() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "rust:latest",
|
||||
"command": "cargo build",
|
||||
"working_dir": "/app",
|
||||
"volumes": [
|
||||
{ "host": "/host/src", "container": "/app" }
|
||||
],
|
||||
"env": { "CARGO_HOME": "/cargo" }
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("abc"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_wraps_spaces() {
|
||||
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
|
||||
}
|
||||
}
|
||||
426
crates/df-nodes/src/git_node.rs
Normal file
426
crates/df-nodes/src/git_node.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
|
||||
//!
|
||||
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
|
||||
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Git 节点
|
||||
pub struct GitNode;
|
||||
|
||||
/// 支持的 Git 动作
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GitAction {
|
||||
Branch,
|
||||
Checkout,
|
||||
Commit,
|
||||
Merge,
|
||||
Push,
|
||||
Status,
|
||||
Log,
|
||||
}
|
||||
|
||||
impl GitAction {
|
||||
/// 从 config.action 字符串解析动作,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"branch" => Ok(GitAction::Branch),
|
||||
"checkout" => Ok(GitAction::Checkout),
|
||||
"commit" => Ok(GitAction::Commit),
|
||||
"merge" => Ok(GitAction::Merge),
|
||||
"push" => Ok(GitAction::Push),
|
||||
"status" => Ok(GitAction::Status),
|
||||
"log" => Ok(GitAction::Log),
|
||||
other => anyhow::bail!(
|
||||
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
GitAction::Branch => "branch",
|
||||
GitAction::Checkout => "checkout",
|
||||
GitAction::Commit => "commit",
|
||||
GitAction::Merge => "merge",
|
||||
GitAction::Push => "push",
|
||||
GitAction::Status => "status",
|
||||
GitAction::Log => "log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 GitNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitParams {
|
||||
pub action: GitAction,
|
||||
/// 目标分支(branch/checkout/merge)
|
||||
pub branch_name: Option<String>,
|
||||
/// commit 信息(commit)
|
||||
pub message: Option<String>,
|
||||
/// 仓库路径(None=当前目录)
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
|
||||
let action = config
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
|
||||
let action = GitAction::parse(action)?;
|
||||
|
||||
let branch_name = config
|
||||
.get("branch_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(GitParams {
|
||||
action,
|
||||
branch_name,
|
||||
message,
|
||||
working_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
|
||||
fn build_command(params: &GitParams) -> anyhow::Result<String> {
|
||||
match params.action {
|
||||
GitAction::Branch => {
|
||||
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
|
||||
match ¶ms.branch_name {
|
||||
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
|
||||
None => Ok("git branch".to_string()),
|
||||
}
|
||||
}
|
||||
GitAction::Checkout => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
|
||||
Ok(format!("git checkout {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Commit => {
|
||||
let msg = params
|
||||
.message
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
|
||||
Ok(format!("git commit -m {}", shell_quote(msg)))
|
||||
}
|
||||
GitAction::Merge => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
|
||||
Ok(format!("git merge {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Push => Ok("git push".to_string()),
|
||||
GitAction::Status => Ok("git status".to_string()),
|
||||
GitAction::Log => Ok("git log --oneline -20".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') {
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for GitNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms)?;
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
working_dir = ?params.working_dir,
|
||||
"GitNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
working_dir: params.working_dir.clone(),
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(60),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
if exit_code != 0 {
|
||||
anyhow::bail!(
|
||||
"GitNode 执行失败 (exit_code={}): {}",
|
||||
exit_code,
|
||||
result.stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
|
||||
let parsed = parse_action_output(¶ms.action, &result.stdout);
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"GitNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"action": params.action.as_str(),
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
"parsed": parsed,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
|
||||
},
|
||||
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
|
||||
"message": { "type": "string", "description": "commit 信息(commit)" },
|
||||
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" },
|
||||
"parsed": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"git"
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 action 解析 stdout 成结构化字段。
|
||||
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
|
||||
/// - log:每行一条 `hash subject`
|
||||
/// - commit:首段 hash
|
||||
/// - 其余动作无强结构,返回空对象
|
||||
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
|
||||
match action {
|
||||
GitAction::Branch => {
|
||||
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
|
||||
let current = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with('*'))
|
||||
.map(|l| l.trim_start_matches("* ").trim().to_string());
|
||||
serde_json::json!({ "branches": branches, "current": current })
|
||||
}
|
||||
GitAction::Log => {
|
||||
let entries: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim();
|
||||
let mut parts = l.splitn(2, ' ');
|
||||
let hash = parts.next()?.to_string();
|
||||
let subject = parts.next().unwrap_or("").to_string();
|
||||
Some(serde_json::json!({ "hash": hash, "subject": subject }))
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "entries": entries })
|
||||
}
|
||||
GitAction::Commit => {
|
||||
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
|
||||
let hash = stdout
|
||||
.split_whitespace()
|
||||
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
serde_json::json!({ "commit_hash": hash })
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── GitAction::parse ──
|
||||
|
||||
#[test]
|
||||
fn parse_all_valid_actions() {
|
||||
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
|
||||
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
|
||||
assert_eq!(a.as_str(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_action_errors() {
|
||||
let err = GitAction::parse("rebase").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
assert!(err.contains("rebase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_action_errors() {
|
||||
let err = GitAction::parse("").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_action_errors() {
|
||||
let err = parse_params(&json!({ "branch_name": "main" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_minimal_valid() {
|
||||
let p = parse_params(&json!({ "action": "status" })).unwrap();
|
||||
assert_eq!(p.action, GitAction::Status);
|
||||
assert_eq!(p.branch_name, None);
|
||||
assert_eq!(p.message, None);
|
||||
assert_eq!(p.working_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"action": "commit",
|
||||
"branch_name": "feature/x",
|
||||
"message": "fix: 修复",
|
||||
"working_dir": "/repo"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.action, GitAction::Commit);
|
||||
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
|
||||
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
|
||||
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_branch_list_when_no_name() {
|
||||
let p = parse_params(&json!({ "action": "branch" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_branch_create_when_name() {
|
||||
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_checkout_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("branch_name"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_requires_message() {
|
||||
let p = parse_params(&json!({ "action": "commit" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("message"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_quotes_message_with_space() {
|
||||
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
|
||||
let cmd = build_command(&p).unwrap();
|
||||
assert_eq!(cmd, "git commit -m \"fix bug a\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_merge_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "merge" })).unwrap();
|
||||
assert!(build_command(&p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_push_status_log_fixed() {
|
||||
for (action, expected) in [
|
||||
("push", "git push"),
|
||||
("status", "git status"),
|
||||
("log", "git log --oneline -20"),
|
||||
] {
|
||||
let p = parse_params(&json!({ "action": action })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("feat"), "feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_escapes_embedded_double_quote() {
|
||||
let q = shell_quote("a\"b");
|
||||
assert_eq!(q, "\"a\\\"b\"");
|
||||
}
|
||||
|
||||
// ── parse_action_output ──
|
||||
|
||||
#[test]
|
||||
fn parse_branch_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Branch,
|
||||
"* main\n develop\n feature/x\n",
|
||||
);
|
||||
assert_eq!(out["current"], "main");
|
||||
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_log_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Log,
|
||||
"abc1234 fix bug\n deadbeef add feature\n",
|
||||
);
|
||||
let entries = out["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["hash"], "abc1234");
|
||||
assert_eq!(entries[1]["subject"], "add feature");
|
||||
}
|
||||
}
|
||||
319
crates/df-nodes/src/http_node.rs
Normal file
319
crates/df-nodes/src/http_node.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE)
|
||||
//!
|
||||
//! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。
|
||||
//! 输出 status_code + body + (可选)响应头子集。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// HTTP 节点
|
||||
pub struct HttpNode;
|
||||
|
||||
/// 支持的 HTTP 方法
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl HttpMethod {
|
||||
/// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。
|
||||
pub fn parse(raw: Option<&str>) -> anyhow::Result<Self> {
|
||||
match raw.map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
None | Some("GET") => Ok(HttpMethod::Get),
|
||||
Some("POST") => Ok(HttpMethod::Post),
|
||||
Some("PUT") => Ok(HttpMethod::Put),
|
||||
Some("DELETE") => Ok(HttpMethod::Delete),
|
||||
Some(other) => anyhow::bail!(
|
||||
"HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
HttpMethod::Get => "GET",
|
||||
HttpMethod::Post => "POST",
|
||||
HttpMethod::Put => "PUT",
|
||||
HttpMethod::Delete => "DELETE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 HttpNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpParams {
|
||||
pub method: HttpMethod,
|
||||
pub url: String,
|
||||
/// 请求头(JSON 对象 → Vec<(name, value)>)
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// 请求体(可选)
|
||||
pub body: Option<String>,
|
||||
/// 超时秒数(缺省 30)
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// 默认超时
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<HttpParams> {
|
||||
let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?;
|
||||
|
||||
let url = config
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?;
|
||||
if url.trim().is_empty() {
|
||||
anyhow::bail!("HttpNode url 不能为空");
|
||||
}
|
||||
|
||||
// headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。
|
||||
let mut headers = Vec::new();
|
||||
if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) {
|
||||
for (k, v) in obj {
|
||||
match v.as_str() {
|
||||
Some(s) => headers.push((k.clone(), s.to_string())),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
header = %k,
|
||||
"HttpNode headers 中 header 值非字符串,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body = config
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let timeout_secs = config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
Ok(HttpParams {
|
||||
method,
|
||||
url: url.to_string(),
|
||||
headers,
|
||||
body,
|
||||
timeout_secs,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for HttpNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("HttpNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
|
||||
tracing::info!(
|
||||
method = params.method.as_str(),
|
||||
url = %params.url,
|
||||
timeout_secs = params.timeout_secs,
|
||||
"HttpNode 发起请求"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(params.timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let mut req = match params.method {
|
||||
HttpMethod::Get => client.get(¶ms.url),
|
||||
HttpMethod::Post => client.post(¶ms.url),
|
||||
HttpMethod::Put => client.put(¶ms.url),
|
||||
HttpMethod::Delete => client.delete(¶ms.url),
|
||||
};
|
||||
|
||||
for (name, value) in ¶ms.headers {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
|
||||
if let Some(body) = ¶ms.body {
|
||||
req = req.body(body.clone());
|
||||
}
|
||||
|
||||
let response = req.send().await?;
|
||||
let status_code = response.status().as_u16();
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let body_text = response.text().await?;
|
||||
|
||||
tracing::info!(
|
||||
method = params.method.as_str(),
|
||||
url = %params.url,
|
||||
status_code,
|
||||
body_len = body_text.len(),
|
||||
"HttpNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"method": params.method.as_str(),
|
||||
"url": params.url,
|
||||
"status_code": status_code,
|
||||
"body": body_text,
|
||||
"content_type": content_type,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE"],
|
||||
"default": "GET"
|
||||
},
|
||||
"url": { "type": "string", "description": "请求 URL" },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "请求头 JSON 对象",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"body": { "type": "string", "description": "请求体(可选)" },
|
||||
"timeout_secs": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["url"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"status_code": { "type": "integer" },
|
||||
"body": { "type": "string" },
|
||||
"content_type": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"http"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── HttpMethod::parse ──
|
||||
|
||||
#[test]
|
||||
fn method_defaults_to_get_when_absent() {
|
||||
assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_case_insensitive() {
|
||||
assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get);
|
||||
assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post);
|
||||
assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete);
|
||||
assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_invalid_errors() {
|
||||
let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string();
|
||||
assert!(err.contains("非法 method"), "实际: {}", err);
|
||||
assert!(err.contains("PATCH"));
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_url_errors() {
|
||||
let err = parse_params(&json!({ "method": "GET" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_empty_url_errors() {
|
||||
let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string();
|
||||
assert!(err.contains("空"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_defaults_method_and_timeout() {
|
||||
let p = parse_params(&json!({ "url": "https://example.com" })).unwrap();
|
||||
assert_eq!(p.method, HttpMethod::Get);
|
||||
assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS);
|
||||
assert!(p.headers.is_empty());
|
||||
assert_eq!(p.body, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_custom_timeout() {
|
||||
let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap();
|
||||
assert_eq!(p.timeout_secs, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parses_headers_object() {
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"headers": {
|
||||
"Authorization": "Bearer abc",
|
||||
"X-Trace-Id": "123"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
// headers 顺序由 serde_json BTreeMap 保证;只校验集合
|
||||
let map: std::collections::HashMap<&str, &str> =
|
||||
p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map.get("Authorization"), Some(&"Bearer abc"));
|
||||
assert_eq!(map.get("X-Trace-Id"), Some(&"123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_non_string_header_value_skipped() {
|
||||
// 非字符串值不应炸,应被跳过
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"headers": { "X-Num": 123, "X-Ok": "yes" }
|
||||
}))
|
||||
.unwrap();
|
||||
let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(names.contains(&"X-Ok"));
|
||||
assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parses_body() {
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"method": "POST",
|
||||
"body": "{\"k\":1}"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.method, HttpMethod::Post);
|
||||
assert_eq!(p.body.as_deref(), Some("{\"k\":1}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_invalid_method_propagates() {
|
||||
let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("非法 method"), "实际: {}", err);
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ mod tests {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
@@ -477,8 +477,8 @@ mod tests {
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
@@ -537,7 +537,7 @@ mod tests {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
pub mod docker_node;
|
||||
pub mod git_node;
|
||||
pub mod http_node;
|
||||
pub mod human_node;
|
||||
mod human_node_helpers;
|
||||
pub mod notify_node;
|
||||
pub mod subflow_node;
|
||||
pub mod script_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
|
||||
292
crates/df-nodes/src/notify_node.rs
Normal file
292
crates/df-nodes/src/notify_node.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! 通知节点 — desktop(本地日志,后续接 tauri-plugin-notification) / webhook
|
||||
//!
|
||||
//! - type=desktop:tracing::info! 输出(桌面通知集成延后,见 PROGRESS.md 后续 Sprint)
|
||||
//! - type=webhook:POST JSON {title, message} 到 webhook_url
|
||||
//!
|
||||
//! 节点语义:尽力而为,通知失败不阻断工作流(webhook 发送失败时输出 success=false 但仍 Ok 返回)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// 通知节点
|
||||
pub struct NotifyNode;
|
||||
|
||||
/// 通知类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NotifyType {
|
||||
Desktop,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
impl NotifyType {
|
||||
/// 从 config.type 字符串解析,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"desktop" => Ok(NotifyType::Desktop),
|
||||
"webhook" => Ok(NotifyType::Webhook),
|
||||
other => anyhow::bail!(
|
||||
"NotifyNode 非法 type: {}(合法值: desktop|webhook)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NotifyType::Desktop => "desktop",
|
||||
NotifyType::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 NotifyNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起网络请求)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotifyParams {
|
||||
pub notify_type: NotifyType,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
/// webhook 类型必填;desktop 类型忽略
|
||||
pub webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 NotifyParams。type 必填;webhook 类型要求 webhook_url。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<NotifyParams> {
|
||||
let raw_type = config
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("NotifyNode 缺少必填参数: type"))?;
|
||||
let notify_type = NotifyType::parse(raw_type)?;
|
||||
|
||||
let title = config
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("通知")
|
||||
.to_string();
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let webhook_url = config
|
||||
.get("webhook_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// webhook 类型必须有 webhook_url
|
||||
if notify_type == NotifyType::Webhook {
|
||||
if webhook_url.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
|
||||
anyhow::bail!("NotifyNode type=webhook 缺少 webhook_url");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NotifyParams {
|
||||
notify_type,
|
||||
title,
|
||||
message,
|
||||
webhook_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for NotifyNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("NotifyNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
|
||||
match params.notify_type {
|
||||
NotifyType::Desktop => {
|
||||
// 桌面通知集成延后;当前仅日志,后续接 tauri-plugin-notification。
|
||||
tracing::info!(
|
||||
title = %params.title,
|
||||
message = %params.message,
|
||||
"NotifyNode desktop 通知(日志占位,集成待后续 Sprint)"
|
||||
);
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"type": params.notify_type.as_str(),
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
"delivered": true,
|
||||
})))
|
||||
}
|
||||
NotifyType::Webhook => {
|
||||
// webhook_url 在 parse_params 已校验非空
|
||||
let url = params.webhook_url.as_deref().unwrap();
|
||||
tracing::info!(
|
||||
title = %params.title,
|
||||
url = %url,
|
||||
"NotifyNode webhook 推送"
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()?;
|
||||
|
||||
// 尽力而为:发送失败不阻断工作流,降级为 success=false + error 字段返回。
|
||||
let (status_code, success, error) = match client
|
||||
.post(url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let code = resp.status().as_u16();
|
||||
(Some(code), code >= 200 && code < 300, None)
|
||||
}
|
||||
Err(e) => (None, false, Some(e.to_string())),
|
||||
};
|
||||
|
||||
if !success {
|
||||
tracing::warn!(
|
||||
url = %url,
|
||||
status_code = ?status_code,
|
||||
error = ?error,
|
||||
"NotifyNode webhook 发送失败(不阻断工作流)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"type": params.notify_type.as_str(),
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
"webhook_url": url,
|
||||
"status_code": status_code,
|
||||
"success": success,
|
||||
"error": error,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["desktop", "webhook"]
|
||||
},
|
||||
"title": { "type": "string", "description": "通知标题" },
|
||||
"message": { "type": "string", "description": "通知正文" },
|
||||
"webhook_url": { "type": "string", "description": "webhook URL(webhook 类型必填)" }
|
||||
},
|
||||
"required": ["type"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"delivered": { "type": "boolean" },
|
||||
"webhook_url": { "type": "string" },
|
||||
"status_code": { "type": "integer" },
|
||||
"success": { "type": "boolean" },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"notify"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── NotifyType::parse ──
|
||||
|
||||
#[test]
|
||||
fn type_parses_desktop_and_webhook() {
|
||||
assert_eq!(NotifyType::parse("desktop").unwrap(), NotifyType::Desktop);
|
||||
assert_eq!(NotifyType::parse("webhook").unwrap(), NotifyType::Webhook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_invalid_errors() {
|
||||
let err = NotifyType::parse("email").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 type"), "实际: {}", err);
|
||||
assert!(err.contains("email"));
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_type_errors() {
|
||||
let err = parse_params(&json!({ "title": "t" })).unwrap_err().to_string();
|
||||
assert!(err.contains("type"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_invalid_type_errors() {
|
||||
let err = parse_params(&json!({ "type": "sms" })).unwrap_err().to_string();
|
||||
assert!(err.contains("非法 type"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_desktop_defaults_title_and_message() {
|
||||
let p = parse_params(&json!({ "type": "desktop" })).unwrap();
|
||||
assert_eq!(p.notify_type, NotifyType::Desktop);
|
||||
assert_eq!(p.title, "通知"); // 默认标题
|
||||
assert_eq!(p.message, ""); // 默认空正文
|
||||
assert_eq!(p.webhook_url, None); // desktop 不需要
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_desktop_with_custom_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"type": "desktop",
|
||||
"title": "构建完成",
|
||||
"message": "已发布 v1.0"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.title, "构建完成");
|
||||
assert_eq!(p.message, "已发布 v1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_requires_url() {
|
||||
let err = parse_params(&json!({ "type": "webhook" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("webhook_url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_empty_url_errors() {
|
||||
let err = parse_params(&json!({ "type": "webhook", "webhook_url": " " }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("webhook_url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_valid() {
|
||||
let p = parse_params(&json!({
|
||||
"type": "webhook",
|
||||
"title": "告警",
|
||||
"message": "CPU > 90%",
|
||||
"webhook_url": "https://hooks.example.com/x"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.notify_type, NotifyType::Webhook);
|
||||
assert_eq!(p.title, "告警");
|
||||
assert_eq!(p.message, "CPU > 90%");
|
||||
assert_eq!(p.webhook_url.as_deref(), Some("https://hooks.example.com/x"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,23 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
/// 脚本节点
|
||||
pub struct ScriptNode;
|
||||
|
||||
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
|
||||
static RUNTIME_SCRIPT_WHITELIST: OnceLock<String> = OnceLock::new();
|
||||
/// 运行时黑名单配置(前端设置页写入,优先于环境变量)
|
||||
static RUNTIME_SCRIPT_BLACKLIST: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// 注入运行时脚本安全配置(由 Tauri IPC 调用,前端设置页保存后触发)。
|
||||
/// 空字符串表示「未配置」,回退到环境变量 / 默认行为。
|
||||
pub fn set_script_safety_config(whitelist: &str, blacklist: &str) {
|
||||
let wl = whitelist.trim();
|
||||
let bl = blacklist.trim();
|
||||
if !wl.is_empty() { let _ = RUNTIME_SCRIPT_WHITELIST.set(wl.to_string()); }
|
||||
if !bl.is_empty() { let _ = RUNTIME_SCRIPT_BLACKLIST.set(bl.to_string()); }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for ScriptNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
@@ -39,6 +56,32 @@ impl Node for ScriptNode {
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
|
||||
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
|
||||
// - 黑名单匹配时:直接拒绝执行
|
||||
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
if let Some(denied) = check_command_policy(&cmd_name) {
|
||||
tracing::warn!(
|
||||
command = %command,
|
||||
reason = %denied,
|
||||
"ScriptNode 命令被策略拒绝"
|
||||
);
|
||||
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
|
||||
}
|
||||
|
||||
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
|
||||
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
|
||||
for &kw in &dangerous_keywords {
|
||||
if command.contains(kw) {
|
||||
tracing::warn!(
|
||||
keyword = %kw,
|
||||
command = %command,
|
||||
"ScriptNode 即将执行包含危险关键词的命令"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("ScriptNode 执行命令: {}", command);
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
@@ -93,3 +136,67 @@ impl Node for ScriptNode {
|
||||
"script"
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
|
||||
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
|
||||
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
|
||||
|
||||
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
|
||||
///
|
||||
/// 优先级(高→低):
|
||||
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
|
||||
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
|
||||
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
|
||||
///
|
||||
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
|
||||
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令在黑名单中(运行时配置)");
|
||||
}
|
||||
}
|
||||
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查
|
||||
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令不在白名单中(运行时配置)");
|
||||
}
|
||||
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
|
||||
if !list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:回退到环境变量(兼容旧部署)
|
||||
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
|
||||
let blacklist: Vec<String> = match env_blacklist {
|
||||
Some(raw) => raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
|
||||
};
|
||||
if blacklist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令在黑名单中");
|
||||
}
|
||||
|
||||
// 白名单:非空时命令名必须在白名单中才放行
|
||||
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
|
||||
let whitelist: Vec<String> = whitelist_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令不在白名单中");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
175
crates/df-nodes/src/subflow_node.rs
Normal file
175
crates/df-nodes/src/subflow_node.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! SubflowNode — 嵌套子工作流节点
|
||||
//!
|
||||
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
|
||||
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::dag_def::DagDef;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SubflowNode 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SubflowConfig {
|
||||
/// 子工作流 DAG 定义(内联 JSON)
|
||||
dag: serde_json::Value,
|
||||
/// 嵌套深度限制(防无限递归,默认 MAX_DEPTH)
|
||||
#[serde(default)]
|
||||
max_depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// 最大递归深度(安全阀)
|
||||
const MAX_DEPTH: u32 = 10;
|
||||
|
||||
/// SubflowNode — 嵌套子工作流
|
||||
pub struct SubflowNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SubflowNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("SubflowNode 执行: {}", ctx.node_id);
|
||||
|
||||
let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
|
||||
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
|
||||
|
||||
let max_depth = config.max_depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
|
||||
|
||||
// 反序列化子 DAG
|
||||
let sub_dag: DagDef = serde_json::from_value(config.dag)
|
||||
.map_err(|e| anyhow::anyhow!("子工作流 DAG 解析失败: {}", e))?;
|
||||
|
||||
if sub_dag.nodes.is_empty() {
|
||||
anyhow::bail!("子工作流节点不能为空");
|
||||
}
|
||||
|
||||
if max_depth == 0 {
|
||||
anyhow::bail!("SubflowNode 超过最大嵌套深度");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
node_count = sub_dag.nodes.len(),
|
||||
edge_count = sub_dag.edges.len(),
|
||||
max_depth,
|
||||
"SubflowNode 加载子工作流"
|
||||
);
|
||||
|
||||
// 返回子 DAG 的 JSON 快照供 DagExecutor 消费
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"subflow": true,
|
||||
"node_count": sub_dag.nodes.len(),
|
||||
"edge_count": sub_dag.edges.len(),
|
||||
"max_depth": max_depth,
|
||||
"dag": sub_dag,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dag": {
|
||||
"type": "object",
|
||||
"description": "子工作流 DAG 定义(nodes + edges)",
|
||||
"properties": {
|
||||
"nodes": { "type": "object" },
|
||||
"edges": { "type": "array" }
|
||||
}
|
||||
},
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "最大嵌套深度(默认10)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["dag"]
|
||||
}),
|
||||
output: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &'static str {
|
||||
"subflow"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::state::StateMachine;
|
||||
|
||||
fn make_ctx(config: serde_json::Value) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: "test_subflow".into(),
|
||||
inputs: Default::default(),
|
||||
config,
|
||||
execution_id: "exec_1".into(),
|
||||
event_bus: EventBus::new(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_01_valid_dag_returns_metadata() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": {
|
||||
"nodes": {
|
||||
"child": {
|
||||
"id": "child",
|
||||
"node_type": "script",
|
||||
"config": {}
|
||||
}
|
||||
},
|
||||
"edges": []
|
||||
}
|
||||
}));
|
||||
let result = node.execute(ctx).await.unwrap();
|
||||
assert_eq!(result.data["subflow"], true);
|
||||
assert_eq!(result.data["node_count"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_02_empty_dag_rejected() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": { "nodes": {}, "edges": [] }
|
||||
}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("不能为空"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_03_max_depth_zero_guard() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": {
|
||||
"nodes": { "c": { "id": "c", "node_type": "script", "config": {} } },
|
||||
"edges": []
|
||||
},
|
||||
"max_depth": 0
|
||||
}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("最大嵌套深度"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_04_missing_dag_field_errors() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_05_schema_has_required_fields() {
|
||||
let node = SubflowNode;
|
||||
let schema = node.schema();
|
||||
let params = &schema.params;
|
||||
assert!(params["properties"]["dag"].is_object());
|
||||
assert!(params["required"].as_array().unwrap().contains(&serde_json::json!("dag")));
|
||||
}
|
||||
}
|
||||
@@ -174,14 +174,15 @@ mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::{ProjectStatus, TaskStatus};
|
||||
|
||||
fn rec(id: &str, status: &str) -> TaskRecord {
|
||||
fn rec(id: &str, status: TaskStatus) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("t-{id}"),
|
||||
description: "".to_string(),
|
||||
status: status.to_string(),
|
||||
status,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
@@ -190,6 +191,9 @@ mod tests {
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
@@ -203,7 +207,7 @@ mod tests {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
@@ -218,43 +222,43 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn forward_path_todo_to_done() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 主路径逐级推进
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
assert_eq!(r.status.as_str(), "testing");
|
||||
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.status.as_str(), "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_testing_to_in_review_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "testing")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_regressions_accumulate() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
|
||||
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
|
||||
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
@@ -266,7 +270,7 @@ mod tests {
|
||||
async fn illegal_skip_rejected() {
|
||||
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, expected } => {
|
||||
@@ -282,7 +286,7 @@ mod tests {
|
||||
async fn terminal_done_no_successor() {
|
||||
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "done")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::InvalidState { current, .. } => {
|
||||
@@ -298,7 +302,7 @@ mod tests {
|
||||
// 同态属空操作,归 Validation「相同状态,无需推进」;
|
||||
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||
match err {
|
||||
df_types::error::Error::Validation(msg) => {
|
||||
@@ -311,7 +315,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn invalid_target_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
|
||||
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
||||
}
|
||||
@@ -329,7 +333,7 @@ mod tests {
|
||||
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
|
||||
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
|
||||
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
|
||||
repo.advance_status_atomic("t1", "todo", "in_progress", false)
|
||||
@@ -337,13 +341,13 @@ mod tests {
|
||||
.unwrap();
|
||||
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cas_returns_none_when_status_mismatch() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
||||
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "in_review", false)
|
||||
@@ -362,12 +366,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn blocked_round_trip_does_not_bump() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
|
||||
assert_eq!(r.status, "blocked");
|
||||
assert_eq!(r.status.as_str(), "blocked");
|
||||
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
||||
}
|
||||
|
||||
@@ -457,24 +461,24 @@ mod tests {
|
||||
async fn callback_completed_advance_lands_in_db() {
|
||||
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("c1", "todo")).await.unwrap();
|
||||
repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
|
||||
let to = callback_advance_target("completed", "in_progress").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
|
||||
|
||||
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
|
||||
repo.insert(rec("c2", "in_review")).await.unwrap();
|
||||
repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
|
||||
let to = callback_advance_target("completed", "testing").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
assert_eq!(r.status.as_str(), "testing");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
|
||||
// done: testing → done(②-3 done 模板最终核对通过)
|
||||
repo.insert(rec("c3", "testing")).await.unwrap();
|
||||
repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
|
||||
let to = callback_advance_target("completed", "done").unwrap();
|
||||
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.status.as_str(), "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
@@ -484,32 +488,32 @@ mod tests {
|
||||
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
|
||||
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("f1", "testing")).await.unwrap();
|
||||
repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
|
||||
let to = callback_advance_target("failed", "testing").unwrap();
|
||||
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
|
||||
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.status.as_str(), "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
|
||||
|
||||
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
|
||||
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
|
||||
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
|
||||
repo.insert(rec("f2", "in_review")).await.unwrap();
|
||||
repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
|
||||
let to = callback_advance_target("failed", "in_review").unwrap();
|
||||
assert_eq!(to, "in_progress");
|
||||
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.status.as_str(), "in_progress");
|
||||
assert_eq!(r.review_rounds, 1);
|
||||
|
||||
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
|
||||
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
|
||||
// 验证:callback 返回 None,不调 advance_task_atomic。
|
||||
repo.insert(rec("f3", "in_progress")).await.unwrap();
|
||||
repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
|
||||
let to = callback_advance_target("failed", "in_progress");
|
||||
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
|
||||
// 任务状态未被改动(仍是 in_progress)
|
||||
let still = repo.get_by_id("f3").await.unwrap().unwrap();
|
||||
assert_eq!(still.status, "in_progress", "None 退回不应改动 status");
|
||||
assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
|
||||
}
|
||||
|
||||
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
|
||||
|
||||
@@ -22,27 +22,29 @@
|
||||
//! - cancelled → 终态
|
||||
|
||||
// ============================================================
|
||||
// 状态字符串常量 — 与 df-types::TaskStatus::as_str 一一对应
|
||||
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
|
||||
// ============================================================
|
||||
//
|
||||
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
|
||||
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
|
||||
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
|
||||
// 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
|
||||
// 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
|
||||
// as_str 输出,本模块常量自动同步,编译期即可发现柡移。
|
||||
|
||||
use df_types::types::TaskStatus;
|
||||
|
||||
/// 待开始
|
||||
pub const TODO: &str = "todo";
|
||||
pub const TODO: &str = TaskStatus::Todo.as_str();
|
||||
/// 进行中
|
||||
pub const IN_PROGRESS: &str = "in_progress";
|
||||
pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
|
||||
/// 代码审查中
|
||||
pub const IN_REVIEW: &str = "in_review";
|
||||
pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
|
||||
/// 测试中
|
||||
pub const TESTING: &str = "testing";
|
||||
pub const TESTING: &str = TaskStatus::Testing.as_str();
|
||||
/// 已完成(终态)
|
||||
pub const DONE: &str = "done";
|
||||
pub const DONE: &str = TaskStatus::Done.as_str();
|
||||
/// 已阻塞
|
||||
pub const BLOCKED: &str = "blocked";
|
||||
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
|
||||
/// 已取消(终态)
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
|
||||
|
||||
/// 全部合法状态值(供输入校验与错误提示复用)
|
||||
pub const ALL_STATES: &[&str] = &[
|
||||
|
||||
@@ -11,3 +11,4 @@ tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! 项目管理器 — 项目的 CRUD 与生命周期管理
|
||||
//! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束)
|
||||
//!
|
||||
//! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。
|
||||
//! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。
|
||||
//!
|
||||
//! 调用方(src-tauri commands/project.rs)推进项目状态时必须经
|
||||
//! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 项目管理器
|
||||
/// 项目状态机错误
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectTransitionError {
|
||||
#[error("项目状态转换非法: {from:?} → {to:?}")]
|
||||
IllegalTransition {
|
||||
from: ProjectStatus,
|
||||
to: ProjectStatus,
|
||||
},
|
||||
#[error("项目名称不能为空")]
|
||||
EmptyName,
|
||||
#[error("项目已处在终态 {0:?},不可再推进")]
|
||||
TerminalState(ProjectStatus),
|
||||
}
|
||||
|
||||
/// 项目管理器(无状态纯逻辑,实体构造 + 状态机 + 业务约束)
|
||||
pub struct ProjectManager;
|
||||
|
||||
impl ProjectManager {
|
||||
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
|
||||
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
|
||||
pub fn create(input: CreateProjectInput) -> Project {
|
||||
pub fn create(input: CreateProjectInput) -> Result<Project, ProjectTransitionError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ProjectTransitionError::EmptyName);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
Project {
|
||||
Ok(Project {
|
||||
id: df_types::types::new_id(),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -57,11 +80,15 @@ impl ProjectManager {
|
||||
tags: input.tags,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 从想法创建项目
|
||||
pub fn create_from_idea(name: String, description: String, idea_id: IdeaId) -> Project {
|
||||
pub fn create_from_idea(
|
||||
name: String,
|
||||
description: String,
|
||||
idea_id: IdeaId,
|
||||
) -> Result<Project, ProjectTransitionError> {
|
||||
Self::create(CreateProjectInput {
|
||||
name,
|
||||
description,
|
||||
@@ -71,4 +98,118 @@ impl ProjectManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// 判断项目状态转换是否合法(状态机核心)。
|
||||
///
|
||||
/// 项目状态语义(与 ARCHITECTURE.md 一致):
|
||||
/// - Planning → InProgress(开始), Cancelled(取消)
|
||||
/// - InProgress → Testing(提交测试), Paused(暂停), Cancelled
|
||||
/// - Testing → Completed(测试通过), InProgress(退回开发), Cancelled
|
||||
/// - Completed → 终态(不可变)
|
||||
/// - Paused → InProgress(恢复), Cancelled
|
||||
/// - Cancelled → 终态
|
||||
pub fn can_transition(from: &ProjectStatus, to: &ProjectStatus) -> bool {
|
||||
use ProjectStatus::*;
|
||||
matches!((from, to),
|
||||
(Planning, InProgress) | (Planning, Cancelled)
|
||||
| (InProgress, Testing) | (InProgress, Paused) | (InProgress, Cancelled)
|
||||
| (Testing, Completed) | (Testing, InProgress) | (Testing, Cancelled)
|
||||
| (Paused, InProgress) | (Paused, Cancelled)
|
||||
)
|
||||
}
|
||||
|
||||
/// 执行状态转换,返回新状态或非法错误。
|
||||
///
|
||||
/// 调用方(如 IPC `update_project_status` / `advance_project`)应用本方法校验后
|
||||
/// 再写 storage,防跳态(如 Planning → Completed 跳过 Testing)。
|
||||
pub fn transition(
|
||||
from: ProjectStatus,
|
||||
to: ProjectStatus,
|
||||
) -> Result<ProjectStatus, ProjectTransitionError> {
|
||||
if !Self::can_transition(&from, &to) {
|
||||
// 区分错误: 终态→任何 vs 一般非法
|
||||
if matches!(from, ProjectStatus::Completed | ProjectStatus::Cancelled) {
|
||||
return Err(ProjectTransitionError::TerminalState(from));
|
||||
}
|
||||
return Err(ProjectTransitionError::IllegalTransition { from, to });
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// 是否为终态(不可再转换)
|
||||
pub fn is_terminal(s: &ProjectStatus) -> bool {
|
||||
matches!(s, ProjectStatus::Completed | ProjectStatus::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_rejects_empty_name() {
|
||||
let err = ProjectManager::create(CreateProjectInput {
|
||||
name: " ".into(),
|
||||
description: String::new(),
|
||||
idea_id: None,
|
||||
priority: Priority::default(),
|
||||
tags: vec![],
|
||||
}).unwrap_err();
|
||||
assert!(matches!(err, ProjectTransitionError::EmptyName));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_path_planning_to_completed() {
|
||||
use ProjectStatus::*;
|
||||
assert!(ProjectManager::can_transition(&Planning, &InProgress));
|
||||
assert!(ProjectManager::can_transition(&InProgress, &Testing));
|
||||
assert!(ProjectManager::can_transition(&Testing, &Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_skips_rejected() {
|
||||
use ProjectStatus::*;
|
||||
assert!(!ProjectManager::can_transition(&Planning, &Completed));
|
||||
assert!(!ProjectManager::can_transition(&Planning, &Testing));
|
||||
assert!(!ProjectManager::can_transition(&InProgress, &Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states_block_all() {
|
||||
use ProjectStatus::*;
|
||||
for term in [Completed, Cancelled] {
|
||||
for to in [Planning, InProgress, Testing, Paused] {
|
||||
assert!(!ProjectManager::can_transition(&term, &to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_round_trip() {
|
||||
use ProjectStatus::*;
|
||||
assert!(ProjectManager::can_transition(&InProgress, &Paused));
|
||||
assert!(ProjectManager::can_transition(&Paused, &InProgress));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_returns_target_on_legal() {
|
||||
use ProjectStatus::*;
|
||||
assert_eq!(
|
||||
ProjectManager::transition(Planning, InProgress).unwrap(),
|
||||
InProgress
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_errors_on_illegal() {
|
||||
use ProjectStatus::*;
|
||||
let err = ProjectManager::transition(Planning, Completed).unwrap_err();
|
||||
assert!(matches!(err, ProjectTransitionError::IllegalTransition { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_errors_on_terminal_source() {
|
||||
use ProjectStatus::*;
|
||||
let err = ProjectManager::transition(Completed, InProgress).unwrap_err();
|
||||
assert!(matches!(err, ProjectTransitionError::TerminalState(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,741 +0,0 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
//!
|
||||
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
|
||||
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[path = "scan_helpers.rs"]
|
||||
mod scan_helpers;
|
||||
pub use scan_helpers::ImageRef;
|
||||
use scan_helpers::{collect_images, strip_readme_noise};
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
|
||||
// ============================================================
|
||||
|
||||
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
|
||||
const MONOREPO_MARKERS: &[&str] = &[
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"turbo.json",
|
||||
"nx.json",
|
||||
];
|
||||
|
||||
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
|
||||
///
|
||||
/// 命中任一即视为 monorepo:
|
||||
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
|
||||
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
|
||||
pub fn is_monorepo(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
for marker in MONOREPO_MARKERS {
|
||||
if root.join(marker).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
|
||||
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
|
||||
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
|
||||
let pkg_path = root.join("package.json");
|
||||
if pkg_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 单个发现的候选项目(monorepo 子项目或独立项目)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredProject {
|
||||
/// 项目根目录绝对路径
|
||||
pub path: String,
|
||||
/// 推断的项目名(目录名)
|
||||
pub name: String,
|
||||
/// 规则探测的技术栈(空=未识别)
|
||||
pub stack: Vec<String>,
|
||||
/// 是否为 monorepo 根(便于前端标记)
|
||||
pub is_monorepo: bool,
|
||||
}
|
||||
|
||||
/// 在指定根目录下发现候选项目。
|
||||
///
|
||||
/// 策略(只展开一层,不做深递归):
|
||||
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
|
||||
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
|
||||
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
|
||||
///
|
||||
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
|
||||
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
|
||||
let mut out: Vec<DiscoveredProject> = Vec::new();
|
||||
let mono = is_monorepo(root);
|
||||
|
||||
// 1. 根目录自身是项目(有 manifest 标志)
|
||||
if has_project_manifest(root) {
|
||||
let stack = detect_stack(root).unwrap_or_default();
|
||||
out.push(DiscoveredProject {
|
||||
path: root.to_string_lossy().to_string(),
|
||||
name: root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| root.to_string_lossy().to_string()),
|
||||
stack,
|
||||
is_monorepo: mono,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. monorepo → 展开 packages/* apps/* 直接子目录
|
||||
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
|
||||
let scan_globs: &[&str] = if mono {
|
||||
&["packages", "apps"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
|
||||
for glob in scan_globs {
|
||||
let target = if glob.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(glob)
|
||||
};
|
||||
if !target.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&target) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 必须有项目标志 + detect_stack 非空
|
||||
if !has_project_manifest(&p) {
|
||||
continue;
|
||||
}
|
||||
let stack = match detect_stack(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if stack.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(DiscoveredProject {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
name,
|
||||
stack,
|
||||
is_monorepo: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 目录是否含任一项目清单标志文件
|
||||
fn has_project_manifest(dir: &Path) -> bool {
|
||||
const MARKS: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
];
|
||||
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)
|
||||
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
|
||||
// ============================================================
|
||||
|
||||
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
|
||||
///
|
||||
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme_raw(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
if started {
|
||||
break; // 段落结束
|
||||
}
|
||||
continue; // 首段尚未开始,跳过开头空行
|
||||
}
|
||||
// 段落开始后不再跳行,直接累加
|
||||
if !started {
|
||||
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
|
||||
if line.starts_with('#')
|
||||
|| line.starts_with("<!--")
|
||||
|| line.starts_with('!')
|
||||
|| line.starts_with('<')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let desc = text.trim().to_string();
|
||||
if desc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
|
||||
}
|
||||
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
/// README 内的内容图引用(架构图/截图等,跳徽章)
|
||||
pub images: Vec<ImageRef>,
|
||||
}
|
||||
|
||||
const SAMPLE_README_MAX: usize = 8000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||
const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 读 README 原始内容(不做处理)
|
||||
fn read_readme_raw(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let raw = read_readme_raw(root);
|
||||
let (readme, images) = match raw {
|
||||
Some(text) => {
|
||||
let images = collect_images(&text);
|
||||
let cleaned = strip_readme_noise(&text);
|
||||
if cleaned.trim().is_empty() {
|
||||
(None, images)
|
||||
} else {
|
||||
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
|
||||
}
|
||||
}
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Ok(ProjectSample {
|
||||
readme,
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# Test\n\n\n\n\n\nA test project.\nMore.",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
// 徽章行剥,内容图 + 正文保留
|
||||
let readme = s.readme.as_deref().unwrap_or("");
|
||||
assert!(readme.contains("test project"), "readme={readme}");
|
||||
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
|
||||
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
|
||||
// 内容图收集(badge 跳,arch 留)
|
||||
assert_eq!(s.images.len(), 1);
|
||||
assert_eq!(s.images[0].src, "./docs/arch.png");
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_frontmatter_and_toc_and_badges() {
|
||||
let d = scratch("noise");
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
fs::write(d.join("README.md"), readme).unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
let r = s.readme.as_deref().unwrap_or("");
|
||||
assert!(r.contains("# Foo"), "标题应保留: {r}");
|
||||
assert!(r.contains("real intro"), "正文应保留: {r}");
|
||||
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
|
||||
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
|
||||
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
|
||||
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
|
||||
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_pnpm() {
|
||||
let d = scratch("mono-pnpm");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_npm_workspaces() {
|
||||
let d = scratch("mono-npm");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"root","workspaces":["packages/*"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_monorepo() {
|
||||
let d = scratch("nonmono");
|
||||
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
|
||||
assert!(!is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_monorepo_children() {
|
||||
let d = scratch("discover-mono");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
|
||||
fs::create_dir_all(d.join("packages/web")).unwrap();
|
||||
fs::write(
|
||||
d.join("packages/web/package.json"),
|
||||
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(d.join("packages/cli")).unwrap();
|
||||
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
|
||||
// 空 stack 子目录应过滤
|
||||
fs::create_dir_all(d.join("packages/empty")).unwrap();
|
||||
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"web"), "names={names:?}");
|
||||
assert!(names.contains(&"cli"), "names={names:?}");
|
||||
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_flat_children() {
|
||||
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
|
||||
let d = scratch("discover-flat");
|
||||
fs::create_dir_all(d.join("proj-a")).unwrap();
|
||||
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
|
||||
fs::create_dir_all(d.join("not-a-project")).unwrap();
|
||||
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"proj-a"), "names={names:?}");
|
||||
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
// 标题 + badge 图片行应跳过,首段为正文
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# My Project\n\n\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
|
||||
)
|
||||
.unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
assert!(desc.contains("示例项目"));
|
||||
assert!(desc.contains("第二行正文"));
|
||||
assert!(!desc.contains("My Project"));
|
||||
assert!(!desc.contains("badge"));
|
||||
assert!(!desc.contains("安装"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_no_readme_returns_none() {
|
||||
let d = scratch("nodesc");
|
||||
assert!(extract_description(&d).is_none());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_truncates_long() {
|
||||
let d = scratch("longdesc");
|
||||
let long = "啊".repeat(500);
|
||||
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
// 截断到 200 字 + 末尾「…(已截断)」标记
|
||||
assert!(desc.chars().count() <= 210);
|
||||
assert!(desc.contains("已截断"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
// ── normalize_path 跨平台归一化 ──
|
||||
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
|
||||
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
|
||||
// 不断言精确串,避免与平台耦合。
|
||||
#[test]
|
||||
fn normalize_path_uses_forward_slash_and_lowercase() {
|
||||
// 不存在的路径走降级分支(trim + replace + lowercase)
|
||||
let n = normalize_path(r"C:\Foo\Bar\");
|
||||
assert!(
|
||||
!n.contains('\\'),
|
||||
"反斜杠未归一: {n}"
|
||||
);
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
assert!(
|
||||
!n.ends_with('/') && !n.ends_with('\\'),
|
||||
"尾部分隔符未裁剪: {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_equivalent_inputs_equal() {
|
||||
// 两种写法应归一为同一串(去重场景)
|
||||
let a = normalize_path(r"C:\Foo\Bar");
|
||||
let b = normalize_path("C:/Foo/Bar/");
|
||||
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_relative_is_lowercase_and_forward_slash() {
|
||||
let n = normalize_path(r"..\Some\Path");
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
}
|
||||
|
||||
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
|
||||
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_null_not_misclassified() {
|
||||
let d = scratch("ws-null");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"x","workspaces":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!is_monorepo(&d),
|
||||
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
|
||||
);
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_object_treated_as_real() {
|
||||
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
|
||||
let d = scratch("ws-obj");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_non_dir_returns_false() {
|
||||
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
|
||||
}
|
||||
}
|
||||
276
crates/df-project/src/scan/discover.rs
Normal file
276
crates/df-project/src/scan/discover.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::stack::{detect_stack, has_file_with_ext};
|
||||
use super::SAMPLE_IGNORED_DIRS;
|
||||
|
||||
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
|
||||
const MONOREPO_MARKERS: &[&str] = &[
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"turbo.json",
|
||||
"nx.json",
|
||||
];
|
||||
|
||||
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
|
||||
///
|
||||
/// 命中任一即视为 monorepo:
|
||||
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
|
||||
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
|
||||
pub fn is_monorepo(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
for marker in MONOREPO_MARKERS {
|
||||
if root.join(marker).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
|
||||
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
|
||||
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
|
||||
let pkg_path = root.join("package.json");
|
||||
if pkg_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 单个发现的候选项目(monorepo 子项目或独立项目)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredProject {
|
||||
/// 项目根目录绝对路径
|
||||
pub path: String,
|
||||
/// 推断的项目名(目录名)
|
||||
pub name: String,
|
||||
/// 规则探测的技术栈(空=未识别)
|
||||
pub stack: Vec<String>,
|
||||
/// 是否为 monorepo 根(便于前端标记)
|
||||
pub is_monorepo: bool,
|
||||
}
|
||||
|
||||
/// 在指定根目录下发现候选项目。
|
||||
///
|
||||
/// 策略(只展开一层,不做深递归):
|
||||
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
|
||||
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
|
||||
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
|
||||
///
|
||||
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
|
||||
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
|
||||
let mut out: Vec<DiscoveredProject> = Vec::new();
|
||||
let mono = is_monorepo(root);
|
||||
|
||||
// 1. 根目录自身是项目(有 manifest 标志)
|
||||
if has_project_manifest(root) {
|
||||
let stack = detect_stack(root).unwrap_or_default();
|
||||
out.push(DiscoveredProject {
|
||||
path: root.to_string_lossy().to_string(),
|
||||
name: root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| root.to_string_lossy().to_string()),
|
||||
stack,
|
||||
is_monorepo: mono,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. monorepo → 展开 packages/* apps/* 直接子目录
|
||||
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
|
||||
let scan_globs: &[&str] = if mono {
|
||||
&["packages", "apps"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
|
||||
for glob in scan_globs {
|
||||
let target = if glob.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(glob)
|
||||
};
|
||||
if !target.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&target) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 必须有项目标志 + detect_stack 非空
|
||||
if !has_project_manifest(&p) {
|
||||
continue;
|
||||
}
|
||||
let stack = match detect_stack(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if stack.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(DiscoveredProject {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
name,
|
||||
stack,
|
||||
is_monorepo: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 目录是否含任一项目清单标志文件
|
||||
fn has_project_manifest(dir: &Path) -> bool {
|
||||
const MARKS: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
];
|
||||
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_pnpm() {
|
||||
let d = scratch("mono-pnpm");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_npm_workspaces() {
|
||||
let d = scratch("mono-npm");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"root","workspaces":["packages/*"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_monorepo() {
|
||||
let d = scratch("nonmono");
|
||||
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
|
||||
assert!(!is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_monorepo_children() {
|
||||
let d = scratch("discover-mono");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
|
||||
fs::create_dir_all(d.join("packages/web")).unwrap();
|
||||
fs::write(
|
||||
d.join("packages/web/package.json"),
|
||||
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(d.join("packages/cli")).unwrap();
|
||||
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
|
||||
// 空 stack 子目录应过滤
|
||||
fs::create_dir_all(d.join("packages/empty")).unwrap();
|
||||
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"web"), "names={names:?}");
|
||||
assert!(names.contains(&"cli"), "names={names:?}");
|
||||
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_flat_children() {
|
||||
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
|
||||
let d = scratch("discover-flat");
|
||||
fs::create_dir_all(d.join("proj-a")).unwrap();
|
||||
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
|
||||
fs::create_dir_all(d.join("not-a-project")).unwrap();
|
||||
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"proj-a"), "names={names:?}");
|
||||
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
|
||||
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_null_not_misclassified() {
|
||||
let d = scratch("ws-null");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"x","workspaces":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!is_monorepo(&d),
|
||||
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
|
||||
);
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_object_treated_as_real() {
|
||||
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
|
||||
let d = scratch("ws-obj");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_non_dir_returns_false() {
|
||||
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
|
||||
}
|
||||
}
|
||||
114
crates/df-project/src/scan/mod.rs
Normal file
114
crates/df-project/src/scan/mod.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
//!
|
||||
//! ## 模块拆分(2026-06-22,REFACTOR-260619-10)
|
||||
//!
|
||||
//! 原 `scan.rs`(741 行)按职责拆为 4 子模块,纯文件重组、零行为变化:
|
||||
//! - [`stack`]:技术栈探测(`detect_stack` + package.json 解析)
|
||||
//! - [`discover`]:历史项目发现/monorepo 展开(`discover_projects`/`is_monorepo`)
|
||||
//! - [`readme`]:README 读取与首段抽取(`read_readme_raw`/`extract_description`)
|
||||
//! - [`sample`]:项目采样(`collect_sample` + 目录树/清单/噪音目录)
|
||||
//!
|
||||
//! 跨模块共享的常量/helper 留在本 `mod.rs`(`SAMPLE_IGNORED_DIRS`/`truncate_chars`),
|
||||
//! 子模块经 `use super::*` 引用。`pub use` 重导出保持外部 `df_project::scan::*` 路径不变。
|
||||
//!
|
||||
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
|
||||
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[path = "../scan_helpers.rs"]
|
||||
mod scan_helpers;
|
||||
pub use scan_helpers::ImageRef;
|
||||
|
||||
pub mod discover;
|
||||
pub mod readme;
|
||||
pub mod sample;
|
||||
pub mod stack;
|
||||
|
||||
// ── 对外稳定 API 重导出(外部 `df_project::scan::*` 路径不变)──
|
||||
pub use discover::{discover_projects, is_monorepo, DiscoveredProject};
|
||||
pub use readme::extract_description;
|
||||
pub use sample::{collect_sample, ProjectSample};
|
||||
pub use stack::detect_stack;
|
||||
|
||||
// ============================================================
|
||||
// 跨模块共享常量/helper
|
||||
// ============================================================
|
||||
|
||||
/// 目录树/项目发现过滤的噪音目录(依赖产物/构建/缓存/IDE)。
|
||||
///
|
||||
/// 同时被 [`discover`] 与 [`sample`] 引用,故置于本 `mod.rs`,`pub(super)` 暴露给子模块。
|
||||
pub(super) const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)。
|
||||
///
|
||||
/// 被 [`readme`](crate::scan::readme) 与 [`sample`](crate::scan::sample) 引用,故置于本 `mod.rs`。
|
||||
pub(super) fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── normalize_path 跨平台归一化 ──
|
||||
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
|
||||
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
|
||||
// 不断言精确串,避免与平台耦合。
|
||||
#[test]
|
||||
fn normalize_path_uses_forward_slash_and_lowercase() {
|
||||
// 不存在的路径走降级分支(trim + replace + lowercase)
|
||||
let n = normalize_path(r"C:\Foo\Bar\");
|
||||
assert!(
|
||||
!n.contains('\\'),
|
||||
"反斜杠未归一: {n}"
|
||||
);
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
assert!(
|
||||
!n.ends_with('/') && !n.ends_with('\\'),
|
||||
"尾部分隔符未裁剪: {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_equivalent_inputs_equal() {
|
||||
// 两种写法应归一为同一串(去重场景)
|
||||
let a = normalize_path(r"C:\Foo\Bar");
|
||||
let b = normalize_path("C:/Foo/Bar/");
|
||||
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_relative_is_lowercase_and_forward_slash() {
|
||||
let n = normalize_path(r"..\Some\Path");
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
}
|
||||
}
|
||||
122
crates/df-project/src/scan/readme.rs
Normal file
122
crates/df-project/src/scan/readme.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! README 读取与首段抽取 — 纯 IO + 文本规则。
|
||||
//!
|
||||
//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`)
|
||||
//! (`collect_sample`)共用,故 `pub(super)` 暴露。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::truncate_chars;
|
||||
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 读 README 原始内容(不做处理)。
|
||||
///
|
||||
/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等),
|
||||
/// 返回首个存在的文件内容。
|
||||
pub(super) fn read_readme_raw(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
|
||||
///
|
||||
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme_raw(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
if started {
|
||||
break; // 段落结束
|
||||
}
|
||||
continue; // 首段尚未开始,跳过开头空行
|
||||
}
|
||||
// 段落开始后不再跳行,直接累加
|
||||
if !started {
|
||||
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
|
||||
if line.starts_with('#')
|
||||
|| line.starts_with("<!--")
|
||||
|| line.starts_with('!')
|
||||
|| line.starts_with('<')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let desc = text.trim().to_string();
|
||||
if desc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
// 标题 + badge 图片行应跳过,首段为正文
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# My Project\n\n\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
|
||||
)
|
||||
.unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
assert!(desc.contains("示例项目"));
|
||||
assert!(desc.contains("第二行正文"));
|
||||
assert!(!desc.contains("My Project"));
|
||||
assert!(!desc.contains("badge"));
|
||||
assert!(!desc.contains("安装"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_no_readme_returns_none() {
|
||||
let d = scratch("nodesc");
|
||||
assert!(extract_description(&d).is_none());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_truncates_long() {
|
||||
let d = scratch("longdesc");
|
||||
let long = "啊".repeat(500);
|
||||
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
// 截断到 200 字 + 末尾「…(已截断)」标记
|
||||
assert!(desc.chars().count() <= 210);
|
||||
assert!(desc.contains("已截断"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
167
crates/df-project/src/scan/sample.rs
Normal file
167
crates/df-project/src/scan/sample.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
//! 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码。
|
||||
//!
|
||||
//! README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::readme::read_readme_raw;
|
||||
use super::scan_helpers::{collect_images, strip_readme_noise, ImageRef};
|
||||
use super::{truncate_chars, SAMPLE_IGNORED_DIRS};
|
||||
|
||||
const SAMPLE_README_MAX: usize = 8000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
|
||||
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
/// README 内的内容图引用(架构图/截图等,跳徽章)
|
||||
pub images: Vec<ImageRef>,
|
||||
}
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let raw = read_readme_raw(root);
|
||||
let (readme, images) = match raw {
|
||||
Some(text) => {
|
||||
let images = collect_images(&text);
|
||||
let cleaned = strip_readme_noise(&text);
|
||||
if cleaned.trim().is_empty() {
|
||||
(None, images)
|
||||
} else {
|
||||
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
|
||||
}
|
||||
}
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Ok(ProjectSample {
|
||||
readme,
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# Test\n\n\n\n\n\nA test project.\nMore.",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
// 徽章行剥,内容图 + 正文保留
|
||||
let readme = s.readme.as_deref().unwrap_or("");
|
||||
assert!(readme.contains("test project"), "readme={readme}");
|
||||
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
|
||||
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
|
||||
// 内容图收集(badge 跳,arch 留)
|
||||
assert_eq!(s.images.len(), 1);
|
||||
assert_eq!(s.images[0].src, "./docs/arch.png");
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_frontmatter_and_toc_and_badges() {
|
||||
let d = scratch("noise");
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
fs::write(d.join("README.md"), readme).unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
let r = s.readme.as_deref().unwrap_or("");
|
||||
assert!(r.contains("# Foo"), "标题应保留: {r}");
|
||||
assert!(r.contains("real intro"), "正文应保留: {r}");
|
||||
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
|
||||
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
|
||||
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
|
||||
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
|
||||
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
176
crates/df-project/src/scan/stack.rs
Normal file
176
crates/df-project/src/scan/stack.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
//! 技术栈探测 — 浅读根目录标志文件识别技术栈。
|
||||
//!
|
||||
//! 纯函数、零状态。读 `Cargo.toml`/`go.mod`/`package.json` 等标志,
|
||||
//! 不递归遍历(控制性能与安全)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)。
|
||||
///
|
||||
/// 被 `detect_stack`(C# csproj)与 `super::discover`(项目清单判定)共用,
|
||||
/// 故 `pub(super)`。
|
||||
pub(super) fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
21
crates/df-relay/Cargo.toml
Normal file
21
crates/df-relay/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "df-relay"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "跨端 AI Chat 云中继服务(axum WS Server,转发小程序 ↔ 桌面端)"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
# binary 入口(main.rs)日志初始化
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# HTTP + WS 服务端(axum ws 内部用 tungstenite 协议层;df-relay 不直接依赖 tokio-tungstenite。
|
||||
# TLS 由 nginx wss 终止,:9180 明文 ws → 去 native-tls 避 openssl 依赖,利 linux 交叉/容器编译)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
21
crates/df-relay/Dockerfile
Normal file
21
crates/df-relay/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# df-relay 跨端中继(Phase3 云端部署,u-work.1216.top)
|
||||
# multi-stage:rust:slim 编译 df-relay → debian:slim 运行(static-ish binary)
|
||||
#
|
||||
# 构建(context = repo 根,workspace):
|
||||
# docker build -f crates/df-relay/Dockerfile -t df-relay .
|
||||
# 运行(容器内 :8080,映射宿主 :9180,避开测试机 8080 占用):
|
||||
# docker run -d --name df-relay --restart unless-stopped -p 9180:8080 df-relay
|
||||
|
||||
FROM rust:1.85-slim AS builder
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN cargo build --release -p df-relay
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /build/target/release/df-relay /usr/local/bin/df-relay
|
||||
ENV DF_RELAY_ADDR=0.0.0.0:8080
|
||||
EXPOSE 8080
|
||||
CMD ["df-relay"]
|
||||
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;
|
||||
}
|
||||
}
|
||||
145
crates/df-relay/src/broadcast.rs
Normal file
145
crates/df-relay/src/broadcast.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! df-relay 广播消息骨架
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— 云后端纯转发,无业务逻辑。
|
||||
//! 中继消息骨架定义「谁发给谁」的路由语义:
|
||||
//! - `Event`:桌面端事件 → 广播给该 device 绑定的小程序
|
||||
//! - `Command`:小程序指令 → 转发给该 device 绑定的桌面端
|
||||
//! - `Control`:控制面消息(配对/心跳/在线状态)
|
||||
//!
|
||||
//! 与 df-tunnel::TunnelMessage 区分:
|
||||
//! - TunnelMessage 是隧道两端(桌面↔云)的传输单元
|
||||
//! - BroadcastMessage 是云后端内部的路由单元(标识来源/去向/广播范围)
|
||||
//!
|
||||
//! AiChatEvent JSON 透传:relay 不解析 AiChatEvent(类型在 src-tauri/df-types,
|
||||
//! relay 不依赖避跨 crate 强耦合),payload 用 serde_json::Value 透传。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::conn::ConnId;
|
||||
|
||||
/// 客户端类型(用于路由判断)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientKind {
|
||||
/// 桌面端(出站长连接,事件真相源)
|
||||
Device,
|
||||
/// 小程序(远程操作发起方)
|
||||
Miniapp,
|
||||
}
|
||||
|
||||
/// 中继路由消息语义分类
|
||||
///
|
||||
/// - `Event`:桌面端 → 小程序(渲染事件,AiChatEvent 透传)
|
||||
/// - `Command`:小程序 → 桌面端(操作指令,触发 Tauri command)
|
||||
/// - `Control`:控制面(配对/心跳/Ping-Pong,不进业务流)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageKind {
|
||||
Event,
|
||||
Command,
|
||||
Control,
|
||||
}
|
||||
|
||||
/// 中继路由消息骨架(云后端内部的路由单元)
|
||||
///
|
||||
/// 每条消息携带 `device_id`(配对绑定键),云后端按 device_id
|
||||
/// 找到绑定的对端连接进行转发/广播。payload 为原始 JSON,转发时不解析业务字段,
|
||||
/// 保持轻量。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BroadcastMessage {
|
||||
/// 配对绑定的设备 ID(桌面端首次配置时生成)
|
||||
pub device_id: String,
|
||||
/// 消息语义(Event / Command / Control)
|
||||
pub kind: MessageKind,
|
||||
/// 消息来源连接(用于鉴权校验:device 不能发 Command)
|
||||
pub source: ConnId,
|
||||
/// 来源客户端类型
|
||||
pub from: ClientKind,
|
||||
/// 消息载荷(原始 JSON,透传不解析)
|
||||
pub payload: serde_json::Value,
|
||||
/// 时间戳(毫秒,用于冲突判断 —— 桌面端为真相源,但保留时间戳辅助)
|
||||
pub ts: i64,
|
||||
}
|
||||
|
||||
impl BroadcastMessage {
|
||||
/// 便捷构造:device 发出的事件
|
||||
pub fn from_device(
|
||||
device_id: impl Into<String>,
|
||||
source: ConnId,
|
||||
payload: serde_json::Value,
|
||||
ts: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_id: device_id.into(),
|
||||
kind: MessageKind::Event,
|
||||
source,
|
||||
from: ClientKind::Device,
|
||||
payload,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷构造:小程序发出的指令
|
||||
pub fn from_miniapp(
|
||||
device_id: impl Into<String>,
|
||||
source: ConnId,
|
||||
payload: serde_json::Value,
|
||||
ts: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_id: device_id.into(),
|
||||
kind: MessageKind::Command,
|
||||
source,
|
||||
from: ClientKind::Miniapp,
|
||||
payload,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷构造:控制面消息
|
||||
pub fn control(
|
||||
device_id: impl Into<String>,
|
||||
source: ConnId,
|
||||
from: ClientKind,
|
||||
payload: serde_json::Value,
|
||||
ts: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_id: device_id.into(),
|
||||
kind: MessageKind::Control,
|
||||
source,
|
||||
from,
|
||||
payload,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端在线状态(用于离线降级提示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PresenceState {
|
||||
/// 在线
|
||||
Online,
|
||||
/// 离线(对端可缓存指令待重连)
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// 控制面消息(配对绑定 / 心跳 / 在线状态广播)
|
||||
///
|
||||
/// 仅作为 payload 的语义约定,relay 对 Control 消息同样透传;部分控制语义
|
||||
/// (如 Ping/Pong 心跳)由 WS 连接层直接处理,不进 BroadcastMessage 流。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "control_kind", rename_all = "snake_case")]
|
||||
pub enum ControlMessage {
|
||||
/// 配对绑定请求(小程序持 token 申请绑定 device_id)
|
||||
Pair { device_id: String, token: String },
|
||||
/// 心跳(保活 + 在线确认)
|
||||
Heartbeat { device_id: String },
|
||||
/// 在线状态变更通知
|
||||
Presence { device_id: String, state: PresenceState },
|
||||
/// 心跳 Ping(WS 应用层心跳,与协议层 Ping 区分)
|
||||
Ping,
|
||||
/// 心跳 Pong
|
||||
Pong,
|
||||
}
|
||||
202
crates/df-relay/src/conn.rs
Normal file
202
crates/df-relay/src/conn.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! df-relay 连接注册表
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— 按 device_id 配对路由(非全局广播)。
|
||||
//!
|
||||
//! 注册表结构:`HashMap<conn_id, ConnHandle>` + `device_id → conn_id` 索引。
|
||||
//! 配对语义:小程序 device_id ↔ 桌面端绑定该 device_id 的 token(同 device_id
|
||||
//! 的小程序与桌面端互为对端)。relay 按 device_id 找到对端连接转发消息。
|
||||
//!
|
||||
//! 并发:tokio::sync::Mutex 单锁(注册表操作高频但临界区小,先简单后优化)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::broadcast::{BroadcastMessage, ClientKind};
|
||||
|
||||
/// 连接唯一标识(u64 自增,进程内唯一)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConnId(pub u64);
|
||||
|
||||
impl ConnId {
|
||||
/// 伪值(用于无来源场景,如服务端构造的控制消息)
|
||||
pub const NIL: ConnId = ConnId(0);
|
||||
}
|
||||
|
||||
static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// 分配下一个 conn_id(进程内递增,从 1 起,0 留作 NIL)
|
||||
pub fn next_conn_id() -> ConnId {
|
||||
ConnId(NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// 连接句柄(注册表条目)
|
||||
///
|
||||
/// 每个连接持一个 mpsc::UnboundedSender,广播时向其投递 BroadcastMessage,
|
||||
/// 由连接自己的读取任务转发给对端 socket。
|
||||
#[derive(Debug)]
|
||||
pub struct ConnHandle {
|
||||
/// 连接 ID
|
||||
pub id: ConnId,
|
||||
/// 客户端类型
|
||||
pub kind: ClientKind,
|
||||
/// 配对绑定的设备 ID(桌面端首次配置生成,小程序携带)
|
||||
pub device_id: String,
|
||||
/// 广播投递通道(连接读取任务消费 → 写入 socket)
|
||||
pub sender: mpsc::UnboundedSender<BroadcastMessage>,
|
||||
}
|
||||
|
||||
impl ConnHandle {
|
||||
pub fn new(
|
||||
id: ConnId,
|
||||
kind: ClientKind,
|
||||
device_id: String,
|
||||
sender: mpsc::UnboundedSender<BroadcastMessage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
device_id,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接注册表(RelayState 内部状态)
|
||||
#[derive(Default)]
|
||||
pub struct ConnRegistry {
|
||||
/// conn_id → 连接句柄
|
||||
by_id: HashMap<ConnId, ConnHandle>,
|
||||
/// device_id → 绑定的连接集合(同 device_id 的小程序 + 桌面端)
|
||||
/// 设计为 Vec:兼容同 device_id 多连接(多端登录),MVP 一般 1+1。
|
||||
by_device: HashMap<String, Vec<ConnId>>,
|
||||
}
|
||||
|
||||
impl ConnRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 注册连接
|
||||
pub fn add(&mut self, handle: ConnHandle) {
|
||||
let id = handle.id;
|
||||
let device_id = handle.device_id.clone();
|
||||
self.by_device.entry(device_id).or_default().push(id);
|
||||
self.by_id.insert(id, handle);
|
||||
}
|
||||
|
||||
/// 注销连接(返回其 device_id 用于清理索引)
|
||||
pub fn remove(&mut self, id: ConnId) -> Option<String> {
|
||||
let handle = self.by_id.remove(&id)?;
|
||||
let device_id = handle.device_id.clone();
|
||||
if let Some(ids) = self.by_device.get_mut(&device_id) {
|
||||
ids.retain(|c| *c != id);
|
||||
if ids.is_empty() {
|
||||
self.by_device.remove(&device_id);
|
||||
}
|
||||
}
|
||||
Some(device_id)
|
||||
}
|
||||
|
||||
/// 查询 device_id 是否有在线连接(离线降级判断用)
|
||||
pub fn is_device_online(&self, device_id: &str) -> bool {
|
||||
self.by_device
|
||||
.get(device_id)
|
||||
.map(|ids| !ids.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 投递消息给指定 device_id 配对的对端连接(排除来源 source)
|
||||
///
|
||||
/// 路由规则:
|
||||
/// - Event(Device 发):转发给同 device_id 的所有 Miniapp 连接
|
||||
/// - Command(Miniapp 发):转发给同 device_id 的所有 Device 连接
|
||||
/// - Control:转发给同 device_id 的所有连接(除来源)
|
||||
///
|
||||
/// 返回成功投递的连接数(用于日志/降级判断)。
|
||||
pub fn route(&self, msg: &BroadcastMessage) -> usize {
|
||||
let Some(ids) = self.by_device.get(&msg.device_id) else {
|
||||
return 0;
|
||||
};
|
||||
let mut delivered = 0usize;
|
||||
for &id in ids {
|
||||
if id == msg.source {
|
||||
// 不回环给来源
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = self.by_id.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
match (msg.kind, msg.from, handle.kind) {
|
||||
// Event:仅投递给 Miniapp
|
||||
(crate::broadcast::MessageKind::Event, _, ClientKind::Miniapp) => {}
|
||||
// Command:仅投递给 Device
|
||||
(crate::broadcast::MessageKind::Command, _, ClientKind::Device) => {}
|
||||
// Control:全投递(除来源)
|
||||
(crate::broadcast::MessageKind::Control, _, _) => {}
|
||||
// 其余方向不匹配(Event 不投递给 Device,Command 不投递给 Miniapp)
|
||||
_ => continue,
|
||||
}
|
||||
if handle.sender.send(msg.clone()).is_ok() {
|
||||
delivered += 1;
|
||||
}
|
||||
}
|
||||
delivered
|
||||
}
|
||||
|
||||
/// 当前总连接数(诊断用)
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_id.len()
|
||||
}
|
||||
|
||||
/// 是否为空(诊断用)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_id.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 共享中继状态(axum State 传递)
|
||||
///
|
||||
/// 注册表用 tokio Mutex 包裹:广播路径在锁内调用 route,锁粒度小。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RelayState {
|
||||
inner: Arc<Mutex<ConnRegistry>>,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 从 Arc 直接构造(用于 axum state)
|
||||
pub fn from_arc(inner: Arc<Mutex<ConnRegistry>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// 暴露内部 Arc(测试/外部 start 使用)
|
||||
pub fn inner(&self) -> Arc<Mutex<ConnRegistry>> {
|
||||
self.inner.clone()
|
||||
}
|
||||
|
||||
/// 注册连接
|
||||
pub async fn add_conn(&self, handle: ConnHandle) {
|
||||
self.inner.lock().await.add(handle);
|
||||
}
|
||||
|
||||
/// 注销连接
|
||||
pub async fn remove_conn(&self, id: ConnId) -> Option<String> {
|
||||
self.inner.lock().await.remove(id)
|
||||
}
|
||||
|
||||
/// 查询 device 在线状态
|
||||
pub async fn is_device_online(&self, device_id: &str) -> bool {
|
||||
self.inner.lock().await.is_device_online(device_id)
|
||||
}
|
||||
|
||||
/// 投递消息给对端连接(返回成功投递数)
|
||||
pub async fn route(&self, msg: &BroadcastMessage) -> usize {
|
||||
self.inner.lock().await.route(msg)
|
||||
}
|
||||
}
|
||||
41
crates/df-relay/src/error.rs
Normal file
41
crates/df-relay/src/error.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! df-relay 错误类型
|
||||
//!
|
||||
//! 设计:单一 RelayError 覆盖服务启动/客户端管理/广播/WS 协议四类失败,
|
||||
//! thiserror 派生保留错误链。脚手架阶段仅定义类型。
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// 中继服务运行期错误
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RelayError {
|
||||
/// 服务启动失败(端口占用 / 绑定失败)
|
||||
#[error("中继服务启动失败: {0}")]
|
||||
Start(String),
|
||||
|
||||
/// 客户端管理错误(配对失败 / 重复连接 / 鉴权拒绝)
|
||||
#[error("客户端管理错误: {0}")]
|
||||
Client(String),
|
||||
|
||||
/// 广播失败(无可用连接 / 发送失败)
|
||||
#[error("广播失败: {0}")]
|
||||
Broadcast(String),
|
||||
|
||||
/// WS 协议层错误(底层 axum::extract::ws / tungstenite)
|
||||
#[error("WS 协议错误: {0}")]
|
||||
WebSocket(String),
|
||||
|
||||
/// 序列化/反序列化失败
|
||||
#[error("消息序列化失败: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// 广播发送失败(mpsc channel 对端已断开)
|
||||
#[error("广播 channel 发送失败: {0}")]
|
||||
Channel(String),
|
||||
|
||||
/// 其他未归类错误(降级通道)
|
||||
#[error("中继内部错误: {0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// 模块级 Result 别名
|
||||
pub type Result<T> = std::result::Result<T, RelayError>;
|
||||
29
crates/df-relay/src/lib.rs
Normal file
29
crates/df-relay/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! # df-relay
|
||||
//!
|
||||
//! 跨端 AI Chat 云中继服务(设计文档 Layer2)。
|
||||
//!
|
||||
//! ## 职责
|
||||
//! - axum WS Server 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)
|
||||
//! - 广播中继:小程序操作 → 桌面端;桌面端事件 → 小程序
|
||||
//! - 纯转发,无业务逻辑(保持轻量,设计文档明确)
|
||||
//!
|
||||
//! ## 边界
|
||||
//! - 不依赖 df-tunnel / df-types / src-tauri(独立消息骨架,避免跨 crate 强耦合)
|
||||
//! - 不含业务逻辑(Phase2 填连接生命周期 / 注册表 / 广播分发)
|
||||
//!
|
||||
//! ## 模块
|
||||
//! - [`broadcast`]:广播消息骨架(BroadcastMessage / ClientKind / ControlMessage / MessageKind)
|
||||
//! - [`conn`]:连接注册表(ConnId / ConnHandle / ConnRegistry / RelayState)
|
||||
//! - [`relay`]:RelayServer trait + DefaultRelayServer + axum WS 路由与连接生命周期
|
||||
//! - [`error`]:RelayError 错误类型
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod conn;
|
||||
pub mod error;
|
||||
pub mod relay;
|
||||
|
||||
// 顶层再导出常用项
|
||||
pub use broadcast::{BroadcastMessage, ClientKind, ControlMessage, MessageKind, PresenceState};
|
||||
pub use conn::{next_conn_id, ConnHandle, ConnId, ConnRegistry, RelayState};
|
||||
pub use error::{RelayError, Result};
|
||||
pub use relay::{build_router, DefaultRelayServer, RelayServer};
|
||||
21
crates/df-relay/src/main.rs
Normal file
21
crates/df-relay/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! df-relay binary 入口 — 启动跨端 AI Chat 云中继 axum WS Server。
|
||||
//!
|
||||
//! 监听 `0.0.0.0:8080`(`DF_RELAY_ADDR` env 覆盖),提供两个 WS 端点:
|
||||
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
|
||||
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
|
||||
//!
|
||||
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。
|
||||
//!
|
||||
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。
|
||||
|
||||
use df_relay::{DefaultRelayServer, RelayServer};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> df_relay::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
|
||||
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
|
||||
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
|
||||
let server = DefaultRelayServer::new();
|
||||
server.start(&addr).await
|
||||
}
|
||||
442
crates/df-relay/src/relay.rs
Normal file
442
crates/df-relay/src/relay.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
//! df-relay 中继服务核心实现
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
|
||||
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对),按 device_id
|
||||
//! 配对转发(非全局广播),纯转发无业务逻辑。
|
||||
//!
|
||||
//! 协议(简单握手):
|
||||
//! 1. 客户端建立 WS 后,首条消息发 JSON `Hello { kind, device_id, token }` 宣告身份。
|
||||
//! 2. relay 校验 token(MVP:env `DF_RELAY_TOKEN` 或硬编码常量;生产级鉴权留 Phase3)。
|
||||
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
|
||||
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
|
||||
//!
|
||||
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
|
||||
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
||||
use crate::error::{RelayError, Result};
|
||||
|
||||
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
|
||||
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
||||
fn expected_token() -> String {
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
|
||||
panic!("必须设置环境变量 DF_RELAY_TOKEN")
|
||||
})
|
||||
}
|
||||
|
||||
/// 客户端首消息:身份宣告(简单协议)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// 客户端类型("device" / "miniapp")
|
||||
pub kind: ClientKindWire,
|
||||
/// 配对绑定的设备 ID
|
||||
pub device_id: String,
|
||||
/// 配对 token
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientKindWire {
|
||||
Device,
|
||||
Miniapp,
|
||||
}
|
||||
|
||||
impl From<ClientKindWire> for ClientKind {
|
||||
fn from(w: ClientKindWire) -> Self {
|
||||
match w {
|
||||
ClientKindWire::Device => ClientKind::Device,
|
||||
ClientKindWire::Miniapp => ClientKind::Miniapp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中继服务抽象
|
||||
///
|
||||
/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。
|
||||
#[async_trait]
|
||||
pub trait RelayServer: Send + Sync {
|
||||
/// 启动 HTTP/WS 服务监听指定地址
|
||||
async fn start(&self, addr: &str) -> Result<()>;
|
||||
|
||||
/// 广播消息给指定 device_id 绑定的对端
|
||||
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>;
|
||||
|
||||
/// 查询 device 是否有在线连接(离线降级判断用)
|
||||
fn is_device_online(&self, device_id: &str) -> bool;
|
||||
}
|
||||
|
||||
/// 默认中继服务(持有共享 RelayState)
|
||||
pub struct DefaultRelayServer {
|
||||
state: RelayState,
|
||||
}
|
||||
|
||||
impl DefaultRelayServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: RelayState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从既有 RelayState 构造(测试 / 外部复用)
|
||||
pub fn with_state(state: RelayState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
/// 暴露共享状态(外部可读连接数等)
|
||||
pub fn state(&self) -> RelayState {
|
||||
self.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DefaultRelayServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RelayServer for DefaultRelayServer {
|
||||
async fn start(&self, addr: &str) -> Result<()> {
|
||||
let socket_addr: SocketAddr = addr
|
||||
.parse()
|
||||
.map_err(|e| RelayError::Start(format!("地址解析失败 {addr}: {e}")))?;
|
||||
let app = build_router(self.state.clone());
|
||||
let listener = tokio::net::TcpListener::bind(&socket_addr)
|
||||
.await
|
||||
.map_err(|e| RelayError::Start(format!("监听绑定失败 {addr}: {e}")))?;
|
||||
tracing::info!(%addr, "df-relay WS 服务已启动");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.map_err(|e| RelayError::Start(format!("axum::serve 失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()> {
|
||||
let delivered = self.state.route(&msg).await;
|
||||
if delivered == 0 {
|
||||
// 对端离线不算硬错误(MVP 返回 Ok,离线降级由调用方据 is_device_online 判断)
|
||||
tracing::debug!(
|
||||
device_id = %msg.device_id,
|
||||
kind = ?msg.kind,
|
||||
"广播无对端在线(消息丢弃)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_device_online(&self, device_id: &str) -> bool {
|
||||
// trait 同步签名:tokio Mutex 用 try_lock 快照,失败保守返回 false
|
||||
match self.state.inner().try_lock() {
|
||||
Ok(g) => g.is_device_online(device_id),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// axum WS 路由构造
|
||||
///
|
||||
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
|
||||
/// 共享 RelayState。端点仅决定「期望的客户端类型」,真正的身份宣告在首消息
|
||||
/// Hello 中再次校验(防误连/误用)。
|
||||
pub fn build_router(state: RelayState) -> Router {
|
||||
Router::new()
|
||||
.route("/ws/device", get(device_ws_handler))
|
||||
.route("/ws/miniapp", get(miniapp_ws_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// 桌面端 WS upgrade handler
|
||||
async fn device_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<RelayState>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::debug!("桌面端 WS 连接接入");
|
||||
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Device))
|
||||
}
|
||||
|
||||
/// 小程序 WS upgrade handler
|
||||
async fn miniapp_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<RelayState>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::debug!("小程序 WS 连接接入");
|
||||
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Miniapp))
|
||||
}
|
||||
|
||||
/// WS 连接生命周期(握手 → 收发循环 → 注销)
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. 等待首条 Hello 文本帧,校验 kind 与 token。
|
||||
/// 2. 校验通过:分配 conn_id + mpsc,注册 ConnHandle,派发广播读取任务。
|
||||
/// 3. 主循环:从 socket recv 文本帧 → 构造 BroadcastMessage → route 投递。
|
||||
/// 4. 同时读取 mpsc 广播队列 → 写回 socket(双任务用 split sink/stream)。
|
||||
/// 5. 任一端断开 → 注销连接、关闭 mpsc。
|
||||
async fn handle_connection(socket: WebSocket, state: RelayState, expected: ClientKindWire) {
|
||||
// 握手阶段:等待首条 Hello
|
||||
let (mut socket_tx, mut socket_rx) = socket.split();
|
||||
|
||||
let hello = match recv_hello(&mut socket_rx).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "握手失败:未收到合法 Hello");
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"handshake_failed"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 身份 + token 双因子校验
|
||||
if hello.kind != expected {
|
||||
tracing::warn!(
|
||||
?hello.kind,
|
||||
?expected,
|
||||
"握手失败:客户端类型与端点不匹配"
|
||||
);
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"kind_mismatch"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
if hello.token != expected_token() {
|
||||
tracing::warn!(
|
||||
device_id = %hello.device_id,
|
||||
"握手失败:token 校验不通过"
|
||||
);
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"auth_failed"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
|
||||
let conn_id = next_conn_id();
|
||||
let kind: ClientKind = hello.kind.into();
|
||||
let device_id = hello.device_id.clone();
|
||||
tracing::info!(
|
||||
conn_id = conn_id.0,
|
||||
?kind,
|
||||
device_id = %device_id,
|
||||
"连接握手通过,进入收发循环"
|
||||
);
|
||||
|
||||
// 握手通过:立即发 ack 控制帧给客户端。
|
||||
// 客户端据此判定握手成功(首条非 error 消息即 handshaked),不依赖等待对端首条业务消息 ——
|
||||
// 否则单端连入时(device 离线)relay 静默,客户端永卡 handshaking,send 被 handshaked 守卫拦截。
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","payload":{"control_kind":"hello_ack"}}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 建立广播投递 mpsc(连接读取任务消费 → 写回 socket)
|
||||
let (bc_tx, bc_rx) = mpsc::unbounded_channel::<BroadcastMessage>();
|
||||
let handle = ConnHandle::new(conn_id, kind, device_id.clone(), bc_tx);
|
||||
state.add_conn(handle).await;
|
||||
|
||||
// 派发广播读取任务:从 bc_rx 取消息 → 序列化 → 写 socket
|
||||
let mut bc_task = tokio::spawn(broadcast_pump(bc_rx, socket_tx));
|
||||
|
||||
// 主循环:从 socket recv → 构造 BroadcastMessage → route
|
||||
loop {
|
||||
tokio::select! {
|
||||
// socket 入帧
|
||||
maybe_msg = socket_rx.next() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
// MVP 仅支持文本帧;二进制帧忽略(协议层可后续扩展)
|
||||
tracing::debug!(conn_id = conn_id.0, "收到二进制帧,忽略");
|
||||
}
|
||||
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {
|
||||
// axum/tungstenite 协议层 Ping/Pong 自动处理,这里仅记录
|
||||
tracing::trace!(conn_id = conn_id.0, "协议层 Ping/Pong");
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
tracing::info!(conn_id = conn_id.0, "客户端主动关闭连接");
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "socket 接收错误,断开");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 广播 pump 任务结束(socket_tx 关闭或 mpsc 关闭)
|
||||
res = &mut bc_task => {
|
||||
match res {
|
||||
Ok(()) => {
|
||||
tracing::debug!(conn_id = conn_id.0, "广播 pump 任务正常结束");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "广播 pump 任务 panic");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注销连接
|
||||
if let Some(d) = state.remove_conn(conn_id).await {
|
||||
tracing::info!(conn_id = conn_id.0, device_id = %d, "连接已注销");
|
||||
}
|
||||
// 结束 pump 任务(若仍在运行)
|
||||
bc_task.abort();
|
||||
}
|
||||
|
||||
/// 接收并解析首条 Hello 文本帧
|
||||
async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Result<Hello> {
|
||||
let deadline = tokio::time::Duration::from_secs(10);
|
||||
let next = tokio::time::timeout(deadline, rx.next())
|
||||
.await
|
||||
.map_err(|_| RelayError::Client("握手超时(10s 未收到 Hello)".into()))?;
|
||||
let msg = next
|
||||
.ok_or_else(|| RelayError::Client("握手阶段连接关闭".into()))?
|
||||
.map_err(|e| RelayError::WebSocket(format!("握手 recv 失败: {e}")))?;
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Binary(_) => {
|
||||
return Err(RelayError::Client("握手首帧必须为文本".into()));
|
||||
}
|
||||
_ => return Err(RelayError::Client("握手首帧类型非法".into())),
|
||||
};
|
||||
let hello: Hello =
|
||||
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
|
||||
Ok(hello)
|
||||
}
|
||||
|
||||
/// 处理入站文本帧(构造 BroadcastMessage → route)
|
||||
async fn handle_inbound_text(
|
||||
state: &RelayState,
|
||||
conn_id: ConnId,
|
||||
kind: ClientKind,
|
||||
device_id: &str,
|
||||
raw: &str,
|
||||
) -> Result<()> {
|
||||
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
|
||||
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
||||
|
||||
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
|
||||
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
|
||||
// device 端收到 control 消息也仅 console.log,不影响业务。
|
||||
if let Some(control_kind) = payload
|
||||
.get("control_kind")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if control_kind == "ping" && kind == ClientKind::Miniapp {
|
||||
let pong_payload = serde_json::json!({"control_kind": "pong"});
|
||||
let pong_msg = BroadcastMessage {
|
||||
device_id: device_id.to_string(),
|
||||
kind: crate::broadcast::MessageKind::Control,
|
||||
source: conn_id,
|
||||
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
|
||||
payload: pong_payload,
|
||||
ts: now_ms(),
|
||||
};
|
||||
let _ = state.route(&pong_msg).await;
|
||||
tracing::trace!(
|
||||
conn_id = conn_id.0,
|
||||
device_id = %device_id,
|
||||
"miniapp ping → relay pong(本地响应)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let (msg_kind, from) = match kind {
|
||||
ClientKind::Device => (MessageKind::Event, ClientKind::Device),
|
||||
ClientKind::Miniapp => (MessageKind::Command, ClientKind::Miniapp),
|
||||
};
|
||||
let msg = BroadcastMessage {
|
||||
device_id: device_id.to_string(),
|
||||
kind: msg_kind,
|
||||
source: conn_id,
|
||||
from,
|
||||
payload,
|
||||
ts: now,
|
||||
};
|
||||
let delivered = state.route(&msg).await;
|
||||
tracing::debug!(
|
||||
conn_id = conn_id.0,
|
||||
?msg_kind,
|
||||
device_id = %device_id,
|
||||
delivered,
|
||||
"入站消息已路由"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 广播 pump:从 mpsc 取消息,序列化后写回 socket sink
|
||||
///
|
||||
/// 任务退出条件:bc_rx 关闭(对端 handle 全部 drop)/ socket_tx 关闭出错。
|
||||
async fn broadcast_pump(
|
||||
mut bc_rx: mpsc::UnboundedReceiver<BroadcastMessage>,
|
||||
mut socket_tx: futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
) {
|
||||
while let Some(msg) = bc_rx.recv().await {
|
||||
let text = match serde_json::to_string(&msg) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "广播消息序列化失败,跳过");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = socket_tx.send(Message::Text(text)).await {
|
||||
tracing::warn!(error = %e, "广播写回 socket 失败,pump 退出");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷发送文本帧
|
||||
async fn send_text(
|
||||
tx: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
text: &str,
|
||||
) -> Result<()> {
|
||||
tx.send(Message::Text(text.to_string()))
|
||||
.await
|
||||
.map_err(|e| RelayError::WebSocket(format!("发送失败: {e}")))
|
||||
}
|
||||
|
||||
/// 当前毫秒时间戳(避开 chrono workspace 依赖,直接用 std + SystemTime)
|
||||
fn now_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
233
crates/df-relay/tests/registry_test.rs
Normal file
233
crates/df-relay/tests/registry_test.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! df-relay 单元测试(任务 #12)
|
||||
//!
|
||||
//! 覆盖纯逻辑(不依赖 axum server):
|
||||
//! - ConnRegistry: add/remove/is_online/route(四种路由规则)
|
||||
//! - BroadcastMessage 构造器
|
||||
//! - RelayState async 包装
|
||||
//! - next_conn_id 单调递增
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use df_relay::{
|
||||
next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind,
|
||||
RelayState,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// ---------- ConnId 分配 ----------
|
||||
|
||||
#[test]
|
||||
fn next_conn_id_monotonic() {
|
||||
let a = next_conn_id();
|
||||
let b = next_conn_id();
|
||||
let c = next_conn_id();
|
||||
assert!(b.0 > a.0, "next_conn_id 应单调递增");
|
||||
assert!(c.0 > b.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conn_id_nil_is_zero() {
|
||||
assert_eq!(ConnId::NIL.0, 0);
|
||||
assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)");
|
||||
}
|
||||
|
||||
// ---------- ConnRegistry 基础操作 ----------
|
||||
|
||||
fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver<BroadcastMessage>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(ConnHandle::new(id, kind, device_id.to_string(), tx), rx)
|
||||
}
|
||||
|
||||
/// 注册连接并返回 receiver(保活防 channel 关闭)。
|
||||
fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver<BroadcastMessage> {
|
||||
let (handle, rx) = make_handle(id, kind, device_id);
|
||||
reg.add(handle);
|
||||
rx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_add_and_lookup() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let id = next_conn_id();
|
||||
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
|
||||
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
assert!(!reg.is_device_online("dev-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_remove_returns_device_id() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let id = next_conn_id();
|
||||
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
|
||||
|
||||
let removed = reg.remove(id);
|
||||
assert_eq!(removed.as_deref(), Some("dev-1"));
|
||||
assert_eq!(reg.len(), 0);
|
||||
assert!(!reg.is_device_online("dev-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_remove_unknown_returns_none() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
assert_eq!(reg.remove(ConnId(9999)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_supports_multi_conn_per_device() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let d1 = next_conn_id();
|
||||
let m1 = next_conn_id();
|
||||
let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
assert_eq!(reg.len(), 2);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
|
||||
reg.remove(d1);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
|
||||
reg.remove(m1);
|
||||
assert!(!reg.is_device_online("dev-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_empty_default() {
|
||||
let reg = ConnRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
}
|
||||
|
||||
// ---------- 路由规则 ----------
|
||||
|
||||
#[test]
|
||||
fn route_event_device_to_miniapp_only() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let other_mini = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
|
||||
let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_command_miniapp_to_device_only() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage {
|
||||
device_id: "dev-1".into(),
|
||||
kind: MessageKind::Command,
|
||||
source: mini,
|
||||
from: ClientKind::Miniapp,
|
||||
payload: serde_json::json!({}),
|
||||
ts: 0,
|
||||
};
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_does_not_loop_back_to_source() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 0, "不应回环给来源");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_control_to_all_except_source() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini1 = next_conn_id();
|
||||
let mini2 = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1");
|
||||
let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage {
|
||||
device_id: "dev-1".into(),
|
||||
kind: MessageKind::Control,
|
||||
source: dev,
|
||||
from: ClientKind::Device,
|
||||
payload: serde_json::json!({}),
|
||||
ts: 0,
|
||||
};
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_unknown_device_zero() {
|
||||
let reg = ConnRegistry::new();
|
||||
let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0);
|
||||
assert_eq!(reg.route(&msg), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_event_not_delivered_to_device() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev1 = next_conn_id();
|
||||
let dev2 = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0);
|
||||
assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递");
|
||||
}
|
||||
|
||||
// ---------- BroadcastMessage 构造器 ----------
|
||||
|
||||
#[test]
|
||||
fn from_device_sets_event_kind_and_from() {
|
||||
let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345);
|
||||
assert_eq!(msg.device_id, "dev-1");
|
||||
assert_eq!(msg.kind, MessageKind::Event);
|
||||
assert_eq!(msg.from, ClientKind::Device);
|
||||
assert_eq!(msg.source, ConnId(42));
|
||||
assert_eq!(msg.ts, 12345);
|
||||
assert_eq!(msg.payload, serde_json::json!({"a": 1}));
|
||||
}
|
||||
|
||||
// ---------- RelayState async 包装 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_state_add_and_remove_conn() {
|
||||
let state = RelayState::new();
|
||||
let id = next_conn_id();
|
||||
let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1");
|
||||
state.add_conn(handle).await;
|
||||
|
||||
assert!(state.is_device_online("dev-1").await);
|
||||
|
||||
let removed = state.remove_conn(id).await;
|
||||
assert_eq!(removed.as_deref(), Some("dev-1"));
|
||||
assert!(!state.is_device_online("dev-1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_state_route_async() {
|
||||
let state = RelayState::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1");
|
||||
let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1");
|
||||
state.add_conn(h1).await;
|
||||
state.add_conn(h2).await;
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let n = state.route(&msg).await;
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
@@ -84,6 +84,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
pinned: row.get::<_, i32>("pinned")? != 0,
|
||||
prompt_tokens: row.get("prompt_tokens")?,
|
||||
completion_tokens: row.get("completion_tokens")?,
|
||||
pinned_goals: row.get("pinned_goals")?,
|
||||
pending_approvals: row.get("pending_approvals")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -159,24 +161,24 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.created_at, rec.updated_at
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11",
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.updated_at, rec.id
|
||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -262,6 +264,25 @@ impl AiToolExecutionRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 清理超期的残留 pending 工具调用(旧会话遗留)。
|
||||
///
|
||||
/// `max_age_secs`: 超过此秒数的 pending 记录被标记为 interrupted(不硬删,保留审计痕迹)。
|
||||
pub async fn cleanup_stale_pending(&self, max_age_secs: u64) -> Result<u64> {
|
||||
let conn = self.conn.clone();
|
||||
let cutoff_ms = (df_types::now_millis() as i64 - (max_age_secs as i64 * 1000)).to_string();
|
||||
let affected = tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard.execute(
|
||||
"UPDATE ai_tool_executions SET status = 'interrupted' \
|
||||
WHERE status = 'pending' AND CAST(requested_at AS INTEGER) < ?1",
|
||||
params![cutoff_ms],
|
||||
).map_err(storage_err)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)??;
|
||||
Ok(affected as u64)
|
||||
}
|
||||
|
||||
/// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
|
||||
///
|
||||
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码
|
||||
|
||||
@@ -9,6 +9,7 @@ use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
|
||||
use df_types::types::IdeaStatus;
|
||||
|
||||
use super::impl_repo;
|
||||
use super::{now_millis_str, storage_err, validate_column_name};
|
||||
@@ -73,6 +74,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 辅助函数
|
||||
// ============================================================
|
||||
@@ -82,7 +94,10 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
|
||||
id: row.get("id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get("description")?,
|
||||
status: row.get("status")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
IdeaStatus::from_db_str(&s).unwrap_or_default()
|
||||
},
|
||||
priority: row.get("priority")?,
|
||||
score: row.get("score")?,
|
||||
tags: row.get("tags")?,
|
||||
@@ -131,6 +146,53 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
|
||||
// Repo 实现
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
|
||||
// ============================================================
|
||||
|
||||
/// 灵感多条件查询入参。
|
||||
///
|
||||
/// 所有字段可选;全 None → 等价 `list_all`(向后兼容旧全量调用)。
|
||||
/// 设计对齐 `查询能力补全方案-2026-06-21.md` 4.1:可选字段 struct 而非逐个加 IPC 参数,
|
||||
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 分支化参数绑定)。
|
||||
///
|
||||
/// - `status`:状态精确匹配(走 `idx_tasks_status` 同类索引语义,后端 WHERE 收口前端 filter)
|
||||
/// - `keyword`:`title LIKE %kw% OR description LIKE %kw%`(对齐知识库 LIKE 检索,不上 FTS5)
|
||||
/// - `order_by`:白名单枚举(`created_at`/`updated_at`/`priority`/`status`/`score`,
|
||||
/// 见 `validate_idea_order_by`,防 SQL 注入;不进字符串拼接)
|
||||
/// - `limit`/`offset`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)
|
||||
///
|
||||
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
|
||||
/// `Default`:命令层兼容旧 `status` 单参数路径(构造 `IdeaQuery { status, ..Default }`)。
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
pub struct IdeaQuery {
|
||||
pub status: Option<String>,
|
||||
pub keyword: Option<String>,
|
||||
pub order_by: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// `order_by` 白名单校验(防 SQL 注入,对齐 `impl_repo!` 宏 `validate_column_name` 思路)。
|
||||
///
|
||||
/// 列名直进 SQL 字符串(`ORDER BY {col} DESC`),故必须白名单枚举校验,不接受任意字符串。
|
||||
/// 允许的排序列:created_at(默认/新在前)、updated_at(最近更新)、priority(优先级)、
|
||||
/// status(状态聚合)、score(评分,后端 NULL 视为 0)。
|
||||
fn validate_idea_order_by(col: &str) -> df_types::error::Result<&'static str> {
|
||||
Ok(match col {
|
||||
"created_at" => "created_at",
|
||||
"updated_at" => "updated_at",
|
||||
"priority" => "priority",
|
||||
"status" => "status",
|
||||
"score" => "score",
|
||||
_ => {
|
||||
return Err(df_types::error::Error::Storage(format!(
|
||||
"ideas order_by 不允许的字段名: {col}"
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl_repo!(
|
||||
/// 想法表 CRUD
|
||||
IdeaRepo,
|
||||
@@ -142,7 +204,7 @@ impl_repo!(
|
||||
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
rec.id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.related_ids, rec.created_at, rec.updated_at
|
||||
],
|
||||
@@ -152,7 +214,7 @@ impl_repo!(
|
||||
conn.execute(
|
||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
rec.scores, rec.related_ids, rec.updated_at, rec.id
|
||||
],
|
||||
@@ -193,13 +255,299 @@ impl_repo!(
|
||||
|
||||
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||
|
||||
impl IdeaRepo {
|
||||
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
|
||||
///
|
||||
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
|
||||
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
|
||||
/// 拼入(防 SQL 注入),`limit` 钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)。
|
||||
///
|
||||
/// **向后兼容**:空 query(全 None)→ `WHERE` 子句空 + 默认 `created_at DESC`,等价 `list_all`。
|
||||
///
|
||||
/// `score DESC NULLS LAST`:SQLite NULL 在 ASC 升序最前、DESC 降序最后,但为与前端原
|
||||
/// computed 语义一致(`(b.score ?? 0) - (a.score ?? 0)`,NULL 当 0),显式 `COALESCE(score,0)`
|
||||
/// 把 NULL 当 0 排序,避免 NULL 意外下沉到结果末尾。
|
||||
pub async fn list_by_query(&self, q: &IdeaQuery) -> Result<Vec<IdeaRecord>> {
|
||||
// 解析 + 校验 order_by(默认 created_at DESC)
|
||||
let order_col = match &q.order_by {
|
||||
Some(o) => validate_idea_order_by(o)?,
|
||||
None => "created_at",
|
||||
};
|
||||
|
||||
let conn = self.conn.clone();
|
||||
|
||||
// 所有按值移动进闭包的量,提前 clone 避免 move 借用问题
|
||||
let status = q.status.clone();
|
||||
let keyword = q.keyword.clone();
|
||||
// 钳制 limit 上限 200(对齐 list_recent),默认不限制分页(None → 不拼 LIMIT)
|
||||
let limit_i: Option<i64> = q.limit.map(|l| (l.min(200)) as i64);
|
||||
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
|
||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
|
||||
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
|
||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(s) = &status {
|
||||
// 占位符编号用 params_vec.len()+1(参数实际位置),非 where_clauses.len()+1
|
||||
// (where_clauses 含 deleted_at IS NULL 常量无占位符子句,len() 会偏移致 ?N 与参数错位
|
||||
// — 父⑤⑤.1 加 deleted_at 恒带引入的潜伏 bug,非空 status/keyword 查询 rusqlite 报
|
||||
// "needed N, got M"。对标 task_repo list_by_query ②.2 同款修复)
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(kw) = &keyword {
|
||||
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
|
||||
let pat = format!("%{escaped}%");
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
// where_clauses 恒含 deleted_at IS NULL(初始项),永非空,直接 join 拼 WHERE。
|
||||
let where_sql = format!(" WHERE {}", where_clauses.join(" AND "));
|
||||
|
||||
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
|
||||
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
|
||||
let order_expr = if order_col == "score" {
|
||||
"COALESCE(score, 0) DESC".to_string()
|
||||
} else {
|
||||
format!("{order_col} DESC")
|
||||
};
|
||||
|
||||
// LIMIT/OFFSET:limit 为 None → 不拼(全量),offset 仅在 limit 存在时有意义。
|
||||
let where_param_count = params_vec.len();
|
||||
let limit_sql_bound = match limit_i {
|
||||
Some(_) => format!(
|
||||
" LIMIT ?{} OFFSET ?{}",
|
||||
where_param_count + 1,
|
||||
where_param_count + 2
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {IDEA_COLS} FROM ideas{where_sql} ORDER BY {order_expr}{limit_sql_bound}"
|
||||
);
|
||||
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
|
||||
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
|
||||
// limit/offset 也压入 Box<Vec> 收口:借用引用需指向同一生命周期存活处,
|
||||
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597)。
|
||||
if let Some(l) = limit_i {
|
||||
params_vec.push(Box::new(l));
|
||||
params_vec.push(Box::new(offset_i));
|
||||
}
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| idea_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删灵感,返回是否命中。
|
||||
/// 对标 TaskRepo::soft_delete / ProjectRepo::soft_delete。
|
||||
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ideas SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删灵感,返回是否命中。
|
||||
/// 对标 TaskRepo::restore / ProjectRepo::restore。
|
||||
pub async fn restore(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ideas SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.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>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&format!(
|
||||
"SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC"
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| idea_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
impl KnowledgeRepo {
|
||||
/// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
|
||||
///
|
||||
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
|
||||
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let pattern = format!("%{}%", query);
|
||||
let escaped = query.replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
let kind = kind.map(|s| s.to_owned());
|
||||
let limit_i = limit as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -207,7 +555,7 @@ impl KnowledgeRepo {
|
||||
let mut results = Vec::new();
|
||||
if let Some(k) = &kind {
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -217,7 +565,7 @@ impl KnowledgeRepo {
|
||||
}
|
||||
} else {
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '\\' OR content LIKE ?2 ESCAPE '\\') ORDER BY reuse_count DESC LIMIT ?3"))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -1052,4 +1400,101 @@ mod tests {
|
||||
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
|
||||
}
|
||||
|
||||
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
|
||||
|
||||
/// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
|
||||
fn irec(id: &str, title: &str) -> IdeaRecord {
|
||||
IdeaRecord {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: String::new(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
source: None,
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_idea_repo() -> IdeaRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
IdeaRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_marks_and_filters_from_list_by_query() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "保留")).await.unwrap();
|
||||
repo.insert(irec("i2", "软删")).await.unwrap();
|
||||
|
||||
// 软删 i2:返回 true(命中)
|
||||
assert!(repo.soft_delete("i2").await.unwrap());
|
||||
|
||||
// list_by_query(空 query = 等价全量未删)应只返回 i1,i2 进回收站被过滤
|
||||
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
|
||||
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["i1"], "soft_delete 后 list_by_query 应过滤回收站");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_idempotent_on_deleted() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
assert!(repo.soft_delete("i1").await.unwrap());
|
||||
// 已删再删:WHERE deleted_at IS NULL 不命中 → false
|
||||
assert!(!repo.soft_delete("i1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_missing_returns_false() {
|
||||
let repo = setup_idea_repo().await;
|
||||
// 不存在的 id → affected=0
|
||||
assert!(!repo.soft_delete("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_restore_brings_back_to_active() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
repo.soft_delete("i1").await.unwrap();
|
||||
// 恢复:返回 true,回到 list_by_query
|
||||
assert!(repo.restore("i1").await.unwrap());
|
||||
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
|
||||
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["i1"], "restore 后灵感应回到活跃列表");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_restore_only_affects_deleted() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
// 未删灵感 restore:WHERE deleted_at IS NOT NULL 不命中 → false
|
||||
assert!(!repo.restore("i1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "活跃")).await.unwrap();
|
||||
repo.insert(irec("i2", "回收1")).await.unwrap();
|
||||
repo.insert(irec("i3", "回收2")).await.unwrap();
|
||||
|
||||
// 软删 i2 / i3(updated_at 在 soft_delete 时被刷新为 now)
|
||||
repo.soft_delete("i2").await.unwrap();
|
||||
// 让 i3 的删除时间晚于 i2,保证 updated_at DESC 顺序确定(i3 在前)
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
repo.soft_delete("i3").await.unwrap();
|
||||
|
||||
let deleted = repo.list_deleted().await.unwrap();
|
||||
let ids: Vec<_> = deleted.iter().map(|r| r.id.as_str()).collect();
|
||||
// i1 活跃不出现;i3 删除最晚在前
|
||||
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,57 @@ impl AiMessageRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。
|
||||
///
|
||||
/// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与
|
||||
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
|
||||
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
|
||||
///
|
||||
/// 典型用法:
|
||||
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
|
||||
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
|
||||
pub async fn list_recent(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
limit: usize,
|
||||
before_seq: Option<i64>,
|
||||
) -> Result<Vec<AiMessageRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conversation_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
|
||||
let limit = limit.max(1) as i64;
|
||||
let sql = if before_seq.is_some() {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
||||
} else {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
||||
let rows = match before_seq {
|
||||
Some(seq) => stmt
|
||||
.query_map(params![conv_id, seq, limit], ai_message_from_row)
|
||||
.map_err(storage_err)?,
|
||||
None => stmt
|
||||
.query_map(params![conv_id, limit], ai_message_from_row)
|
||||
.map_err(storage_err)?,
|
||||
};
|
||||
let mut results: Vec<AiMessageRecord> = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
// DESC → 反转为 ASC(与 list_by_conversation 一致顺序)
|
||||
results.reverse();
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
|
||||
///
|
||||
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
|
||||
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
|
||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
||||
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
||||
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
|
||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
@@ -14,21 +17,34 @@
|
||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||
|
||||
mod module_dependency_repo;
|
||||
mod plan_repo;
|
||||
|
||||
mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_module_repo;
|
||||
mod project_repo;
|
||||
mod project_service_repo;
|
||||
mod settings;
|
||||
mod task_link_repo;
|
||||
mod task_repo;
|
||||
|
||||
pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_module_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
pub use task_repo::*;
|
||||
pub use module_dependency_repo::*;
|
||||
pub use plan_repo::*;
|
||||
|
||||
// ============================================================
|
||||
// 辅助宏 — 消除多个 Repo 的重复样板
|
||||
@@ -266,7 +282,7 @@ mod baseline_tests {
|
||||
let tables = [
|
||||
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
|
||||
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
|
||||
"knowledges", "knowledge_events",
|
||||
"knowledges", "knowledge_events", "project_services",
|
||||
];
|
||||
for t in tables {
|
||||
assert!(
|
||||
@@ -293,6 +309,10 @@ mod baseline_tests {
|
||||
let _ = IdeaRepo::new(&db);
|
||||
let _ = ProjectRepo::new(&db);
|
||||
let _ = TaskRepo::new(&db);
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = ProjectServiceRepo::new(&db);
|
||||
let _ = ProjectModuleRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user