Compare commits
357
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bd6a41fe6e | ||
|
|
330bb7f505 | ||
|
|
4b5da38e28 | ||
|
|
44ecdf825e | ||
|
|
126bee5b24 | ||
|
|
e2ece2d898 | ||
|
|
e5a89165e6 | ||
|
|
b22e9ae912 | ||
|
|
e4c2e310a5 | ||
|
|
7911bc292c | ||
|
|
de049704c6 | ||
|
|
4e3a11f925 | ||
|
|
4746288a70 | ||
|
|
5811991887 | ||
|
|
4f8f1cf794 | ||
|
|
5c395f4825 | ||
|
|
70de99ae77 | ||
|
|
26765a050a | ||
|
|
c61a222db8 | ||
|
|
ad25925af7 | ||
|
|
74f38609e0 | ||
|
|
ba47f44ab8 | ||
|
|
4a87c552cc | ||
|
|
e5a8fec1fe | ||
|
|
6a7a8cbaac | ||
|
|
7d5402951b | ||
|
|
19eb14ffc8 | ||
|
|
6034e58b43 | ||
|
|
2f8b13ff7c | ||
|
|
d3e7640b8d | ||
|
|
cea0c71546 | ||
|
|
ef465625ed | ||
|
|
353b07427f | ||
|
|
f217e39bfe | ||
|
|
e981c1492a | ||
|
|
44d1c6a00c | ||
|
|
db129fd78f | ||
|
|
8da6bdcec2 | ||
|
|
968c3df5ab | ||
|
|
ec1daa8844 | ||
|
|
c38cefe47b | ||
|
|
5feeaa6cb0 | ||
|
|
998a2f243d | ||
|
|
f1a06732fd | ||
|
|
b42cc66e7a | ||
|
|
b237f716dd | ||
|
|
874cad22db | ||
|
|
11b579eb87 | ||
|
|
cfae6027d2 | ||
|
|
7748e3ecbc | ||
|
|
64ece1d3a0 | ||
|
|
03f0effcf2 | ||
|
|
55209cfbf9 | ||
|
|
e32eca3572 | ||
|
|
f5fd580215 | ||
|
|
a2330de2a8 | ||
|
|
356c8f27f8 | ||
|
|
3794b3d333 | ||
|
|
a1ad93ab31 | ||
|
|
35c976793b | ||
|
|
3e97fff328 | ||
|
|
a5d125847f | ||
|
|
2d6eff66a7 | ||
|
|
4dee706631 | ||
|
|
c908a570e5 | ||
|
|
a81cab074a | ||
|
|
a0987bad7d | ||
|
|
f5f2358799 | ||
|
|
d6e45f7bc6 | ||
|
|
a3835e2acb | ||
|
|
f3a287bbd4 | ||
|
|
55bc1e1b23 | ||
|
|
ed35c51a47 | ||
|
|
e0ffd98223 | ||
|
|
f026880fa7 | ||
|
|
4eaff04ac1 | ||
|
|
6a75c11dfa | ||
|
|
96a05638b1 | ||
|
|
63fe0a6375 | ||
|
|
f1aa7ac33d | ||
|
|
6769100b57 | ||
|
|
86f6c9685b | ||
|
|
e6dbb14c5d | ||
|
|
d899c58682 | ||
|
|
2b40f65ef2 | ||
|
|
7c708a6e01 | ||
|
|
6ad4ec2848 | ||
|
|
5c15b72d3d | ||
|
|
d96041721a | ||
|
|
2a4d745c5b | ||
|
|
2c8764abee | ||
|
|
d817386418 | ||
|
|
0142654be3 | ||
|
|
1dc8b12ec4 | ||
|
|
6d2244c03b | ||
|
|
60b01d03ee | ||
|
|
a2871a66e0 | ||
|
|
0ca5d9805f | ||
|
|
02bee38738 | ||
|
|
8abcd567a6 | ||
|
|
36cde88b80 | ||
|
|
ecff4cc23d | ||
|
|
b4f738bada | ||
|
|
3055b06734 | ||
|
|
aa75454878 | ||
|
|
f6e4e824fa | ||
|
|
645f73d1a3 | ||
|
|
74003bc04f | ||
|
|
1cd7652a34 | ||
|
|
924158cff2 | ||
|
|
0633aa6614 | ||
|
|
6b67214395 | ||
|
|
2065335c8c | ||
|
|
ca9b3187b5 | ||
|
|
9d1e5c2737 | ||
|
|
a622bdca61 | ||
|
|
6b2dd8fb5d | ||
|
|
bac3c76fa2 | ||
|
|
cf829a1933 | ||
|
|
9c60c8fb78 | ||
|
|
4317d02e0f | ||
|
|
e1d396dbf2 | ||
|
|
e3cd44802a | ||
|
|
71718c1cd8 | ||
|
|
e38474b794 | ||
|
|
3f160464ea | ||
|
|
4be15912cd | ||
|
|
a19f535134 | ||
|
|
0289d8c37c | ||
|
|
3f90aad2d6 | ||
|
|
e16d038bb2 | ||
|
|
32878e9ceb | ||
|
|
2c8004df65 | ||
|
|
80c0955a1a | ||
|
|
b3684f4d1f | ||
|
|
247dc5da4a | ||
|
|
2d02cff7a6 | ||
|
|
83cd8ed256 | ||
|
|
79b6a43095 | ||
|
|
31ea151bb2 | ||
|
|
332a6bba1a | ||
|
|
b6fe405431 | ||
|
|
31e048faf7 | ||
|
|
e23b316faf | ||
|
|
f2a56b6982 | ||
|
|
6ec191e750 | ||
|
|
0202b5195e | ||
|
|
5ca178436f | ||
|
|
fb4b067728 | ||
|
|
de9809688e | ||
|
|
4194842561 | ||
|
|
7fd29e68c3 | ||
|
|
c191d122bd | ||
|
|
4c84fbc1b6 | ||
|
|
63bff8bf96 | ||
|
|
76d823ded0 | ||
|
|
8667e850e3 | ||
|
|
2446d1b876 | ||
|
|
2ddfea67c7 | ||
|
|
5b7f342e14 | ||
|
|
a26b86e003 | ||
|
|
8a142c212c | ||
|
|
afcdf5e17d | ||
|
|
313459b8e4 | ||
|
|
8dad15ecc1 | ||
|
|
44bc18294c | ||
|
|
bebf2ec329 | ||
|
|
107b8486ae | ||
|
|
5c2122ac3b | ||
|
|
22d866d4de | ||
|
|
b3e78e6061 | ||
|
|
d3e6f80d2b | ||
|
|
84a6fc41d1 | ||
|
|
766f235ab7 | ||
|
|
36f70b3229 | ||
|
|
d3c9b4ad47 | ||
|
|
741b0b9d0c | ||
|
|
c10adaf0b0 | ||
|
|
25409e5329 | ||
|
|
539b5ed63e | ||
|
|
b5a9557b50 | ||
|
|
129f0f0156 | ||
|
|
d242e8c69c | ||
|
|
4bdaac5983 | ||
|
|
b94e74a96d | ||
|
|
dfe0096498 | ||
|
|
35b8eac46b | ||
|
|
2069f79198 | ||
|
|
d00b30f0ba | ||
|
|
ba7f35552b | ||
|
|
7d5cd4c89a | ||
|
|
212a927eee | ||
|
|
fc767e1db6 | ||
|
|
49e61d4153 | ||
|
|
4dc5219a3c | ||
|
|
38c7180365 | ||
|
|
73ed4bd637 | ||
|
|
10e4945e5a | ||
|
|
d2cb38cdac | ||
|
|
f30df333b3 | ||
|
|
672d677046 | ||
|
|
b880a976c2 | ||
|
|
2196c7748f | ||
|
|
6254d06c7a | ||
|
|
d6eb8551da | ||
|
|
d8f1d97804 | ||
|
|
f95fa5ee8f | ||
|
|
fb68c731a4 | ||
|
|
b9bdd2eb8b | ||
|
|
8382730bbd | ||
|
|
be38a44b0c | ||
|
|
dcd8b9359a | ||
|
|
c7a1d5665f | ||
|
|
2ddd2940cc | ||
|
|
6116c9bb4a | ||
|
|
b300bb721a | ||
|
|
40461b260a | ||
|
|
ac8dab3855 | ||
|
|
5e6aa7920a | ||
|
|
7637f297c9 | ||
|
|
ef2d033ad6 | ||
|
|
16aeacc37b | ||
|
|
94cdcdcb6a | ||
|
|
f574562a8d | ||
|
|
dc27e79fd2 | ||
|
|
b08adcb9da | ||
|
|
06a2dea1d7 | ||
|
|
8710d6cfd1 | ||
|
|
d809cf4b18 | ||
|
|
19d64fc421 | ||
|
|
892a642bd4 | ||
|
|
fddca9daf1 | ||
|
|
0e196ee86f | ||
|
|
9bf9e12058 | ||
|
|
dfb459a8d6 | ||
|
|
4665e910ee | ||
|
|
af8f74e29e | ||
|
|
f93b7584ff | ||
|
|
8720b9424d | ||
|
|
c7386a12c5 | ||
|
|
80b9243528 | ||
|
|
2de0c6ecb7 | ||
|
|
04032a2a8d | ||
|
|
4b5f096d1c | ||
|
|
4a95f6a1e2 | ||
|
|
36d68ddb26 | ||
|
|
49ac0601e1 | ||
|
|
f58743efbc | ||
|
|
f82dd8ba90 | ||
|
|
b195a38d64 | ||
|
|
b47d45ea4e | ||
|
|
f3c0967f41 | ||
|
|
7e27370923 | ||
|
|
5367f19f1c | ||
|
|
8dbe3d277b | ||
|
|
698a874709 | ||
|
|
3c5f702288 | ||
|
|
cf18678634 | ||
|
|
3e1f119003 | ||
|
|
0ec2b3fd56 | ||
|
|
0bb96fc509 | ||
|
|
3e042f049b | ||
|
|
65c475b549 | ||
|
|
d6cd2805be | ||
|
|
406b9216de | ||
|
|
9e2aeffbe4 | ||
|
|
6a6f9829e3 | ||
|
|
bf8e105440 | ||
|
|
057a2121a5 | ||
|
|
f0c525d0e7 | ||
|
|
422cef00e5 | ||
|
|
89da9fad9a | ||
|
|
4aa689e110 | ||
|
|
0789a36030 | ||
|
|
d9d4a0fcf6 | ||
|
|
22964a2e20 | ||
|
|
02ff88fc61 | ||
|
|
d5a641797c | ||
|
|
3f0839ace1 | ||
|
|
cf017f81e2 |
@@ -0,0 +1,5 @@
|
||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=y"]
|
||||
Generated
+105
@@ -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",
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+16
@@ -10,6 +10,9 @@ node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# uni-app 编译产物(微信小程序/H5 打包输出)
|
||||
unpackage/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -47,3 +50,16 @@ coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 损坏备份(FR-S7 write_file 事故残留)+临时文件
|
||||
*.broken.bak
|
||||
tmp/
|
||||
.temp/
|
||||
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
devflow-dev.db-wal
|
||||
|
||||
+242
-216
@@ -31,6 +31,48 @@
|
||||
└→ 🎯 Release (发布) — 合并多个 Task → 集成测试 → 发布
|
||||
```
|
||||
|
||||
### AI Working 定位体系
|
||||
|
||||
DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的主要操作者,人是监督者与决策者**。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ AI Chat(唯一对话入口) │
|
||||
│ 工具注册表 ←→ Agentic Loop ←→ 审批 │
|
||||
└──────────────┬───────────────────┘
|
||||
│
|
||||
┌───────┬───────┬───┴───┬───────┬───────┐
|
||||
│ 查看 │ 驱动 │ 创意 │ 决策 │ 录入 │ ← 人的五种行为
|
||||
└───────┴───────┴───┬───┴───────┴───────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ IPC 命令层 │
|
||||
│ project / task / idea / │
|
||||
│ knowledge / workflow / ai │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ 数据层 (SQLite) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 人的五种行为(均通过 AI Chat 完成)
|
||||
|
||||
| 行为 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **查看数据** | 不点页面翻列表,自然语言查询 | "最近 blocked 的任务有哪些?" → AI 调 `list_tasks` |
|
||||
| **驱动执行** | 不手动推进状态,对话指令驱动 | "把子1推进到 in_progress" → AI 调 `advance_task` |
|
||||
| **创意捕获** | 不打开灵感表单,对话中自然捕获 | "这个想法记一下" → AI 调 `create_idea` |
|
||||
| **决策审批** | 不逐条点批准,Chat 内确认/拒绝 | 工具审批卡片(Agentic Loop 内) |
|
||||
| **录入信息** | 不填 CRUD 表单,自然语言录入 | "建个项目叫 foo,在 E:/bar" → AI 调 `create_project` |
|
||||
|
||||
#### 关键设计原则
|
||||
|
||||
1. **AI 工具注册表是主接口**:IPC 就绪后必须立刻注册为 AI 工具,页面是旁路查看器
|
||||
2. **页面降级为「查看层」**:Dashboard / Projects / Tasks / Ideas 等页面用于事后查看、审计、回放
|
||||
3. **功能优先级 = AI 工具覆盖度**:人对模块的操作能力取决于 AI 工具注册表中该模块的工具是否完整
|
||||
4. **灵感 / 项目 / 任务 三模块的 AI 工具必须完全覆盖 CRUD + 状态推进 + 升级/评估**
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
@@ -63,7 +105,7 @@
|
||||
│ Workflow Engine (DAG · Node · State · Event · Persist) │
|
||||
├──────────────────────┬───────────────────────────────────────────┤
|
||||
│ AI Orchestrator │ Execution Runtime │
|
||||
│ Multi-Provider │ Shell · Docker · SSH · Git (libgit2) │
|
||||
│ Multi-Provider │ Shell (tokio::process) │
|
||||
│ Agent Coordinator │ Merge · Conflict Resolve │
|
||||
├──────────────────────┴───────────────────────────────────────────┤
|
||||
│ Storage Layer (SQLite) │
|
||||
@@ -79,27 +121,25 @@ devflow/
|
||||
├── crates/
|
||||
│ ├── df-core/ # 核心类型、错误、常量、事件
|
||||
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Docker/Git/Human/HTTP/Subflow)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Router/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell/Docker/SSH/Git)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
|
||||
│ ├── df-execute/ # 执行运行时 (Shell)
|
||||
│ ├── df-storage/ # 存储层 (SQLite)
|
||||
│ ├── df-ideas/ # 想法池引擎 (捕捉/评估/评分/晋升)
|
||||
│ ├── df-project/ # 多项目管理 (调度/上下文/时间线)
|
||||
│ ├── df-task/ # 任务/分支管理 (并行任务/分支/合并/冲突)
|
||||
│ ├── df-traceability/ # 可追溯性 (标注/决策留痕/需求-测试映射)
|
||||
│ ├── df-evolve/ # 经验进化 (知识沉淀/Prompt模板/审查规则/踩坑经验)
|
||||
│ ├── df-stages/ # 阶段插件 (5 阶段内置模板)
|
||||
│ └── df-plugin/ # 插件系统 (WASM/动态库)
|
||||
├── src/ # Tauri 主入口
|
||||
│ ├── main.rs
|
||||
│ ├── state.rs
|
||||
│ └── commands/ # IPC 命令
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ └── df-project/ # 多项目管理 (调度/上下文/时间线)
|
||||
│
|
||||
│ 注: df-task / df-traceability / df-evolve / df-stages / df-plugin
|
||||
│ 5 个 crate 已移除(2026-06-14 零引用清理,推翻原"保留骨架"取舍)
|
||||
├── src-tauri/ # Tauri 主入口 (Rust)
|
||||
│ └── src/
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── components/ # 业务组件
|
||||
│ ├── stores/ # Pinia 状态
|
||||
│ └── composables/ # 组合式函数
|
||||
│ ├── main.rs
|
||||
│ ├── state.rs
|
||||
│ └── commands/ # IPC 命令
|
||||
├── src/ # Vue 3 前端
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── components/ # 业务组件
|
||||
│ ├── stores/ # Pinia 状态
|
||||
│ └── composables/ # 组合式函数
|
||||
├── templates/ # 工作流模板 (YAML)
|
||||
└── plugins/ # 外部插件目录
|
||||
```
|
||||
@@ -137,7 +177,9 @@ devflow/
|
||||
|
||||
项目状态:`Planning / InProgress / Testing / Releasing / Completed / Paused / Cancelled`
|
||||
|
||||
### 5.3.1 Task & Branch Manager (df-task)
|
||||
### 5.3.1 ~~Task & Branch Manager (df-task)~~ — 已移除
|
||||
|
||||
> **2026-06-14 零引用清理**:df-task crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
|
||||
|
||||
项目内部的并行任务管理,每个任务绑定一个 Git 分支。
|
||||
|
||||
@@ -165,7 +207,9 @@ Task 生命周期:
|
||||
2. AI 辅助解决:冲突文件交给 AI 分析并建议解决方案
|
||||
3. 发布编排:选择多个 Task → 创建 release 分支 → 合并 → 集成测试 → 发布
|
||||
|
||||
### 5.4 Traceability & Annotation (df-traceability)
|
||||
### 5.4 ~~Traceability & Annotation (df-traceability)~~ — 已移除
|
||||
|
||||
> **2026-06-14 零引用清理**:df-traceability crate 已删除。以下内容保留作为历史设计参考,不再对应实际代码。
|
||||
|
||||
贯穿所有阶段的可追溯性引擎。
|
||||
|
||||
@@ -238,237 +282,219 @@ pub struct Decision {
|
||||
|
||||
### 5.4 AI Orchestrator (df-ai)
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)
|
||||
- **ContextManager**:Token 预算管理
|
||||
- **ToolRegistry**:工具注册(供 Agent 调用)
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
|
||||
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
|
||||
| 节点 | 功能 |
|
||||
|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 |
|
||||
| ScriptNode | Shell/脚本执行 |
|
||||
| DockerNode | Docker 容器操作 |
|
||||
| GitNode | Git 操作 (libgit2) |
|
||||
| HumanNode | 人工审批/确认 (阻塞) |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) |
|
||||
| HTTPNode | HTTP 请求 |
|
||||
| SubflowNode | 嵌套子工作流 |
|
||||
| 节点 | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器操作 | ❌ 未实现 |
|
||||
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
|
||||
| HTTPNode | HTTP 请求 | ❌ 未实现 |
|
||||
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
|
||||
|
||||
> `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)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
### SQLite 表结构
|
||||
|
||||
> 真相源:`crates/df-storage/src/migrations.rs`(V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
|
||||
|
||||
共 **13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
|
||||
|
||||
```sql
|
||||
-- 想法池
|
||||
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
|
||||
CREATE TABLE ideas (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
source TEXT NOT NULL, -- Manual/Clipboard/AI
|
||||
status TEXT NOT NULL, -- Draft/PendingReview/Approved/Rejected/Promoted/Archived
|
||||
scores TEXT, -- JSON: {market_potential, competition, feasibility, roi, overall}
|
||||
ai_analysis TEXT,
|
||||
related_ideas TEXT, -- JSON array of idea ids
|
||||
promoted_to TEXT, -- project id
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft/pending_review/approved/rejected/promoted/archived
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
score REAL,
|
||||
tags TEXT, -- JSON 数组
|
||||
source TEXT,
|
||||
promoted_to TEXT, -- 晋升后的 project_id (V2)
|
||||
ai_analysis TEXT, -- AI 分析结果 JSON (V2)
|
||||
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 项目
|
||||
-- 项目 (V1 建表 / V11 补 deleted_at / V12 补 path + stack)
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
idea_id TEXT, -- 来源想法
|
||||
repo_path TEXT, -- 仓库路径
|
||||
tech_stack TEXT, -- JSON array
|
||||
status TEXT NOT NULL, -- Planning/InProgress/Testing/Releasing/Completed/Paused/Cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
current_stage TEXT, -- Idea/Requirement/Coding/Testing/Release
|
||||
config TEXT, -- JSON: 项目级配置
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
idea_id TEXT REFERENCES ideas(id),
|
||||
path TEXT, -- 绑定的本地代码目录绝对路径 (V12,可空=未绑定)
|
||||
stack TEXT, -- 技术栈 JSON 数组字符串 (V12,探测填充)
|
||||
deleted_at TEXT, -- 软删回收站: NULL=正常, 非空=已进回收站 (V11)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 工作流定义
|
||||
CREATE TABLE workflow_defs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联的任务 (可为空,如项目级工作流)
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
dag TEXT NOT NULL, -- JSON: DAG 结构
|
||||
template_id TEXT, -- 来源模板
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 任务 (项目内的并行工作单元)
|
||||
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch)
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL, -- Todo/InProgress/InReview/Testing/Done/Blocked/Cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
branch_name TEXT, -- Git 分支名
|
||||
base_branch TEXT DEFAULT 'main', -- 基于哪个分支创建
|
||||
workflow_def_id TEXT, -- 关联的工作流定义
|
||||
assignee TEXT, -- AI agent 或人工
|
||||
merge_conflict TEXT, -- JSON: 冲突信息
|
||||
merged_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
branch_name TEXT,
|
||||
assignee TEXT,
|
||||
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
|
||||
base_branch TEXT, -- 基础分支 (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 发布计划 (合并多个 Task → 发布)
|
||||
-- 分支 (V2 建表) — 任务与 Git 分支绑定
|
||||
CREATE TABLE branches (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
task_id TEXT REFERENCES tasks(id),
|
||||
name TEXT NOT NULL,
|
||||
base TEXT NOT NULL DEFAULT 'main',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
merged_at TEXT
|
||||
);
|
||||
|
||||
-- 发布计划 (V1 建表)
|
||||
CREATE TABLE releases (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL, -- e.g. v2.3.0
|
||||
branch_name TEXT, -- release 分支
|
||||
task_ids TEXT NOT NULL, -- JSON array: 包含的 Task ID 列表
|
||||
status TEXT NOT NULL, -- Planning/Integrating/Testing/Ready/Published
|
||||
workflow_def_id TEXT, -- 发布工作流
|
||||
changelog TEXT,
|
||||
published_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
version TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
task_ids TEXT NOT NULL DEFAULT '[]', -- JSON 数组: 包含的 Task ID 列表
|
||||
changelog TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
);
|
||||
|
||||
-- 工作流执行
|
||||
CREATE TABLE workflow_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_def_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联的任务 (可为空)
|
||||
release_id TEXT, -- 关联的发布 (可为空)
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- Pending/Running/Paused/Completed/Failed/Cancelled
|
||||
node_states TEXT, -- JSON: {node_id: {status(Pending/Running/Completed/Failed/Skipped/Waiting), output, started_at, finished_at}}
|
||||
current_layer INTEGER,
|
||||
started_at INTEGER,
|
||||
finished_at INTEGER,
|
||||
error TEXT
|
||||
-- 工作流执行 (V1 建表 / V2 补 project_id + task_id)
|
||||
-- 注: 不区分 def/run, DAG 直接序列化进 dag_json, 一次执行一行
|
||||
CREATE TABLE workflow_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
dag_json TEXT NOT NULL, -- DAG 的 JSON 序列化
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/running/paused/completed/failed/cancelled
|
||||
triggered_by TEXT,
|
||||
project_id TEXT, -- 关联项目 ID (V2)
|
||||
task_id TEXT, -- 关联任务 ID (V2)
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- 产出物
|
||||
CREATE TABLE artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
workflow_run_id TEXT,
|
||||
node_id TEXT,
|
||||
kind TEXT NOT NULL, -- PRD/CodeChange/TestReport/ReleaseNote/Image
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
file_path TEXT,
|
||||
metadata TEXT, -- JSON
|
||||
created_at INTEGER NOT NULL
|
||||
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
|
||||
CREATE TABLE node_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflow_executions(id),
|
||||
node_id TEXT NOT NULL,
|
||||
node_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
input_json TEXT,
|
||||
output_json TEXT,
|
||||
error_message TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- 连接配置
|
||||
CREATE TABLE connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- MySQL/SSH/MongoDB/Redis/HTTP
|
||||
config TEXT NOT NULL, -- JSON (加密存储)
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- AI 模型配置
|
||||
-- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象
|
||||
CREATE TABLE ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL, -- Claude/GLM/DeepSeek/OpenAI
|
||||
api_key TEXT, -- 加密存储
|
||||
base_url TEXT,
|
||||
models TEXT, -- JSON: 可用模型列表
|
||||
is_default INTEGER DEFAULT 0,
|
||||
config TEXT -- JSON: 额外配置
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL DEFAULT 'openai_compat',
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
default_model TEXT NOT NULL,
|
||||
models TEXT, -- JSON array of model names
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT, -- JSON extra config
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 需求功能清单
|
||||
CREATE TABLE features (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联任务
|
||||
requirement_id TEXT, -- 来源需求
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'selected', -- Selected/Deferred/Rejected/Completed
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
rejection_reason TEXT, -- 不做的原因(自动进入决策留痕)
|
||||
test_case_count INTEGER DEFAULT 0,
|
||||
order_index INTEGER NOT NULL, -- 排序
|
||||
metadata TEXT, -- JSON: 额外属性
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models)
|
||||
CREATE TABLE ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage
|
||||
provider_id TEXT,
|
||||
model TEXT,
|
||||
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
|
||||
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
|
||||
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
|
||||
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 测试用例
|
||||
CREATE TABLE test_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
feature_id TEXT NOT NULL, -- 关联功能
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
preconditions TEXT, -- 前置条件
|
||||
steps TEXT NOT NULL, -- JSON: 测试步骤 [{step, expected}]
|
||||
kind TEXT NOT NULL DEFAULT 'Manual', -- Manual/Auto/AI-Generated
|
||||
priority INTEGER NOT NULL DEFAULT 1, -- 0=Low, 1=Medium, 2=High, 3=Critical
|
||||
-- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕
|
||||
CREATE TABLE ai_tool_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
tool_call_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments TEXT NOT NULL,
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected/executing/completed/failed
|
||||
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
|
||||
requested_at TEXT NOT NULL,
|
||||
executed_at TEXT,
|
||||
decided_by TEXT -- human/auto
|
||||
);
|
||||
|
||||
-- 测试执行记录
|
||||
CREATE TABLE test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
task_id TEXT, -- 关联任务
|
||||
feature_id TEXT, -- 关联功能
|
||||
test_case_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- Passed/Failed/Skipped/Blocked
|
||||
actual_result TEXT,
|
||||
error_detail TEXT, -- 失败详情
|
||||
executed_by TEXT, -- AI/Human
|
||||
duration_ms INTEGER,
|
||||
executed_at INTEGER NOT NULL
|
||||
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
|
||||
-- 状态机: candidate → pending_review → published → archived
|
||||
CREATE TABLE knowledges (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'pitfall', -- 7 种: review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT, -- JSON 数组字符串
|
||||
status TEXT NOT NULL DEFAULT 'candidate', -- candidate|pending_review|published|archived
|
||||
confidence TEXT, -- high|medium|low (AI 提炼自评)
|
||||
reuse_count INTEGER NOT NULL DEFAULT 0, -- 唯一客观排序信号
|
||||
verified INTEGER NOT NULL DEFAULT 0, -- 发布审核时一次性人工标
|
||||
source_project TEXT, -- 来源项目(仅溯源不过滤)
|
||||
source_ref TEXT, -- 来源实体引用(如 conv:{id})
|
||||
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
|
||||
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 标注 (贯穿所有内容)
|
||||
CREATE TABLE annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL, -- Requirement/Feature/Code/TestCase/TestReport/DesignDoc
|
||||
entity_id TEXT NOT NULL, -- 关联实体 ID
|
||||
marker TEXT NOT NULL, -- FIXME/TODO/QUESTION/RISK/DECISION/OPTIMIZE
|
||||
content TEXT NOT NULL, -- 标注内容
|
||||
location TEXT, -- 文件路径+行号 / 文档段落
|
||||
status TEXT NOT NULL DEFAULT 'Open', -- Open/AI-Processing/Resolved/WontFix
|
||||
resolved_by TEXT, -- AI/Human
|
||||
resolution TEXT, -- 处理结果
|
||||
created_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER
|
||||
-- 知识生命线事件 (V10 建表) — 追加型审计: 产生/审核/引用/归档
|
||||
CREATE TABLE knowledge_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
knowledge_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- created | extracted | status_changed | referenced | archived
|
||||
source_ref TEXT, -- 触发来源: conv:{id} / manual / system
|
||||
context_json TEXT, -- JSON: 因 event_type 而异
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 决策留痕
|
||||
CREATE TABLE decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
context TEXT NOT NULL, -- 决策背景
|
||||
question TEXT NOT NULL, -- 需要决定的问题
|
||||
alternatives TEXT, -- JSON array: 考虑过的方案
|
||||
decision TEXT NOT NULL, -- 最终决定
|
||||
reason TEXT NOT NULL, -- 决策原因
|
||||
decided_by TEXT NOT NULL, -- AI/Human
|
||||
entity_type TEXT, -- 关联实体类型
|
||||
entity_id TEXT, -- 关联实体 ID
|
||||
impact TEXT, -- 影响范围
|
||||
stage TEXT, -- Idea/Requirement/Coding/Testing/Release
|
||||
created_at INTEGER NOT NULL
|
||||
-- 通用应用设置 (V13 建表) — 前端 localStorage 迁移目标 KV 表
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL, -- JSON 字符串
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
|
||||
```
|
||||
|
||||
## 七、阶段模板
|
||||
@@ -504,7 +530,7 @@ CREATE TABLE decisions (
|
||||
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周)
|
||||
- df-nodes (Docker/Git/Human/HTTP)
|
||||
- df-stages (5 阶段模板)
|
||||
- ~~df-stages (5 阶段模板)~~ — 已移除(2026-06-14 零引用清理)
|
||||
- 条件分支 + 断点续跑
|
||||
- 验证:跑通标准产研流程模板
|
||||
|
||||
|
||||
Generated
+904
-62
File diff suppressed because it is too large
Load Diff
@@ -16,3 +16,9 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tracing = "0.1"
|
||||
regex = "1"
|
||||
# keyring v3 默认不带任何 platform 后端,必须显式启 feature——
|
||||
# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空。
|
||||
# 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"] }
|
||||
|
||||
+708
-39
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 项目进展与工作交接
|
||||
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-12 | 当前阶段: Phase 2 aichat UX 快赢(A 路线)+ 技能联想需求调研(Sprint 8)
|
||||
> 创建: 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -9,51 +9,49 @@
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (13 crate) / SQLite |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
|
||||
| 路径 | `E:/wk-lab/devflow/` |
|
||||
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
|
||||
| Git 状态 | 未首次 commit,代码全在 untracked |
|
||||
| AI 能力 | df-ai OpenAI 兼容 Provider + 12 工具 + Agentic Loop |
|
||||
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
|
||||
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
|
||||
|
||||
---
|
||||
|
||||
## 二、代码规模统计
|
||||
|
||||
### Rust 后端 (71 个 .rs 文件)
|
||||
### Rust 后端 (78 个 .rs 文件)
|
||||
|
||||
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|
||||
|-------|--------|--------|--------|---------|
|
||||
| df-core | 4 | 259 | 151 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| df-workflow | 7 | 489 | 333 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
|
||||
| df-storage | 4 | 294 | 217 | ✅ 部分 — SQLite连接/迁移/建表完整,**缺 CRUD 层** |
|
||||
| df-execute | 5 | 203 | 129 | ⚡ 混合 — Shell 执行器真实可用,Docker/SSH/Git 骨架 |
|
||||
| df-ideas | 6 | 421 | 278 | ⚡ 大部分 — 捕获/评估/晋升/关联图 有逻辑,评分固定值 |
|
||||
| df-task | 5 | 390 | 252 | ⚡ 大部分 — Task/Branch/Semver 可用,合并冲突 骨架 |
|
||||
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| 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 复用) |
|
||||
| df-ideas | 7 | 761 | ~500 | ✅ 评分/对抗启发式已真实化(Sprint 9)— 捕获/评估/晋升/关联图 有逻辑,晋升/关联图仍骨架 |
|
||||
| df-project | 5 | 272 | 175 | ⚡ 混合 — 数据模型/Timeline 可用,调度器 骨架 |
|
||||
| df-ai | 6 | 281 | 173 | 🔧 接口 — 类型/Provider trait 完整,无实际 LLM 调用 |
|
||||
| df-traceability | 4 | 312 | 201 | ⚡ 混合 — 决策记录/标注构建 可用,SQLite查询 骨架 |
|
||||
| df-plugin | 4 | 255 | 158 | ⚡ 混合 — Host/Builder 有逻辑,核心加载 骨架 |
|
||||
| df-evolve | 6 | 301 | 172 | ⚡ 混合 — Knowledge/PromptTemplate 可用,引擎/提取器 骨架 |
|
||||
| df-nodes | 9 | 344 | 277 | ⚡ 混合 — script_node 真实可用(接 Shell),human_node 半实现(审批响应 TODO),其余 6 种骨架 |
|
||||
| df-stages | 6 | 287 | 209 | 🔩 全骨架 — 11个阶段节点 Schema 完整,execute() 全空 |
|
||||
| **合计** | **71** | **~4,108** | **~2,625** | |
|
||||
| df-ai | 9 | 1,965 | ~1,300 | ✅ 完整 — OpenAI/Anthropic 双协议 Provider + 流式 SSE + 上下文窗口(ContextManager 分组滑窗) + token usage 解析 + 12 工具 |
|
||||
| df-nodes | 9 | ~500 | ~350 | ⚡ 混合 — script_node(接 Shell)/ ai_node(接 df-ai,Sprint 7)真实可用,human_node 半实现(审批响应 TODO),其余骨架 |
|
||||
| **合计** | **53** | **~7,400** | **~4,800** | 已删除 5 个零引用 crate(2026-06-14,原 78/9100/5900) |
|
||||
|
||||
### 前端 (9 页面 + Store + i18n)
|
||||
### 前端 (9 页面 + 2 组件 + Store + i18n)
|
||||
|
||||
| 类别 | 文件数 | 行数 | 状态 |
|
||||
|------|--------|------|------|
|
||||
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳 |
|
||||
| Pinia Store | 4 | ~362 | ⚠️ 有 state/getter 但**无 action,View 未引用** |
|
||||
| Router | 1 | 63 | ✅ 9 条路由含动态路由 |
|
||||
| i18n | 2 | ~94 | ⚠️ 翻译完整但**未接入 main.ts** |
|
||||
| Vue 页面 | 9 | ~3,372 | ✅ UI 全部真实 — 无空壳(含 AiHome/AiDetached)|
|
||||
| Vue 组件 | 2 | ~1,400 | ✅ AiChat(主面板)+ ConfirmDialog |
|
||||
| Pinia Store | 4+ | ~800 | ✅ composable 模式,View 已引用(Sprint 4 接入)|
|
||||
| Router | 1 | ~90 | ✅ 10 条路由含动态路由(Sprint 7 增 AiHome/AiDetached)|
|
||||
| i18n | 2 | ~94 | ✅ 已注册 main.ts(Sprint 7)+ localStorage 持久化 |
|
||||
| 设计系统 | 1 | 105 | ✅ CSS 变量/动画/Arco 暗色覆盖 |
|
||||
| Tauri Commands | 1 | 13 | 🔩 仅 `greet` 示例,无业务逻辑 |
|
||||
| **前端合计** | | **~8,812** | |
|
||||
|
||||
**前端关键问题**:
|
||||
- 所有 View 组件数据**硬编码** (`ref([...])`),与 Store 完全独立
|
||||
- 所有按钮事件处理函数为**空函数** `{}`
|
||||
- i18n 插件未注册到 Vue app
|
||||
- Arco Design 组件库未实际使用(仅 CSS 变量覆盖)
|
||||
> Tauri Commands 已达 **67 个**(src-tauri/src/lib.rs invoke_handler 注册数:project 14 / task 5 / idea 6 / workflow 5 / ai 19 / knowledge 14 / settings 4),见后端 src-tauri/src/commands/。
|
||||
|
||||
**前端关键问题**(Sprint 4/7 已全部解决):
|
||||
- ~~所有 View 组件数据硬编码~~ ✅ Sprint 4 接 Store 真数据
|
||||
- ~~所有按钮事件处理函数为空函数~~ ✅ Sprint 4 接 Store action
|
||||
- ~~i18n 插件未注册~~ ✅ Sprint 7 注册 + $t() 全 view 改造
|
||||
- ~~Arco Design 组件库未实际使用~~ ✅ 已用(表单/弹窗/消息等),CSS 变量覆盖暗色
|
||||
|
||||
---
|
||||
|
||||
@@ -68,11 +66,11 @@
|
||||
|
||||
待打通路径(骨架就位,需填充):
|
||||
df-workflow → df-nodes (8种节点 execute() 待实现)
|
||||
df-workflow → df-stages (11种阶段节点 execute() 待实现)
|
||||
df-ai → LlmProvider 实现 (无 HTTP client)
|
||||
df-storage → 所有 crate 的 CRUD 操作
|
||||
Tauri commands → Rust 业务逻辑
|
||||
Vue Views → Pinia Store → Tauri IPC → Rust
|
||||
~~df-workflow → df-stages (11种阶段节点 execute() 待实现)~~ — df-stages 已移除(2026-06-14 零引用清理)
|
||||
~~df-ai → LlmProvider 实现~~ ✅ 已打通(OpenAI/Anthropic 双协议真实 HTTP,Sprint 5/8)
|
||||
~~df-storage → 所有 crate 的 CRUD 操作~~ ✅ 已打通(impl_repo! 宏 CRUD + 多 Repo,Sprint 2)
|
||||
~~Tauri commands → Rust 业务逻辑~~ ✅ 已打通(Sprint 3 时点 47 commands 全接业务;现 67)
|
||||
~~Vue Views → Pinia Store → Tauri IPC → Rust~~ ✅ 已打通(Sprint 4)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -89,6 +87,8 @@
|
||||
| 6 | **条件表达式引擎** — 仅支持 true/false 字面量 | 条件分支不可用 | P2 | ⬜ 待办 |
|
||||
| 7 | **i18n 未注册** — 翻译文件存在但未挂载 | 多语言不生效 | P2 | ✅ Sprint 7 |
|
||||
| 8 | **未 git commit** — 代码全 untracked | 无版本基线 | P0 | ✅ Sprint 2 |
|
||||
| 9 | **AI 删项目绕过软删** — tool_registry:263 `repo.delete` 物理删,IPC 走 `soft_delete` 进回收站;AI 层缺 restore/purge/list_trash 工具(仅 project 表有回收站设计,故最该修) | AI 对话删项目 = 数据丢失不可恢复,与 UI 删语义割裂 | P1 | ✅ Sprint 21 已修(tool_registry 补 restore_project/purge_project/list_trash,delete_project 改 soft_delete) |
|
||||
| 10 | **api_key 明文存储** — DB 落盘仍明文(migrations.rs:446 api_key TEXT NOT NULL) | 本地库被读/备份外流则泄露 | P0 | 🟡 部分修(FR-S1:IPC mask+编辑不回填✅ commit 49ac060;DB 明文落盘❌ 待迁移 keyring 单列) |
|
||||
|
||||
---
|
||||
|
||||
@@ -121,9 +121,9 @@
|
||||
| AI Chat 面板 | ✅ | 侧边栏 + 分离窗口 + 拖拽调宽 + 对话管理 |
|
||||
| Agentic Loop | ✅ | 多轮工具调用循环(max 10 轮)+ 中/高风险人工审批 |
|
||||
| AI Node 实现 | ✅ | df-nodes 接 df-ai:config 驱动 provider(base_url/api_key/model/prompt)→ OpenAICompatProvider.complete → 输出 text/model/usage;注册 "ai" 节点;未端到端实测 |
|
||||
| **AI Chat LLM 优化** | 🚧 P0 完成 | 任务 #43:P0 可靠性(超时/断连/停止生成)✅;P1 token 控制(usage采集/滑动窗口/摘要)⬜ | |
|
||||
| **AI Chat LLM 优化** | ✅ ABCD 完成 | 任务 #43:P0 可靠性 ✅;P1 上下文窗口(ContextManager 分组滑窗)✅ + 并行化(工具 join_all / save 后台)✅;**Sprint 14 流式 token 用量记录(usage 解析 + 累加落库 + 展示 + Settings 开关)✅**;Part C 并发 Semaphore(双层 + 运行时可调)+ Part D Settings UI ✅(Sprint 16);Review 两轮 8 修复 ✅ | |
|
||||
|
||||
### Phase 3 — 想法池 + 多项目 (3-4 周) | ⬜ 未开始
|
||||
### Phase 3 — 想法池 + 多项目 (3-4 周) | 🚧 进行中 — Sprint 9 评估闭环已通(启发式评分 + 对抗评估接 IPC),晋升前端层闭环,多项目/关联图待续;Sprint 12 全面审查修 5 项前端 bug(置信度条/状态选择/删除确认等),死代码组缓另会话重审
|
||||
### Phase 4 — 节点丰富 + 阶段插件 (3-4 周) | ⬜ 未开始
|
||||
### Phase 5 — 体验打磨 (4-6 周) | ⬜ 未开始
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
**工作内容**:
|
||||
- 创建项目骨架:`bun create vite` + Tauri v2 + Cargo Workspace
|
||||
- 编写完整架构文档 `ARCHITECTURE.md` (22,745 字)
|
||||
- 13 个 Rust crate 全部创建,类型系统和接口设计完整
|
||||
- 13 个 Rust crate 全部创建,类型系统和接口设计完整(2026-06-14 评审后删 5 僵尸 crate,现 8 crate)
|
||||
- 9 个 Vue 页面 UI 全部编写(~3,372 行),设计系统完善
|
||||
- 4 个 Pinia Store 定义(state + computed)
|
||||
- i18n 中英文翻译
|
||||
@@ -318,7 +318,7 @@
|
||||
- **审查项落地**:删全屏 Tool Approval Modal(保留行内卡片审批)、错误友好化(`friendlyError` 正则映射 404/401/timeout/network → 中文提示 + `isError` 红色气泡)、智能滚动(`isNearBottom` < 80px 阈值)、provider bar 点击切换、未配 provider 空状态引导、删死 class
|
||||
- **B 路线(规划式多智能体)单独立项**:填 `coordinator.rs` + `conditions.rs` 空壳,详见 memory `aichat-decision-capability`
|
||||
|
||||
**B. 技能联想功能需求(本轮重点,未实现,需求阶段)**:
|
||||
**B. 技能联想功能(首批 Claude 3 类,已实现待实测)**:
|
||||
- **需求**:aichat 输入框输入 `/` → 联想列出本机 Claude 技能(SKILL.md),按输入过滤,选中插入 `/name`;发送时后端读 SKILL.md 全文注入 system prompt / context
|
||||
- **首批数据源 = Claude 3 类**(SKILL.md frontmatter 统一 `name` / `description` / `user_invocable` / `metadata.argument-hint` / `triggers[]`,只取 `user_invocable: true`):
|
||||
1. Claude skills:`~/.claude/skills/*/SKILL.md`(23 个:sleep / mission-control / idea / cpa 等)
|
||||
@@ -338,10 +338,679 @@
|
||||
**代码变更**:A 路线 — `crates/df-ai/src/{anthropic_compat.rs, lib.rs}`、`src-tauri/src/commands/ai.rs`、`crates/df-nodes/src/ai_node.rs`、`src/views/Settings.vue`、`src/stores/ai.ts`、`src/components/AiChat.vue`、`src/api/types.ts`
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **技能联想(B)未实现** — 需求已记,首批聚焦 Claude 3 类,等排期(工作量中:后端扫文件 + IPC、前端联想浮层)
|
||||
1. **技能联想(B)已实现待实测** — `ai_list_skills` IPC(扫 skills/commands/plugins 三类 + frontmatter 解析 + name 去重)+ `ai_chat_send` 加 `skill` 参数读 SKILL.md 注入 system prompt + 前端 `/` 浮层(↑↓ 导航 / Enter·Tab 选中 / chip 显示 / Esc 关)。cargo check ✓(7 warning 既有)/ bun build ✓(4.68s)。实测:重启 dev → AiChat 输入 `/` → 选技能 → 发送
|
||||
2. **A 路线剩余场景实测**:切对话不中断(场景 2/3)、错误友好化(场景 4)待用户实测
|
||||
3. **B 路线(规划式能力)**:coordinator / conditions 空壳,单独立项
|
||||
|
||||
### [Sprint 9] 2026-06-12 — 灵感模块评估闭环(启发式评分 + 对抗评估接 IPC)
|
||||
|
||||
**工作内容**:
|
||||
- **df-ideas 修复与真实化**:① 修 adversarial.rs E0308 类型错(`AdversarialEval.negative: Argument` vs `generate_negative_argument` 返 `CounterArgument` 直接赋值,df-ideas 实际编译不过——前文档"cargo check 通过"为误记)→ 删 CounterArgument、正反方归一 Argument;② scoring.rs 三维固定 5.0 → 内容启发式(priority / 描述充实度 / tags / 中英关键词,clamp 0-10);③ adversarial.rs 正反方论点/evidence 基于真实 idea 内容生成,confidence 由评分驱动,final_score=综合评分,AssessmentLevel 由 overall 映射
|
||||
- **devflow 接 df-ideas**:`src-tauri/Cargo.toml` +df-ideas +chrono 依赖(df-ideas 此前为孤儿 crate,devflow 未依赖、零 IPC 接入)
|
||||
- **evaluate_idea IPC**(commands/idea.rs):取 IdeaRecord → record_to_idea 转 df-ideas::Idea → ScoringEngine + AdversarialEngine.evaluate → 组装前端扁平结构(对齐 Ideas.vue AdversarialEval interface)→ 写回 scores(中文维度 0-100)/score(0-100)/ai_analysis/status=pending_review → 返回更新记录;lib.rs 注册
|
||||
- **前端接真实评估**:api/idea.ts +evaluate;stores/project.ts +evaluateIdea action;Ideas.vue 删 mockEval 调真实 + evaluating loading 态
|
||||
- **审核修 2 bug**:① recommendation 大小写(后端原 "With Resources" 与前端 assessmentLabel 全小写 map key 不匹配致中文标签不显示)→ 全小写空格;② assessmentClass 生成的 class 与 CSS 类名不一致致 badge 无色 → map 映射到 .immediate/.soon/.conditional/.revised/.defer/.cancel
|
||||
|
||||
**代码变更**:df-ideas/{scoring,adversarial}.rs、src-tauri/{Cargo.toml,commands/idea.rs,lib.rs}、前端 {api/idea.ts,stores/project.ts,views/Ideas.vue}(8 文件)
|
||||
|
||||
**验证**:cargo check -p df-ideas ✓、cargo check -p devflow ✓、bun run build ✓(vue-tsc + vite 3.3s)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. 对抗评估仍启发式,未接 df-ai LlmProvider(设计:LLM 生成正反方论点 + 启发式 fallback)
|
||||
2. promotion.rs do_promote 仍 TODO(晋升走前端 createProject 闭环,不经 df-ideas/promotion)
|
||||
3. evaluate_idea 用 list_all().find() + 5 次 update_field(IdeaRepo 无 get/多字段更新,量小可接受)
|
||||
4. IdeaGraph 关联图零接入(自动关联发现/聚类待续)
|
||||
5. scores JSON 含「综合」维度也被雷达图渲染一行(小瑕疵)
|
||||
|
||||
### [Sprint 10] 2026-06-12 — AiChat 工具卡片自动折叠(卡片级折叠 + 新内容追加自动收起)
|
||||
|
||||
**工作内容**:
|
||||
- **需求**:对话中工具调用卡片(List Projects / List Tasks / Create Idea / List Ideas / 读取文件等)完成后仍整卡铺开,多步调用时界面臃肿。目标——展示更友好紧凑:所有卡片可点击 header 展开/收起,新内容追加时旧卡片自动收起为单行 header(类似 ChatGPT/Cursor 旧 tool call 折叠)
|
||||
- **卡片级折叠**(区别于 read_file 已有的内容级折叠):`.ai-tool-card` 内 body 区(骨架屏/审批/拒绝/file/dir/write/通用结果)包进 `.ai-tool-body`,`v-show` 控制显隐;header 加 `@click` + ▸ 箭头指示器;新增 `expandedCards` Set + `toggleCardExpand` + `cardCollapseClass`,与既有 `expandedTools`(read_file 预览级)职责分离不互相干扰
|
||||
- **自动收起**:deep watch `store.state.messages` + 轻量 JSON snapshot diff 检测新内容(新消息 / toolCall 状态变化 / 文本增长)→ 清除旧 completed/rejected 的展开态,保留 running/pending 活跃卡片;`isFirst` guard 防首次加载/切对话误触发
|
||||
- **running/pending_approval 强制展开**(需看骨架屏与审批按钮);`v-show` 保留 DOM 避免重挂载
|
||||
|
||||
**代码变更**:`src/components/AiChat.vue`(template header/body 包裹 + script 状态与 watcher + style 折叠态),单文件 7 处改动
|
||||
|
||||
**验证**:vue-tsc --noEmit ✓(运行时待 `bun run tauri dev` 实测多步工具调用折叠效果)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. ~~审查项 ①~~ ✅ 已落地(2026-06-12):`isRunningOrPending(status)` → `shouldKeepOpen(tc)`,`running` / `pending_approval` / `rejected` / `write_file` 强制展开(短结果不折叠、原因/路径不丢失),仅 `read_file` / `list_directory` / 通用 JSON 大体量结果折叠。vue-tsc --noEmit ✓。决策详见 [功能决策记录 - 短结果保持展开](docs/02-架构设计/功能决策记录.md)
|
||||
2. 实测验证:tauri dev 发多步工具调用消息,观察"执行中展开→完成→新内容追加自动收起→点击 header 重展开"
|
||||
|
||||
### [Sprint 11] 2026-06-13 — AI Chat LLM 优化(上下文窗口管理 + 并行化)
|
||||
|
||||
**任务 #43 续**(Part A + B 落地,Part C/D 待续)
|
||||
|
||||
**工作内容**:
|
||||
|
||||
**Part A: 上下文窗口管理(df-ai/context.rs + ai.rs 接线)** — 长对话无限增长致 `context_length_exceeded` 死锁
|
||||
- **A1 ContextManager**(`crates/df-ai/src/context.rs` 新增 ~350 行):TokenEstimator 字符粗估(chars×0.35 + per_message 4 + per_tool_call 30,零依赖,保守 ±15%)+ ContextConfig(max_tokens 128k / output_reserve 8192 / safety 0.85)+ 分组滑动窗口淘汰——淘汰单元保工具调用三元组原子性(ToolCallHead + ToolResultTail* + 紧随文本 Assistant 同进同出),保护区最后 6 条永不裁;裁剪仅影响发送视图(build_for_request 返裁剪 Vec),不影响持久化(all_messages_clone 返全量)。5 单测全绿(短对话无裁剪 / 超预算裁剪保保护区 / 三元组原子性双分支 / replace 重估 token / restore 重建缓存)
|
||||
- **A2 ai.rs 接线**(~15 处):AiSession.messages 类型 `Vec<ChatMessage>` → `ContextManager`;核心 run_agentic_loop 用 `build_for_request(sys_tokens)` 替代全量 `.clone()`;save/title 用 `all_messages_clone()`;switch 用 `restore_from_messages()`;`replace_tool_result` 收敛为 ContextManager 方法(DRY,删自由函数)
|
||||
|
||||
**Part B: 并行化改造(ai.rs)** — 串行瓶颈 + Completed 事件阻塞
|
||||
- **B1 process_tool_calls 并行化**:Low 风险工具 `futures::future::join_all` 并行 execute + 即时 emit Completed,结果串行回填 session.messages(N 个独立工具 ≈ 最慢一个耗时,原 N 倍串行);Med/High 仍串行审批门控。tool_result push 顺序变化无语义影响(LLM 按 tool_call_id 关联,不看绝对位置)
|
||||
- **B2 正常完成后台化**:save+extract+title 打包同一 `tauri::async_runtime::spawn`(非裸 spawn save——保 maybe_spawn_extraction 读已落库消息的顺序依赖);Completed 事件即时下发
|
||||
- **B3 标题后台化**:`ensure_conversation_title` 签名收 `provider_config` 克隆进 task 自建 provider(`&dyn` 非 'static 无法 move),`spawn_ensure_title` 后台(含 2 处 stop 路径)
|
||||
|
||||
**关键取舍**:①打包 spawn 而非裸 spawn save(保 extract 顺序依赖);②并发 upsert 低概率丢少量 token 可接受(非功能错误);③停止路径保持同步 save(避免 stop 后即发新消息的并发 upsert 竞态丢 token)
|
||||
|
||||
**代码变更**:`crates/df-ai/src/context.rs`(新增)、`src-tauri/src/commands/ai.rs`(核心改造)、`docs/02-架构设计/功能决策记录.md`(8 条新决策:Part A 2 + Part B 3 + 既有的)
|
||||
|
||||
**验证**:cargo check -p df-ai ✓(5 单测绿)、cargo check -p devflow ✓(6 既有 warning,无 error,无单测回归)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **Part C/D 未做**(todo #48-52 已建)—— Part C 双层 Semaphore(global 默认 3 / per_conv 默认 2)+ 3 LLM 调用点(stream_llm / 标题 / 提炼)加 permit + `ai_set_concurrency_config` command;Part D Settings.vue 两 setting-row + localStorage 持久化 + onMounted 同步
|
||||
2. **实测验证**(P1):tauri dev 验证长对话裁剪行为 + 多工具并行耗时 + Completed 事件即时性
|
||||
|
||||
### [Sprint 12] 2026-06-13 — 灵感模块全面审查 + A 类问题修复
|
||||
|
||||
**工作内容**:
|
||||
|
||||
- **df-ideas 灵感模块全面审查**(前端 Ideas.vue / api / store + IPC idea.rs + crate 7 文件 + df-project/manager + df-storage/crud):按「界面展示 / 交互 / 功能可用性 / 合理性」+「功能架构 / 代码逻辑 / 代码架构」六维逐条核对,共 12 项发现(🔴5 🟡5 ⚪2)。复核确认 12 项全部真实存在、无误判无虚报。
|
||||
- **修复 A 类 5 项**(明确知道怎么做、零争议,全在 Ideas.vue):
|
||||
- ① 置信度进度条不显示填充(`::after` 无 width + 锚点跑到 `.debate-column`)→ 拆 `.confidence-bar`(灰轨)+ `.confidence-fill`(填充层,正绿反红),删失效 `::after`
|
||||
- ② 状态 select 改选不生效(v-model 绑只读 computed 致写失败、回弹并写回旧 status)→ 弃 v-model,改 `:value` + `@change` 读 `event.target.value`
|
||||
- ③ 删 `filterIdeas` 空函数及其 `@input` 绑定(filteredIdeas 本是 computed 自动响应)
|
||||
- ④ 删除想法加 `confirm()` 二次确认
|
||||
- ⑥ 对抗评估标题加「启发式」tag(诚实标注当前为启发式非 LLM,避免「对抗辩论」措辞误导)
|
||||
- **决策**:死代码/双套抽象组(⑦⑧⑨⑩)缓到另一会话,届时重新审核问题真实性;B 类(⑤⑥根治⑪⑫)方案未定,待定方向后再推。
|
||||
|
||||
**代码变更**:`src/views/Ideas.vue`(单文件 9 处:模板 5 + script 3 + CSS 2 段)
|
||||
|
||||
**验证**:`vue-tsc --noEmit` ✓(零错误零输出)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **B 类(有必要但方案未定)**:⑤ 立项引导(三种改法改变产品流程语义:放宽显示条件 / 保持 approved 门槛加提示 / 评估达标自动 approved)/ ⑥ 对抗评估接 df-ai LlmProvider 根治(大工程,单独立项;当前 UI「启发式」标记为临时绕过)/ ⑪ 状态常量集中(跨 Rust+TS 双侧,IdeaStatus 枚举已有但 IdeaRecord 用 String 落库)/ ⑫ 实体↔Record 映射(评估为合理分层,建议不改)
|
||||
2. **缓组(另会话重审真实性后再动)**:⑦ df-ideas 死代码空壳(capture/graph/evaluator/promotion;IdeaGraph 关联图是已知下一步增强需求,删/留需定)/ ⑧ 两套 Recommendation 枚举 + 两套评估入口并存(adversarial 6 变体实用 vs evaluator 5 变体仅死代码引用)/ ⑨ PromotionPolicy 三档死枚举 / ⑩ Idea 实体构造分散(record_to_idea 与 IdeaCapture::capture 字段重叠)
|
||||
3. **实测验证**:tauri dev 看置信度条彩色填充 / 状态切换生效 / 删除弹确认 / 评估区「启发式」标
|
||||
|
||||
### [Sprint 13] 2026-06-13 — 文档记录治理体系(路由规范 + decision-record skill + 降频自检 hook)
|
||||
|
||||
**背景**:功能决策/需求散落于对话、PROGRESS、模块文档,缺统一真相源与触发机制。建立「单一真相源 + 自动自检」治理体系。
|
||||
|
||||
**工作内容**:
|
||||
- **文档记录规范**(`docs/02-架构设计/文档记录规范.md` 新建):核心原则(SSOT / 不复制只引用 / 决策与流水分离 / 先主后辅)+ 文档职责矩阵(每类信息唯一主文档)+ 内容路由表 + 更新顺序 + 唯一性记录与检测(记前 grep 查重 / 单向引用 / 定期扫描)+ 与 skill 关系 + 治理体系实现决策(hook 设计取舍)
|
||||
- **功能决策记录扩展**(`docs/02-架构设计/功能决策记录.md`):约定节加需求条目说明;末尾新增「需求与待办」总览(📋 待做需求表 + 需求澄清)——散落各决策条目 📐/🚧 状态的待办汇集为单一清单
|
||||
- **decision-record skill 增强**(`~/.claude/skills/decision-record/SKILL.md`):除「为什么这么定」决策,增记需求细节(待做/规格/澄清);维护规则(记前查重 / 演进 `→` 标注 / 定期压缩防爆炸);原则补「更新优先于新增」
|
||||
- **降频自检 Stop hook**(`~/.claude/hooks/dr-check.sh` + `settings.json` Stop 配置):累计 ≥10 轮 或 (>1 轮 且 距上次 ≥10 分钟) 才注入自检提示触发 skill;纯脚本计数无 API(替代每轮自检,省 ~90% token);`stop_hook_active` guard 防循环;状态按项目隔离(`~/.claude/.dr-state/`,不污染 git)。设计取舍详见 [文档记录规范 - 治理体系实现决策](docs/02-架构设计/文档记录规范.md)
|
||||
- **修 `docs/INDEX.md` 误登记**:PROGRESS 实际在根级,移除 `07-项目管理/` 树下的重复登记行
|
||||
|
||||
**代码变更**:非产品代码——`docs/02-架构设计/{文档记录规范.md,功能决策记录.md,INDEX.md}`、`~/.claude/{skills/decision-record/SKILL.md,hooks/dr-check.sh,settings.json}`
|
||||
|
||||
**验证**:hook 逻辑实测(首战静默初始化 / 2-9 轮静默计数 / 第 10 轮或 ≥10 分钟触发 / 防循环 guard);无 jq 依赖纯脚本
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. hook 实际长期触发频率待观察(10 轮 / 10 分钟阈值是否合理,按需调)
|
||||
2. 决策爆炸后执行首次压缩(> 300 行 / 老 Sprint > 3 / 单域 > 10 条触发归档或拆域)
|
||||
|
||||
### [Sprint 14] 2026-06-13 — 流式 token 用量记录 + Provider 默认态持久化
|
||||
|
||||
**工作内容**:
|
||||
|
||||
**A. 流式 Token 用量获取 + 记录 + 展示 + 设置开关**(跨 df-ai/df-storage/src-tauri/前端,11 文件)— `stream_llm` 全程丢弃 token 计数、`AiCompleted.total_tokens` 恒 0 的旧疾根治(Sprint 6 P1 token 控制中「usage 采集用户叫停」项重启落地):
|
||||
- **Provider 层 usage 解析**:`StreamChunk` 加 `usage: Option<TokenUsage>`;OpenAI 开 `stream_options:{include_usage:true}` 末 chunk 取累计 usage;Anthropic `message_start`/`message_delta` 累积(output_tokens 当累计值覆盖,非增量)
|
||||
- **累加落库**:`ai_conversations` 加 `prompt_tokens`/`completion_tokens` 列(V5 迁移);`save_conversation` upsert 走累加模式(读旧值叠加,非覆盖——保审批暂停→恢复跨 loop 实例总用量正确);`run_agentic_loop` 局部累加器每轮叠加
|
||||
- **前端展示 + 开关**:store 双状态 `lastTokenUsage`(单次)+ `convTokenTotal`(对话累计,切对话从 DB 加载);AI 气泡底部 token 条(默认关闭,`df-show-token-usage` 开关放 Settings)
|
||||
- **model 记录**:消息级 model + 对话级 models 数组(C+B),取 `default_model` 配置值补填不覆盖
|
||||
|
||||
**B. Provider 默认态持久化(is_default 重启丢失 bug 修复)** — 「设为默认」重启后丢失、两条记录都显示「设为默认」按钮的 bug 根治:
|
||||
- 根因:`is_default` 字段恒写 `false` + `ai_set_provider` 只改内存 session 不落库 → 重启 session 清零默认全丢
|
||||
- 修复:`ai_set_provider` 互斥落库(目标 true/其余 false,仅写变化记录);`ai_save_provider` 新建首个自动默认;`ai_list_providers` 直返 DB 值;`active_provider_id` 降为运行时缓存
|
||||
|
||||
**代码变更**:A — df-ai/{provider,openai_compat,anthropic_compat}.rs、df-storage/{models,crud,migrations}.rs、src-tauri/commands/ai.rs、前端 {types,stores/ai,components/AiChat,views/Settings}(11 文件);B — src-tauri/commands/ai.rs(3 函数)
|
||||
|
||||
**验证**:cargo check --workspace ✓(0 error,6 既有 warning)、vue-tsc --noEmit ✓。**未 tauri dev 运行时实测**
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. tauri dev 实测:token 落库 / 切换对话总量加载 / 重启保默认 / 首个自动默认
|
||||
2. `ai_save_provider` get_by_id 双调用(review 🟡①,冗余往返,待合并)
|
||||
3. Settings 页面 UI 重构(Part D,用户说稍后)
|
||||
4. model UI 展示(暂缓)
|
||||
|
||||
### [Sprint 15] 2026-06-13 — 知识库 Tier 1 全栈打通 + Phase 5.5 向量检索
|
||||
|
||||
**工作内容**:
|
||||
|
||||
**Tier 1 知识库双向闭环(AI Chat ↔ 知识库,全栈打通)** — candidate→published 状态机 + AI 提炼 + 手动录入 + 知识注入:
|
||||
|
||||
**A. 存储层(df-storage,V7/V8 迁移)**:
|
||||
- V7 建表 `knowledges`(id/kind/title/content/tags/status/confidence/reuse_count/verified/source_project/source_ref/created_at/updated_at),3 索引
|
||||
- V8 幂等补列 `embedding BLOB`(PRAGMA table_info 探测,同 v4/v5/v6 模式)
|
||||
- `KnowledgeRecord`(models.rs),含 `embedding: Option<Vec<u8>>`
|
||||
- `KnowledgeRepo`(crud.rs):`search()`(LIKE 双分支,按 kind 可选过滤)/ `list_by_status()`(CASE WHEN confidence 语义排序)/ `increment_reuse_count()`(SQL 原子 +1)/ `top_used()`;向量扩展:`set_embedding()` / `search_vector()`(纯 Rust 余弦批量比较,skip 维度不匹配);工具函数:`f32s_to_blob` / `blob_to_f32s` / `cosine_similarity`
|
||||
|
||||
**B. 领域层(df-evolve)**:
|
||||
- `KnowledgeStatus` 枚举(Candidate/PendingReview/Published/Archived,snake_case,`from_str`/`as_str`)
|
||||
- `Confidence` 枚举(High/Medium/Low,同上)
|
||||
- `Knowledge` struct:移除 `effectiveness` 字段,时间字段统一 String 毫秒(对齐全部既有 model)
|
||||
- `pattern.rs`:清理死参数 `result_quality: f32`
|
||||
|
||||
**C. IPC 层(src-tauri/commands/knowledge.rs 新建,11 个 Command)**:
|
||||
- `knowledge_{list,get,search,create,update_status,record_reuse,list_candidates,archive,get_config,save_config,extract_now}`
|
||||
- 状态转换合法矩阵硬编码:candidate→{pending_review|published|archived}; pending_review→{published|archived}; published→{archived}
|
||||
- `knowledge_archive`(软删除,匹配 `ai_conversation_archive` 语义)
|
||||
- 配置读写:`knowledge_{get,save}_config`(读写 `AppState.knowledge_config: Arc<Mutex<KnowledgeConfig>>`)
|
||||
- 手动提炼:`knowledge_extract_now`
|
||||
|
||||
**D. AI 集成(ai.rs,知识注入 + 提炼)**:
|
||||
- `build_knowledge_context()`:`auto_inject` 开关 → `hybrid_search()` top-3 → `increment_reuse_count` fire-and-forget → 注入 system prompt 头部
|
||||
- `extract_knowledge_from_conversation()`:后台 spawn,min_messages≥4 守卫 → 取最后 6 条消息 → LLM JSON 提炼 → 逐条写 candidate;提炼失败仅 warn 不阻断
|
||||
- `maybe_spawn_extraction()`:agentic loop 两处正常退出统一调用(`OnComplete` 默认模式)
|
||||
|
||||
**Phase 5.5 — 向量检索(Settings 开关,默认关闭)**:
|
||||
- `LlmProvider::embed()` trait 方法:默认返回 Err,openai_compat 实现 POST `/v1/embeddings`(按 index 排序)
|
||||
- `KnowledgeConfig` 加三字段:`vector_enabled`(默认 false)/ `embedding_provider_id`(排除 anthropic)/ `embedding_model`
|
||||
- `generate_embedding()`:截断 8000 字 → build_provider → embed()
|
||||
- `spawn_embedding_for_knowledge()`:知识发布时 fire-and-forget 生成嵌入(不在 candidate 阶段浪费调用)
|
||||
- `hybrid_search()`:三层降级链——开关关→LIKE; embed 失败→LIKE; 正常→双信号排序(同时命中>仅 LIKE>仅向量 cos≥0.3);不引入 sqlite-vec(规避 Windows MSVC C 扩展编译风险)
|
||||
- `Settings.vue`:语义检索开关 + v-if 联动 embedding provider 下拉 + 模型名输入
|
||||
|
||||
**E. 前端(TypeScript + Vue)**:
|
||||
- `types.ts`:`KnowledgeRecord` / `CreateKnowledgeInput` / `KnowledgeConfig`(含 vector 三字段)
|
||||
- `api/knowledge.ts`(新建,11 个 invoke 封装)
|
||||
- `stores/knowledge.ts`(完全重写,getter 模式,消除全部 15 条 mock + localStorage)
|
||||
- `Knowledge.vue`(完全重写:知识库/审核收件箱双 Tab + 状态徽章 + confidence 指示器 + 新增对话框 + 候选 badge)
|
||||
- `App.vue`:侧栏候选 badge(`candidateCount` ref,store loadCandidates 后更新)
|
||||
|
||||
**F. 配置设计**(AppState 内存 + IPC,非 Tauri config 插件):
|
||||
- `KnowledgeConfig`(state.rs):auto_extract / trigger_mode / min_messages / idle_timeout_ms / auto_inject / vector_enabled / embedding_provider_id / embedding_model
|
||||
|
||||
**代码变更**:df-storage(models.rs/crud.rs/migrations.rs)/ df-evolve(knowledge.rs/pattern.rs)/ src-tauri(state.rs + commands/knowledge.rs 新建 + commands/ai.rs + lib.rs)/ 前端(api/types.ts + api/knowledge.ts 新建 + api/index.ts + stores/knowledge.ts + views/Knowledge.vue + views/Settings.vue + App.vue)。约 1,400 行 Rust + 700 行 TS。
|
||||
|
||||
**验证**:cargo check 0 error ✓,vue-tsc 0 error ✓。**未 tauri dev 运行时实测**
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. tauri dev 实测 10 项功能验证清单(手动录入 / 审核流 / 归档 / 检索注入 / reuse_count / AI 提炼 OnComplete / AI 提炼 Manual / 配置修改 / 注入开关 / 矛盾场景)
|
||||
2. 向量检索待实测(需配 embedding provider + 开启开关 + 发布 ≥1 条知识后验证混合排序)
|
||||
3. Tier 2 待做:ai_node prompt 注入 / 工作流 NodeFailed→pitfall / 决策记录→architecture_pattern(前置 traceability 持久化)
|
||||
4. 知识库 API 对外暴露(MCP Shell 封装,外部工具 Claude Code/CodeX/Cursor 使用)
|
||||
|
||||
### [Sprint 16] 2026-06-13 — AI Chat LLM 优化收尾(Part C/D + Review 修复) + AI Node 单测
|
||||
|
||||
**工作内容**:
|
||||
- **Part C LLM 并发控制**:`state.rs` 新增 `LlmConcurrency`(双层 `Arc<Mutex<Arc<Semaphore>>>` — 全局默认 3 / 单对话默认 2,运行时替换内层 Arc 重建);`ai.rs` 3 个叶子 LLM 调用点(stream_llm / generate_title / extract_knowledge)加 permit,acquire 顺序固定 global→per_conv 防死锁,stream 后显式 `drop` 不占工具执行槽;新增 `ai_set_concurrency_config` command(运行时调并发上限);`lib.rs` 注册
|
||||
- **Part D Settings UI**:`Settings.vue` 通用设置区加「全局最大并发 / 单对话并发」两 setting-row + 前端 clamp(1-10 / 1-5)+ debounce 300ms + localStorage 持久化 + onMounted 启动同步 + onUnmounted 清 timer
|
||||
- **Review 两轮 8 修复**:首轮 6(① per_conv 单例语义注释 / ② save-extract 并发注释改正 / ③ permit 显式 drop / ④ Settings clamp / ⑤ 合并 to_chat_messages 进 all_messages_clone / ⑥ system prompt 超 budget warn)+ 复审 2(后端 set_concurrency 补 clamp 边界对齐前端 / onUnmounted 清 debounce timer)
|
||||
- **AI Node 单测**:`ai_node.rs` 重构抽 `parse_params` 纯函数(execute 参数解析剥离,单测不 mock LLM 零网络)+ 7 单测(缺参×3 / prompt 上游优先回退 / config 取值 / 可选默认值 / 显式参数解析)
|
||||
- **任务收尾**:#43/#44 → completed(代码 + 单测全绿,GUI 实测转跟踪);新增 #54(GUI 动态实测跟踪任务)
|
||||
|
||||
**代码变更**:
|
||||
- `crates/df-ai/src/context.rs`(Review ⑤⑥ — 合并 to_chat_messages / system prompt 超 budget warn)
|
||||
- `src-tauri/src/state.rs`(LlmConcurrency 双层 Semaphore + per_conv 单例语义注释)
|
||||
- `src-tauri/src/commands/ai.rs`(permit×3 + drop / process_tool_calls join_all 并行 / save 后台 spawn / ai_set_concurrency_config command + clamp)
|
||||
- `src-tauri/src/lib.rs`(注册 ai_set_concurrency_config)
|
||||
- `src/views/Settings.vue`(并发 UI + clamp + debounce + onUnmounted)
|
||||
- `crates/df-nodes/src/ai_node.rs`(parse_params 重构 + 7 单测)
|
||||
- `docs/02-架构设计/功能决策记录.md`(新增「AI Node」域记 parse_params 决策 + per_conv 退化条目)
|
||||
|
||||
**验证**:cargo check 0 error ✓ / df-ai context 5 单测 ✓ / df-nodes ai_node 7 单测 ✓ / vue-tsc 0 error ✓。**未 tauri dev 运行时实测**(转 #54 跟踪)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **#54 GUI 动态实测**(用户跑 tauri dev,Claude 核对日志):长对话裁剪(context_trimmed 日志 + DB 全量)/ 多工具并行(Low join_all 时序)/ save 异步不阻塞 Completed / 双层 Semaphore 限流(多对话撞 RPM)/ Settings 并发即时生效 + 刷新保持 / DAG ai 节点真调 LLM
|
||||
2. ~~AI Node 可补 GLM 集成测试~~ ✅ 已补并通过:`glm_live_complete`(`#[ignore]` + env var)真调 GLM glm-4-flash 返回"通过",usage 解析正确(provider 调用层验证;GUI DAG 触发仍属 #54)
|
||||
|
||||
### [Sprint 17] 2026-06-13 — 模块文档↔代码一致性审查(4 文档纠错补全)
|
||||
|
||||
**工作内容**:
|
||||
- **审查范围**:docs/03 模块文档(df-ai / df-storage / df-workflow)+ docs/01 技术文档(SQLite-CRUD 模式)共 4 份 vs 对应 crate 代码,三代理并行审查
|
||||
- **🔴 硬伤 28 全修**:df-ai(LlmProvider trait 签名 / 删 OpenAI 虚构 idle-timeout + finished_received / ContextManager 方法名 replace_tool_result_content + build_for_request 返回元组 / Token 公式 / Anthropic usage take() / 工具归属)/ df-storage(V1 表名 / Repo 表名 releases·workflow_executions / search published 过滤 / increment_reuse_count updated_at / cosine epsilon / blob 截断)/ SQLite-CRUD 整篇过时(表 6→11 / CRUD 待→已 / 时间 INTEGER→TEXT / 写返回 ()→String·bool / 连接池→单连接 / schema.rs→migrations.rs)/ df-workflow(Node trait 按值 + 默认 false + node_type / 文件名 conditions·eventbus / 虚构事件区分已发未发)
|
||||
- **⚪ 虚构 7 全清**:df-workflow(阻塞节点「等待人工操作」标未实现 / 持久化快照删改仅内存态 / 断点续跑补无暂停恢复机制)/ df-ai(conditions.rs 不存在,清状态表 + 决策缺口表虚构引用 / AiToolRegistry「12 工具」夸大改基础设施)
|
||||
- **🟡 核心 8 + 碎琐 10 补全**:df-ai(apply_openai_sse / apply_anthropic_event 纯函数 / ContextManager 6 pub 方法 / Anthropic 事件全集 / 文件结构 router·stream / URL 三分支 / finish=length / max_tokens 4096 / PROTECT_COUNT / budget 公式)/ df-workflow(NodeRegistry / DagDef / StateMachine / Dag API / NodeContext·NodeOutput 字段 / EventBus API / 条件引擎默认放行 warn)/ df-storage(list_non_archived / set_archived 例外对照)
|
||||
|
||||
**代码变更**:无(纯文档纠错,代码未动)
|
||||
|
||||
**验证**:每项修正均经代理 Read 代码复核签名/SQL/行为后改;git diff 4 文档落地(df-ai 443 / df-storage 134 / SQLite-CRUD 56 / df-workflow 122 行变动)
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. 4 文档已与代码实质一致;后续若改 df-ai/df-storage/df-workflow 公开接口,需同步对应模块文档(文档治理:模块文档随代码变)
|
||||
2. df-evolve(knowledge.rs / pattern.rs)本轮未审 — docs/03 无对应模块文档,若补可另起审查
|
||||
|
||||
### [Sprint 18] 2026-06-13 — 知识生命线(详情页 + 溯源 + 引用 + 生命周期)
|
||||
|
||||
**工作内容**:
|
||||
- **根因**:知识库 candidate 卡片点不开详情、不能编辑、看不到"为什么产生";reasoning 字段被 prompt 要求但写库丢弃(bug);increment_reuse_count 只 +1 不记去向;无状态变更历史。用户要求呈现知识完整生命周期:产生→审核→引用→归档,且约束异步不阻塞、代码简洁易扩展。
|
||||
|
||||
**A. 存储层(df-storage,V10 迁移)**:
|
||||
- V10:幂等补 `knowledges.reasoning TEXT` 列 + 新建 `knowledge_events` 审计表(id/knowledge_id/event_type/source_ref/context_json/timestamp + 3 索引)
|
||||
- `KnowledgeRecord` 加 `reasoning: Option<String>`;新增 `KnowledgeEventRecord`(6 字段)
|
||||
- `KnowledgeEventsRepo`(impl_repo! 宏 7 方法 + 扩展 `list_by_knowledge`/`list_by_knowledge_type`);5 处显式 SELECT 列列举 + from_row + insert/update body 全线接 reasoning
|
||||
|
||||
**B. 后端埋点(src-tauri)**:
|
||||
- **新建 `commands/knowledge_timeline.rs`** — `KnowledgeTimeline` 独立记录器(持 Arc<Database>),便捷方法 record_extracted/referenced/status_change/created,内部 `fire()` 吞错误 fire-and-forget。各业务流程只调一行,不内联写 SQL(代码简洁约束)
|
||||
- `extract_knowledge_from_conversation`:回填 reasoning(修 bug)+ 查对话标题 + insert 后记 extracted 事件
|
||||
- `build_knowledge_context`:加 conv_id 参数 + spawn 块内 increment_reuse_count 后记 referenced 事件(conv_id+query);调用点上移 conv_id 快照
|
||||
- `knowledge_create/update_status`:记 created/status_changed 事件(归档复用 status_changed,删 dead code EVENT_ARCHIVED/record_archived)
|
||||
- 3 新 IPC command:`knowledge_get_detail`(聚合 knowledge+events)/`knowledge_update`(部分更新编辑)/`knowledge_events`(查生命线);AppState 加 `knowledge_events` Repo;lib.rs 注册
|
||||
|
||||
**C. 前端**:
|
||||
- types.ts:KnowledgeRecord 加 reasoning + 新增 KnowledgeEventRecord/KnowledgeDetailPayload/UpdateKnowledgeInput
|
||||
- api/store:各加 getDetail/update/getEvents
|
||||
- **Knowledge.vue 全面重构**:Ideas 式左右分栏(左卡片列表 @click 选中 / 右详情四分区 — ①基本信息+编辑 ②溯源(产生方式/来源对话/AI reasoning) ③引用记录(复用次数+最近 N 条) ④生命周期时间线)。可编辑 title/content/tags/confidence
|
||||
|
||||
**关键决策**(详见 docs/02-架构设计/功能决策记录.md):① 独立 events 表非 JSON 嵌主表(引用数百次防行膨胀);② 独立 KnowledgeTimeline 记录器(不散落埋点);③ 归档复用 status_changed(防冗余);④ reasoning 回填(修 prompt 要求但丢弃的 bug)
|
||||
|
||||
**代码变更**:df-storage(migrations/models/crud)+ src-tauri(state/knowledge_timeline 新建/ai/knowledge/lib/mod)+ 前端(types/api/knowledge.ts/stores/knowledge.ts/Knowledge.vue)= 12 文件
|
||||
|
||||
**验证**:cargo check --workspace ✓(仅既有警告)/ vue-tsc --noEmit ✓ 零错误 / cargo test -p df-storage ✓ 21 单测全过。**未 tauri dev 运行时实测**
|
||||
|
||||
**遗留问题 / 下一步**:
|
||||
1. **#54 GUI 实测**:AI 对话结束→提炼→收件箱→点详情看 reasoning+来源;发布→生命线 status_changed;新对话注入→referenced 事件;编辑 candidate→保存
|
||||
2. 老库历史 candidate 无 reasoning/events,详情页降级展示(手动录入/无溯源)——属预期兼容
|
||||
3. knowledge_events 纯追加表,长期可清理 >90 天 referenced 事件(本次不做)
|
||||
|
||||
### [Sprint 19] 2026-06-14 — list_directory 防爆 + localStorage→SQLite 统一持久化 + token/发送 bug 修复
|
||||
|
||||
**工作内容**:用户报多个 AiChat bug + 提需求:list_directory 扫出 13782 项塞爆对话;token 显示"0 in";发送卡住/留框;LLM 并发上限太小;localStorage 散落要统一入 SQLite。同时用户并行主导 ai.rs→ai/ + ai.ts→composables 模块拆分(我观察配合,不接管)。
|
||||
|
||||
**A. list_directory 递归防爆(Part A)**:
|
||||
- `list_dir_recursive`(ai/tool_registry.rs)加噪音目录剪枝(`.git`/`node_modules`/`target`/`dist`/`build`/`.next`/`.cache`/`__pycache__`/`.venv`/`venv`/`.idea`)——**列出但不深入内部**;硬上限 1000 条 + `truncated` 标志;默认 max_depth 3→2;加 `skip_noise_dirs` 参数(默认 true,AI 看编译产物 dist/target 时传 false)
|
||||
- 根因:AI 广扫项目根传 `recursive:true`,噪音目录铺平致 13782 项塞进对话 message(UI 卡 + token 爆)
|
||||
|
||||
**B. localStorage → SQLite 统一持久化(Part B)**:
|
||||
- 新建 `app_settings` KV 表(V13 迁移)+ 手写 `SettingsRepo`(get/set/get_all/delete,不走 `impl_repo!` 宏因 KV 无固定 schema);4 IPC(settings_get/set/get_all/delete);AppState 加 settings 字段
|
||||
- 前端 `appSettings` store(reactive 缓存 + 300ms debounce + `useSetting` ref)+ api 封装
|
||||
- **11 key 迁移**:敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency)+ `df-ai-active-conv`;**例外 `df-ai-gen`/`df-ai-text`**(流式临时快照,高频写留 localStorage)。App.vue onMounted 一次性迁移旧 localStorage
|
||||
|
||||
**C. Bug 修复**:
|
||||
- **token prompt=0**:GLM 流式 usage 带 completion 不带 prompt → 加估算兜底(provider 返回 0 时按请求消息 `TokenEstimator::estimate_message` 求和)
|
||||
- **发送卡住/留框**:`sendMessage` IPC 失败时无 catch → streaming 永真(光标卡死)+ handleSend 回填输入(消息已显示);改 try/catch 回滚 streaming + 清空气泡占位
|
||||
- **LLM 并发上限**:三层(模板 max / JS Math.min / 后端 .clamp)全去上限,保留 min=1 + `per-conv ≤ global` 约束
|
||||
|
||||
**D. token 暴增诊断 + 清理**:
|
||||
- 查 SQLite 实数据:对话 e4365d47 累积 in=115万 / out=2万 / 消息体 1.6MB
|
||||
- 根因:**单条 list_directory 工具结果 1.2MB**(13782 项,Part A 修复前产生)落库后每轮重发 + save 累加 → 暴增;token 估算准,膨胀是真(非估算锅)
|
||||
- 删除污染对话(`ai_conversations` 1 行 + `ai_tool_executions` 123 行)
|
||||
|
||||
**E. 模块拆分(用户主导,本会话发生,我配合不接管)**:
|
||||
- `ai.rs`(2651 行)→ `ai/` 11 子模块(agentic/audit/commands/conversation/knowledge_inject/mod/prompt/skills/stream_recv/title/tool_registry),旧 ai.rs 删
|
||||
- `stores/ai.ts`(749 行)→ 薄壳 + `composables/ai/*`(events/stream/send/conversations/window/panel)
|
||||
- `df-evolve` crate 移除(workspace/代码零引用)
|
||||
- 踩坑(glob 重导出命令宏符号 / super 路径失效 / 0 字节残留触发 E0761)已记入经验记录.md
|
||||
|
||||
**关键决策**(详见功能决策记录 / 经验记录):① list_directory 剪枝 + skip_noise 保留访问能力(定点查/强制递归两路通);② KV 表统一持久化复用 SettingsRepo(兑现经验记录预言);③ token 估算兜底(provider 不报时按消息估);④ 发送失败回滚 streaming;⑤ 并发去上限保 per-conv≤global
|
||||
|
||||
**代码变更**:src-tauri(`commands/ai/` 新模块 + `commands/settings.rs` + state.rs + lib.rs)+ crates/df-storage(migrations V13 + crud SettingsRepo)+ 前端(api/settings.ts + stores/appSettings.ts + App.vue/Settings.vue/composables/ai/* + i18n)≈ 25 文件
|
||||
|
||||
**验证**:cargo check ✓ / vue-tsc ✓ 零错 / tauri dev 启动 ✓ 运行;token/发送/并发/list_directory 已实测
|
||||
|
||||
**遗留 / 下一步**:
|
||||
1. **Fix 1(待用户最终定)**:工具结果入库前截断 50KB(堵 `read_file` 1MB 洞 + 防御);Fix 2(放宽旧工具结果裁剪)因 Part A 已杀主源,延后不做
|
||||
2. Settings.vue(1037 行)拆 panel 子组件——早前建议,用户已做 ai.rs,此件待评估
|
||||
3. 诊断日志清理(handleEvent/approveToolCall 早期调试 console.log 残留)
|
||||
|
||||
### [Sprint 20] 2026-06-14 — 项目管理模块代码审查 + 全局核对(3-agent review 验证)
|
||||
|
||||
**工作内容**: 应用户要求审查「项目管理 / 项目导入 / 生命周期可用性」(后端 commands/project.rs + ai/tool_registry.rs + df-project/scan.rs + 前端 Projects.vue / ProjectDetail.vue)。先 3-agent 并行 review 出 15 项发现,剔幻觉 2 项(read_readme 命名误判 / conflict.name null 过度防御)后剩 13 项;再应要求**全局核对**(grep 全仓库 6 维度)确认每项发现是孤例还是系统性,保证全局成立有效。**本次仅审查 + 核对,未改代码。**
|
||||
|
||||
**验证结论**: 13/13 发现**事实全部成立**,无幻觉。4 处行号偏差(① 249→263 / ⑤ 113→136 / ⑫ 130→145 / ③ 未标→271);2 处范围需补(⑧ 实 8 处非 3 处 / ③ 漏 relocate:212)。
|
||||
|
||||
**🔴 必须修复(3)**:
|
||||
1. **AI delete_project 硬删绕过软删**(tool_registry.rs:263 `repo.delete` 物理 DELETE vs IPC project.rs:120 `soft_delete` 进回收站)—— 同一操作两种后果,AI 对话删 = 数据丢失不可恢复,UI 删 = 可恢复。**数据安全缺口**
|
||||
2. **AI 缺回收站生命周期工具**(restore/purge/list_deleted 全无)—— ProjectRepo 有完整方法(crud.rs:572/591/610/634)+ IPC 全注册(project.rs:125-145),AI 层零覆盖,割裂工作流
|
||||
3. **collect_sample 同步 IO 阻塞 async**(project.rs:271 在 async fn 内直接同步调 df-project::scan)—— 对比 scan_project_stack(:182)用了 spawn_blocking,不一致
|
||||
|
||||
**🟡 建议改进(6)**: ④ normalize_path 两份重复(project.rs:154 + tool_registry.rs:180)/ ⑤ update 白名单双份不同步(tool_registry:136 硬编码 vs crud.rs:287 allowed_columns,idea_id 字段不同步)/ ⑥ AI 扫描 + 目录状态文案硬编码未走 $t(Projects.vue:33/195 + ProjectDetail.vue:93)/ ⑦ parseStack 两份重复(Projects.vue:143 + ProjectDetail.vue:321)/ ⑧ 原生 alert/confirm(全局 8 处非仅 ProjectDetail 3 处,Ideas.vue:378/391 同病)/ ⑨ collect_sample 无文件大小预检(scan.rs:151/205 read 全文后截断)
|
||||
|
||||
**⚪ 可选(3)**: ⑩ LLM 无超时(project.rs:297 占 llm_concurrency permit 无限 await)/ ⑪ JSON 提取取首尾花括号(project.rs:368-369 多 JSON 场景错)/ ⑫ create_project 无 path 参数(tool_registry:145 需 create+bind 两步)
|
||||
|
||||
**全局核对关键定性**(6 维度 grep 全仓库):
|
||||
- **① 是 project 特例但最该修**: soft_delete/回收站机制**全仓库仅 project 一张表有**(task/idea/branch/release/workflow 本就物理删,无语义不一致);正因 project 唯一设计回收站,AI 硬删直接击穿其数据安全设计
|
||||
- **⑧ 系统性**: 全仓库 8 处原生弹窗(ProjectDetail 4 + Projects 2 + Ideas 2),非局部
|
||||
- **③ 范围补报**: 不只 :271,relocate_project_path:212 同步 detect_stack 同样阻塞,3 处未包 spawn_blocking vs 1 处包了
|
||||
- **src-tauri 层自身干净**: `std::fs` 同步调用零匹配,阻塞全在跨 crate 调同步的 df-project::scan
|
||||
- **④⑤⑦ 精准**: 全仓库确为两份/一处,无更多副本;validate_path/resolve_workspace_path 是安全校验,职责不同非 normalize 重复
|
||||
|
||||
**未改代码**(待用户定推进批次)
|
||||
|
||||
**遗留 / 下一步(修复批次建议)**:
|
||||
- **批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)
|
||||
- **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal)
|
||||
- ⚪ 可选: ⑨⑩⑪⑫ 按需
|
||||
|
||||
---
|
||||
|
||||
### [设计] 2026-06-14 — B-03 人工审批响应机制设计
|
||||
|
||||
**工作内容**:df-workflow HumanNode 审批响应机制设计(B-260614-03)。勘察确认链路基础设施 90% 已通(EventBus broadcast / `HumanApprovalResponse` 事件 / `approve_human_approval` IPC / 前端 store 已接通),唯一缺口为 `human_node.rs:55` 发完请求直接 return "同意"。设计:订阅时序铁律(`subscribe()` 先于 `send(Request)`)+ `tokio::select!` 循环(响应 / 超时 / 取消)+ execution_id+node_id 双键过滤 + Lagged 容忍。澄清 B-06/B-07 前置真实影响(B-06=并发隔离,非单流功能前置;B-07=取消必要非充分),拆 **B-03a**(响应等待 + 超时)/ **B-03b**(取消机制:`set_cancelled` + cancel IPC + 前端按钮)。
|
||||
|
||||
**产出**:完整设计文档 [B-03-人工审批响应机制.md](./docs/02-架构设计/B-03-人工审批响应机制.md);功能决策记录加「工作流人工审批节点(B-03)」摘要章节。
|
||||
|
||||
**下一步**:B-06(execution_id 下沉)/ B-07(共享 StateMachine)前置修复 → 落 B-03a 实现 → B-03b 取消机制补全。
|
||||
|
||||
### [Sprint 21] 2026-06-14 — Workflow 编排推进 Sprint 20 批次 + 审批闭环实施
|
||||
|
||||
**工作内容**: 以 workflow(实施 + 对抗验证两阶段,文件域隔离 + prompt 钉死约束)并行推进 Sprint 20 审查发现 + df-workflow 审批闭环。先 commit 基线 cf017f8(Phase2 阶段收尾 167 文件,排除无关的 `src-tauri/儿童每日打卡应用/`),后 A/B/C/D 四 workflow 各独立 commit 隔离可审。失控教训内化:workflow 确定控制流替代 background agent stop hook 循环,全程禁碰决策记录 + 禁自主续推。
|
||||
|
||||
**A 数据安全**(commit 3f0839a): AI `delete_project` 由 `repo.delete` 物理删改 `soft_delete` + 新增 `restore_project`/`purge_project`/`list_trash` 三工具(复用 ProjectRepo 回收站方法)+ `list_projects` 改 `list_active` 排除回收站(验证捕的漏网:LLM 原能看到已软删项目)。tool_registry.rs。
|
||||
**B 阻塞去重**(commit d5a6417): `collect_sample`/`detect_stack` 四处 `spawn_blocking`(含验证捕的 bind_directory 漏网)+ `normalize_path` 抽公共到 `df-project/scan` + tool_registry `update_project` 复用 crud `is_allowed_column`(删硬编码白名单,与 CRUD 同源)。scan.rs/crud.rs/project.rs/tool_registry.rs。
|
||||
**C 前端**(commit 02ff88f): 扫描/目录状态文案走 `$t`(zh-CN+en)+ `parseStack` 抽公共 `src/utils/project.ts` + 8 处原生 `alert`/`confirm` 全换(confirm→ConfirmDialog / alert→Arco Message),控制流语义不变。vue-tsc 0 error。8 文件。
|
||||
**D 审批闭环**(commit 22964a2): B-06 DagExecutor 接收 `execution_id` 下沉 NodeContext(消除 dummy)+ B-07 `node_status` 共享 `self.state_machine.clone()` + B-03a HumanNode `execute` 重写 `subscribe→send→select!`(响应/超时/取消 + execution_id+node_id 双键过滤 + Lagged 容忍,照 B-03 设计)+ eventbus 删 `try_recv_human_approval` 死代码 + human_node 7 单测。executor.rs/workflow.rs/human_node.rs/eventbus.rs。
|
||||
|
||||
**验证**: cargo check 0 error / df-storage 32 + df-project 4 + df-workflow 11 + df-nodes 14 test 全过 / vue-tsc 0 error。A/B 各 1 issue(list_projects 泄漏 / bind_directory 漏网)主代理补修;C/D 验证 safe 0 issue。
|
||||
|
||||
**遗留 / 下一步**: B-03b 取消机制(`set_cancelled` + cancel IPC + 前端按钮)待做;T-09/T-10 非阻断记 todo;架构前置 df-ai-core(F-07)待设计解锁 F-03。
|
||||
|
||||
### [Sprint 22] 2026-06-14 — Wave1 收尾清债(workflow 推进 + 主代理补完 agent 半成品)
|
||||
|
||||
**工作内容**: Wave1 四任务并行 pipeline(实现 + Explore 对抗审查,文件域隔离),收尾 Sprint 21 遗留的 B-03b 审批取消机制 + 三个 P2 清债项。每个 Agent 钉死约束(只碰白名单 + 禁碰决策记录/构想文档 + 禁自主续推 + cargo check 自验)。
|
||||
|
||||
**B-03b 审批取消端到端**(commit 4aa689e): agent 初版完成三件基础设施(`set_cancelled` + cancel IPC + 前端取消按钮)但留**半成品**——`workflow_cancel_state` 全局孤立 StateMachine 实例,IPC `set_cancelled` 写入后无法达运行中 HumanNode 的 `ctx.node_status`(executor.state_machine 的 clone,B-07 仅 clone 传递未共享底层 HashMap)。**Explore 审查 agent(无 Edit 权限,只验编译 + 白名单)漏抓此语义缺陷**。主代理补完:StateMachine 内部 `HashMap`→`Arc<Mutex<HashMap>>`(clone 即共享底层)+ AppState `workflow_state_registry`(execution_id→StateMachine)+ run_workflow 注册执行器状态机/完成清理 + cancel IPC 经 execution_id 取共享引用 `set_cancelled` 直达 HumanNode `is_cancelled` 轮询。`clone_shares` 单测验证共享语义。state.rs/executor.rs/state.rs(tauri)/workflow.rs/lib.rs/project.ts/ProjectDetail.vue。
|
||||
|
||||
**T-09 级联删**(commit 89da9fa): idea.rs:149 `delete`→`purge_with_descendants`(防孤儿子记录),1 行签名兼容。
|
||||
**T-10 canonicalize**(commit 89da9fa): 判定已解决——`normalize_path` 已含 canonicalize 优先 + 词法回退,`find_binding_conflict` 两端对称已用,零改动。
|
||||
**T-12 df-ideas 死代码**(commit 89da9fa): capture.rs 删 `CaptureInput`/`IdeaCapture` 死码,保留 `Idea`/`IdeaScores` 共享实体;promotion/scoring/adversarial 内"两套 Recommendation/PromotionPolicy 死枚举"嫌疑未确认存在/保留为对外契约,本次未动待复查。
|
||||
|
||||
**验证**: cargo check 0 error / cargo test 全过(0 failed,含 `clone_shares` 新测)/ vue-tsc 0 error。
|
||||
|
||||
**教训**: workflow 对抗审查用 Explore agent 能防越权 + 验编译 + 验白名单,但抓不到"功能实际不工作"类语义缺陷(IPC 写了读不到、共享未通)——这类需主代理读实现逻辑复核,不能只信审查 verdict。
|
||||
|
||||
**遗留 / 下一步**: Wave2 候选 F-07(df-ai-core trait 下沉,解锁 F-03,需先定 trait 边界);T-12 df-ideas 其余嫌疑项待复查。
|
||||
|
||||
### [Sprint 23] 2026-06-15 — 全量待办编排 + 首批 A+B 清债热身
|
||||
|
||||
**工作内容**: 全量 `todo.md`(~40 未完成项)汇总按可执行性分 8 组(A 卫生 / B 零风险减法 / C 需用户输入 / D 功能 / E 架构 / F 全局 review P2 / G 测试 / H 长期池),加「编排推进总览」章节供后续会话全局视图。首批推进 A+B 组。
|
||||
|
||||
**ARC-08 转自研块级 memo**(前会话遗留补记,待 commit): 流式 Markdown 渲染——方案 D(markstream-vue)试装后样式还原成本高且脆(代码块 chrome / 暗色 `--ms-*` 变量 / prose 作用域 4 处对接随库漂移),转自研块级 memo(方案 B 增强版):保留 marked+DOMPurify+.ai-md 原样式零对接 + splitBlocks 块级 memo O(末块) + rAF 节流 + 末块不缓存处理未闭合 token。退役 AR-1 流式纯文本短路。详见 [aichat流式Markdown渲染调研-2026-06-15.md](docs/02-架构设计/构想审查/aichat流式Markdown渲染调研-2026-06-15.md) §5。
|
||||
|
||||
**A todo 卫生**:
|
||||
- AR-1 标退役(被 ARC-08 取代)
|
||||
- AR-8 重评:前端 rAF 已被 ARC-08 覆盖,剩后端 50ms 合批 + 滚动,降优先级
|
||||
- ARC-03 降级重评:df-execute **非空壳**(shell.rs `execute()` 完整 + 被 `run_command` 复用),原「空壳合并」前提失效,转架构维护决策非清债
|
||||
|
||||
**B 清死链**(ARC-260615-02): 删 App.vue secondaryNav `/decisions` 死链项(router 无此路由,决策治理 = F-260614-08 长期项入口提前占位)+ 删 `nav.decisions` i18n key(zh/en)。`dashboard.recentDecisions`(Dashboard.vue)不同命名空间保留。
|
||||
|
||||
**代码变更**: `src/App.vue` / `src/i18n/{zh-CN,en}/nav.ts` / `docs/todo.md`(编排总览 + AR-1/AR-8/ARC-02/ARC-03 状态回写)。
|
||||
|
||||
**验证**: vue-tsc EXIT=0。
|
||||
|
||||
**B-03b-R8 human 端到端集成测试**(待 commit): human_node.rs 加 2 端到端测(`end_to_end_human_approval_completes_workflow` / `end_to_end_human_approval_cancelled`),覆盖 executor 驱动 a(SleepNode)→b(HumanNode) 两层 DAG 的 Request 真发出 + outputs 收集 + 双节点 Completed,以及外部 set_cancelled → human cancel_tick → executor 跳过 set_failed + 状态保持 Cancelled。封死 R6/R7 回归土壤。10 测全过(8 现有单测 + 2 新端到端)。
|
||||
|
||||
**遗留 / 下一步**: 下批方向三选一(D 功能增强 F-15-01~04 / E 架构重投入 F-14-01·07 / G 测试 dev 验证 ARC-08 + T-14-01·02);C 组阻塞项(S-260615-01 curl 测 / S-260614-01 多开澄清)等用户输入。
|
||||
|
||||
### [Sprint 24] 2026-06-15 — P0 generating 状态机加固 + 独立项批量
|
||||
|
||||
**工作内容**: 多代理编排推进——P0 generating 卡 true 根治组合(B-09/10/11)串行 + 5 独立项 workflow 并行 + 3 项 read-only 勘察下一波。主代理全程核查(失控防护)。
|
||||
|
||||
**P0 三项**(generating 卡 true 根治,agentic.rs + commands.rs):
|
||||
- B-09: GeneratingGuard RAII(new/reset().await 幂等/Drop spawn 兜底)+ run_agentic_loop 入口实例化 + 5 处手动复位替换 + try_continue:318 保留手动(should_continue=false 须保 generating=true 待审批)
|
||||
- B-10: ai_conversation_create 加 app:AppHandle(Tauri 自动注入)+ generating=true 软复位(记 old_conv→复位→清 pending→stop_flag→释锁→emit 零 token AiCompleted→relock 续建)+ 硬拦移除
|
||||
- B-11: 两处 conv_id 一致性校验(循环顶部 + push 块内,防软复位后旧 loop 污染新对话)
|
||||
|
||||
**独立 7 项**(B-19 根因指针随 09~18 销账):
|
||||
- B-12: SessionState enum 视图(Idle/Streaming/AwaitingApproval)+ session_state() 读侧收敛(mod.rs,三调用点注释待后续替换)
|
||||
- B-18: pending_approvals 单例设计文档注释(mod.rs)
|
||||
- R-PD-4: keyring 迁移失败阈值告警(MIGRATION_FAIL_THRESHOLD=3 + sidecar 计数 + 达阈值升级 warn,secret.rs,不改兼容时序)
|
||||
- R-PD-5: approve IPC 加 decision∈options 校验(⚠️半完成:前端 stores/project.ts:252 未传 options,校验形同虚设,待前端补传触发)
|
||||
- R-PD-13: run_workflow Lagged 结构化 warn + 风险注释(workflow.rs)
|
||||
- B-20: handleSend catch 加 reactive toast(AiChat.vue 复用 Settings 模式,文案待 i18n)
|
||||
- B-21: sendMessage catch 一并回滚 user message(useAiSend.ts)
|
||||
|
||||
**勘察下一波**(read-only workflow wxflofhf2): F-260615-02 TaskDetail(已完成核查通过:get_task_by_id IPC + /tasks/:id 路由 + TaskDetail.vue 11 字段 + 列表跳转,cargo check/vue-tsc 0 err)/ F-260615-04 ToolCard折叠(plan 11 步复杂+改核心 ai 文件+细节有误,拆小或留待)/ AR-11 数据联动(勘察 implPlan 含伪代码错误 std::env::var/.match,暂缓待重设计 emit 点)。
|
||||
|
||||
**代码变更**: agentic.rs / commands.rs / mod.rs / secret.rs / workflow.rs / AiChat.vue / useAiSend.ts。
|
||||
|
||||
**验证**: cargo check 0 error / vue-tsc 0 error / df-nodes 17 test pass(含 2 human 端到端测,P0 不破坏)。主代理独立核查(非 agent 自报):git diff --stat 文件边界 + 编译复核 + R-PD-4 落地核查(agent 报告夸大称改 75 行实际在更早基线,功能存在即销账)。
|
||||
|
||||
**失控防护**: 每 agent prompt 锁死文件 + 禁碰决策记录 + 禁自主扩展 + 返回 schema。R-PD-4 agent 把已有代码当自己的(边界检查不准,非造假,同类 WF-G AR-7)。
|
||||
|
||||
**遗留 / 下一步**:
|
||||
- R-PD-5 半完成:前端补传 options 触发新校验
|
||||
- B-13~17 待续(stop 兜底/Notify 即时打断/heartbeat 提外/MAX_AGENT_ITERATIONS 配置化/key_len 复用)
|
||||
- F-260615-02 TaskDetail 实施中(后台 agent)
|
||||
- AR-11 暂缓(等本批提交后重设计 emit 点,audit.rs app_handle 可达性待核查)/ F-260615-04 拆小或留待
|
||||
|
||||
### [Sprint 25] 2026-06-15 — P0 收尾 + 下一波编排
|
||||
|
||||
**B-15/16/17 P0 收尾**(零行为变优化):
|
||||
- B-15: stream_recv.rs heartbeat interval 提到 loop 外(省每轮重建,节拍不变)
|
||||
- B-16: agentic.rs MAX_AGENT_ITERATIONS pub(crate)→pub const + 配置接入点注释(无现成配置槽,未引入 AppState 字段)
|
||||
- B-17: agentic.rs key_len 复用 resolve_provider_secret(去重复 keyring resolve,build_provider 内联因 secret.rs 锁边界,DRY 隐患注释标注等价性)
|
||||
- 核查:cargo check 0 err / df-nodes 17 test pass
|
||||
|
||||
**下一波编排 workflow wcvigw3z4**(3 实施 + 4 勘察,循环链路):
|
||||
- 实施(文件隔离):B-13 stop 兜底(commands.rs)/ R-PD-5 前端补 options 闭环(stores/project.ts)/ CR-05+07 块级 memo 低风险子项 parseBlock DRY + escapeHtml 抽(AiChat.vue,禁碰 splitBlocks/loadMarkdown 流式核心)
|
||||
- 勘察 read-only(为下一波确定):B-260615-22 状态同步两方案对比 / B-14 stop_flag→Notify 即时打断跨4文件设计 / F-260615-01 HumanNode 多选审批契约变更影响 / ARC-06 composables/ai 循环依赖拆点
|
||||
|
||||
**遗留 / 下一步**: 等勘察结果派下一波实施;AR-11 emit 点已查明(audit.rs process_tool_calls 有 app_handle)待本批提交后派;F-260615-03 breaking change / F-260615-04 需定交互 待用户确认;改动堆积(git diff 100+ 文件)建议择机提交收口。
|
||||
|
||||
### 2026-06-18 03:08 — sweep-fix 6 域走查 + P3 小修(Code-Working 会话·workflow wd2fnjh3s)
|
||||
|
||||
**工作内容**:
|
||||
- workflow wd2fnjh3s 6 域并行走查(rust-ai/dag/storage/ipc + fe-aichat/fe-other,12.4 分钟·6 agent·651k tokens):修 12 处确定性低风险 bug + 新发现 27 todo(去重 24 入 todo.md)
|
||||
- 主代独立核查(审查防污染不信 agent 自报):cargo check --workspace EXIT 0 + vue-tsc EXIT 0 + grep 抽查 scan/audit/commands 源码形态全正确;**拦截 1 瞬态误报**(fe-other vue-tsc 抓 ToolCard.vue:653 cmdOutput TS6133 never read,实为 fe-aichat 改 ToolCard 中间态被并发 vue-tsc 抓到,最终态模板已引用,全量 vue-tsc 复核 EXIT 0 确认非 bug)
|
||||
- 主代 P3 小修 5 处(各步自测通过):SW-18 AiChat wasNearBottom 切会话重置 / SW-21 TaskDetail wfResult timer 清理 / SW-23 Ideas neutral CSS / SW-15 ai_node schema required 对齐 / SW-24 dead_code 标注(finish_reason/call_type 2 处 #[allow],cargo df-ai warning 消除)
|
||||
- 文档:todo.md 24 newTodos 去重追加 + F-260616-15 矛盾项销账 + 5 P3/SW 销账;待审查.md CR-260618-01 登记 17 处改动交其他会话审查
|
||||
|
||||
**代码变更**:
|
||||
- df-ai: context.rs(parts token 预算,F-05 回归)/ anthropic_compat.rs(Image move)/ openai_compat.rs(2 处 #[allow(dead_code)])
|
||||
- df-project: scan.rs(to_ascii_lowercase 索引对齐 + is_monorepo null 判定)
|
||||
- df-ideas: adversarial.rs(MockProvider 补 reasoning_content)
|
||||
- src-tauri: audit.rs(audit_finalize 吞错分流 + PENDING_APPROVAL_PLACEHOLDER 常量)/ commands.rs(ai_conversation_delete retain)
|
||||
- df-nodes: ai_node.rs(schema required=[] 对齐 execute)
|
||||
- 前端: ToolCard.vue(isFailed/cmdOutput computed)/ AiChat.vue(虚拟滚动死注释清理 + wasNearBottom 重置)/ Projects.vue(_toastTimer onUnmounted)/ Knowledge.vue(parseContext 单次)/ TaskDetail.vue(wfResult timer 清理)/ Ideas.vue(neutral CSS)
|
||||
|
||||
**遗留问题**:
|
||||
- 1. **UX-2025-12 分页懒加载(高价值中风险)**:多角度论证暂缓——①必须 limit+滚顶两全(limit 单独引入"看不到 >limit 旧消息"回归,之前全量可见)/ ②token 紧张 180 行 7 文件大工程易出错(borrow/sentinel 时序) / ③长对话卡顿是渲染体验问题 cargo/vue-tsc 只验编译类型,滚动流畅度需用户实测 / ④工作区已累积 17 处 commit-policy-deferred 不提交再加 7 文件增合并冲突。留 todo 待充分资源/用户在场推进。
|
||||
- 2. SW-260618-01/02 两个 P1(executor Ok 路径取消事件不一致 / 审批占位 tool_result 5 处 .clear() 未终态化):均行为变更需确认设计意图,记 todo 未改。
|
||||
- 3. sweep P2 多为重构/防御/改进(AuditLog i18n / ai_node DRY / reqwest Client DRY / 双 watch 合并 / Anthropic url 防御),记 todo 待攒批。
|
||||
- 4. 待审查 CR-260618-01(17 处)待审查会话独立 grep 核验。
|
||||
|
||||
**下一步**:
|
||||
- 1. UX-2025-12 分页(用户在场体验验证 或 新会话充分资源推进)
|
||||
- 2. SW-01/02 P1 确认设计意图后修
|
||||
- 3. 待审查 CR-260618-01 交审查会话
|
||||
- 4. 工作区累积 17 处(commit-policy-deferred 不提交,等架构级重构建基线)
|
||||
|
||||
### 2026-06-18 03:26 — SW-10 reqwest Client 构建 DRY(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- sweep P2 SW-260618-10:openai_compat/anthropic_compat Provider::new reqwest Client 构建(connect_timeout 30s + 回退 Client::new + warn)7 行逐字重复,抽 `lib.rs::build_provider_client()` pub(crate) fn 收敛 DRY,两处调 `crate::build_provider_client()`
|
||||
- 主代自测(审查防污染独立核验):cargo check -p df-ai EXIT 0 无 warning / cargo test -p df-ai 112 passed 0 failed(不回归)+ grep 核验 Client/warn/Duration import 替换后仍多处用(Client 用于 struct 字段 / warn openai:503 + anthropic 7 处 / Duration 用于 :484/:481 timeout),无 unused warning
|
||||
|
||||
**代码变更**:
|
||||
- crates/df-ai/src/lib.rs(+14 build_provider_client pub(crate) fn)/ openai_compat.rs(-8+2)/ anthropic_compat.rs(-6+2)
|
||||
|
||||
**遗留问题**:
|
||||
- 1. sweep 剩余低风险项 ROI 递减:SW-06/07 双 watch 合并需谨慎 immediate 语义边界 / SW-19 combineAndTruncateLines DRY 价值低(结构特殊带 total/truncated)/ SW-04 knowledge_events query 无活跃调用方(防御性)/ SW-09 ai_node DRY 架构级 / SW-16 审计 status 透传需查审计表中复杂度(find_cached 返回 Option<String> 仅 content,来源 status 在 ai_tool_executions 表非 ChatMessage)暂缓
|
||||
- 2. 待审查 CR-260618-03(SW-10 单项)+ CR-260618-01/02 交审查会话独立 grep 核验
|
||||
|
||||
**下一步**:
|
||||
- 1. SW-01/02 P1 待确认设计意图(executor Ok 路径取消事件不一致 / 审批占位 5 处 .clear() 未终态化)
|
||||
- 2. 工作区累积 18 处(commit-policy-deferred 不提交,等架构级重构建基线)
|
||||
- 3. 待用户回归决策 UX-2025-12 分页 / SW P1 设计意图
|
||||
|
||||
### 2026-06-18 03:39 — SW-16 审计 status 透传 + SW-06 ToolCard watch 合并(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-16(P3 审计精度):find_cached_high_risk_result 加 audit_repo 参数 + async + 返回 (content,status),命中旧 tool_result 后查 AiToolExecutionRepo::find_by_tool_call_id(**已存在** crud.rs:1256,复用零新增)拿缓存来源真实 status(completed/rejected/failed)透传 audit_tool_call 替代固定 "completed"。审计缺失/查询失败 fallback completed 不阻塞去重。
|
||||
- SW-260618-06(P2 DRY):ToolCard 双 watch(props.tc.status) 合并。放原 watch2 位置(approving/approvingTimer/cmdOutputExpanded 三 ref 已定义避 TDZ)+ immediate(原 watch1 语义),callback 内 cmdOutputExpanded + approving 复位合并。
|
||||
- 主代自测:cargo check --workspace EXIT 0(仅 4 pre-existing dead_code 基线)/ cargo test -p devflow 92 passed 0 failed / vue-tsc EXIT 0 + grep 独立核验源码形态
|
||||
|
||||
**代码变更**:
|
||||
- src-tauri/src/commands/ai/audit.rs(find_cached 签名+返回+status 查询 / process_tool_calls :625 调用点 .await 拆 status / :639 &status 透传)
|
||||
- src/components/ToolCard.vue(删原 watch1 + watch2 替合并 watch)
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 剩余可自主:SW-07 AiChat 双 watch 合并(同 SW-06 模式可复用)/ SW-17 ToolCardList Set 重置(低价值内存慢增长)/ SW-19/20 DRY(低价值)/ SW-05/11/12/13/14(防御/确认意图/权衡,需勘察或确认)
|
||||
- 高价值待用户:B-260618-03 路由解耦 cost/intel(用户已决策主链大改需在场)/ SW-01·02 P1(行为变更需确认设计意图)/ UX-2025-12 分页(需体验实测)
|
||||
|
||||
**下一步**:
|
||||
- 继续 sweep 可自主项(SW-07 watch 合并模式复用 SW-06)/ 工作区累积 20 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测推进
|
||||
|
||||
### 2026-06-18 03:45 — SW-07 watch 合并 + B-03 路由解耦勘察 + SW-17 评估(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-07(P2 DRY):AiChat 双 watch(currentText) 合并。删原 :2040 watch(currentText→onContentChange) 并入 :2047 watch(currentText→scheduleStreamParse) 单 callback,保持原注册顺序(onContentChange 滚动先 → scheduleStreamParse rAF 分块后)。vue-tsc EXIT 0。
|
||||
- B-260618-03 路由解耦 cost/intel 勘察完成:9 调用点(project×2/compress/knowledge_inject×2/agentic/title/ai_node/adversarial)+ router 核心(TaskRequirements 字段 + 步骤4/5 filter + 步骤7 Reverse(cost_tier))+ 多 test。**自主裁决:记待决策作专项**(用户已全局决策去掉 cost/intel,但工程大 ~30 Edit + 主链路由行为变更需 cargo test router 全验证 + 在场核对 title/compress 选模型退化,本轮 token 不够完整推进,半途中断风险 > 暂缓)。附完整实施清单供专项续。
|
||||
- SW-260618-17 评估:**不做**。切会话清 4 Set 会破坏 UX-260616-03 记忆态设计(userExpandedCards/userCollapsedCards 跨会话保留是 feature——切回 A 恢复展开记忆),「内存慢增长」是记忆保留代价可忽略(tc.id 全局唯一无错配,描述自承认)。
|
||||
|
||||
**代码变更**:
|
||||
- src/components/AiChat.vue(删 :2040 watch + :2047 合并 callback)
|
||||
|
||||
**遗留问题**:
|
||||
- B-260618-03 待决策.md(专项推进,附实施清单)
|
||||
- SW-17 评估不做(破坏 UX-260616-03 记忆态)
|
||||
- sweep 剩余:SW-19(combineAndTruncateLines DRY 低价值可做)/ SW-20(parseTags DRY 签名变更中复杂度)/ SW-05/11/12/13/14(防御/确认/权衡)/ SW-08(AuditLog i18n 重构级)/ SW-09(ai_node DRY 架构级)
|
||||
|
||||
**下一步**:
|
||||
- 推进 SW-19(纯 DRY 低风险)/ 工作区累积 21 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:12 — SW-19/20 DRY + sweep 评估销账批次(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-19(P3 DRY):ToolCard combineOutputs 抽函数,combineAndTruncateLines + cmdOutput computed 复用。vue-tsc EXIT 0。
|
||||
- SW-260618-20(P3 DRY):Ideas 删本地 parseTags(IdeaRecord),import stores/knowledge.ts parseTags,3 调用点传 .tags。vue-tsc EXIT 0。
|
||||
- sweep 评估销账 6 项:SW-05(占位内容匹配·PENDING_APPROVAL_PLACEHOLDER 集中常量有效暂缓)/ SW-11(OpenAI 图片构造·当前不可达暂缓)/ SW-12(pinned/archived 不对称·设计合理确认)/ SW-13(find_path_conflict 规范化·文档+测试守护够暂缓)/ SW-14(keyring current_dir·权衡已承认不做)/ SW-22(Knowledge 模板重复求值·影响可忽略暂缓)。
|
||||
- SW-260618-03 评估暂缓:url 模式当前不可达(前端 Phase2b 只产 base64)+ anthropic_compat 已 warn;加 commands 层校验需追 parts 全链路(IPC→ContextManager→provider)定校验点有歧义 + 误拒合法 url 风险,待 url→base64 预拉落地后变可达再加。
|
||||
|
||||
**代码变更**:
|
||||
- src/components/ToolCard.vue(combineOutputs 抽函数 :463 + :472/:652 调用)
|
||||
- src/views/Ideas.vue(parseTags import :214 + 3 调用点传 .tags)
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 可自主低风险项已全扫净(SW-19/20 已修 + 6 项评估销账 + SW-03 评估暂缓)
|
||||
- 剩余候选:SW-08(AuditLog i18n 重构级 15+ key 可自主中工程)/ SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_node DRY 架构级)/ B-260618-03(路由解耦待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 勘察 SW-08 AuditLog i18n 可行性 / 工作区累积 23 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:16 — SW-08 AuditLog.vue i18n 化(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-08(P2 重构):AuditLog.vue 全硬编码中文 i18n 化。新建 auditLog 命名空间(en/zh-CN:title/refresh/desc/loading/empty + col×7 + pager×4 + risk×3 + status×6 + decided×2),AuditLog.vue 引入 useI18n + template 15 处文本 t() 化 + riskLabel/statusLabel/decidedLabel 改 t(`auditLog.xxx.${val}`)。class 映射保留不国际化。模式对齐 Dashboard/Knowledge/Ideas。vue-tsc EXIT 0。
|
||||
|
||||
**代码变更**:
|
||||
- src/i18n/en/auditLog.ts + src/i18n/zh-CN/auditLog.ts(新建命名空间)
|
||||
- src/views/AuditLog.vue(useI18n + template t() + label 函数 t())
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 可自主项已全扫净(SW-19/20 DRY + SW-08 i18n 重构均完成 + 7 项评估销账)
|
||||
- 剩余候选均非纯自主:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ SW-09(ai_node DRY 架构级)/ B-260618-03(路由解耦待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 勘察 SW-09 ai_node DRY 可行性 / 工作区累积 26 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:32 — SW-09 ai_node provider 三件套 DRY(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- SW-260618-09(P2 架构级 DRY):AiNode/AiSelfReviewNode execute provider 三件套逐字重复消除。抽 2 helper:resolve_and_parse(合并 resolve_provider+parse_params)+ provider_from_params(封装 build_provider 5 行)。AiNode 9 行→2 行;SelfReview parse+build→2 调用(中间 task 读保留,p 生命周期正确)。messages/temperature 差异不抽(各节点自有)。cargo check df-nodes EXIT 0 / cargo test 82 passed 0 failed。
|
||||
|
||||
**代码变更**:
|
||||
- crates/df-nodes/src/ai_node.rs(2 helper 新增 + 2 节点 execute 改)
|
||||
|
||||
**遗留问题**:
|
||||
- sweep 全域可自主项已尽(SW-19/20 DRY + SW-08 i18n 重构 + SW-09 架构级 DRY 均完成 + 7 项评估销账 SW-03/05/11/12/13/14/22)
|
||||
- 剩余候选均需用户介入:SW-01·02 P1(行为变更需确认设计意图)/ SW-04(knowledge_events query 无调用方暂缓)/ B-260618-03(路由解耦待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 可自主项已扫净,状态报告待用户 / 工作区累积 27 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 04:35 — UX-260618-04 前端删 cost/intel 标签(Code-Working 会话续推)
|
||||
|
||||
**工作内容**:
|
||||
- UX-260618-04(P2):前端删 cost/intel 瞎猜档位标签(落地用户「去掉 cost/intel」决策)。自主裁决全删(用户决策明确 + 独立于后端 B-03 路由 + grep 确认仅 2 组件引用 + 回退易)。ProviderPanel 删 tag-cost/tag-intel span;AiChat modelOptionLabel 删 intel/cost 拼接;i18n en/zh settings.ts 删 tagCost/tagIntel + 注释更新。vue-tsc EXIT 0 / grep 无残留。
|
||||
|
||||
**代码变更**:
|
||||
- src/components/settings/ProviderPanel.vue + src/components/AiChat.vue
|
||||
- src/i18n/en/settings.ts + src/i18n/zh-CN/settings.ts
|
||||
|
||||
**遗留问题**:
|
||||
- ModelConfig.cost_tier/.intelligence 字段保留(后端 B-260618-03 解耦后清)
|
||||
- sweep + UX 可自主项已尽,剩余均需用户:SW-01·02 P1(行为变更)/ SW-04(无调用方暂缓)/ F-260616-09(架构级立项)/ B-260618-03(待决策专项)
|
||||
|
||||
**下一步**:
|
||||
- 可自主项已扫净,状态报告待用户 / 工作区累积 31 处 commit-policy-deferred / 心跳 a096aa7f 20 分钟监测
|
||||
|
||||
### 2026-06-18 06:50 — workflow 6项编码批·router解耦+model_probe+executor TOCTOU+审批占位+knowledge_events+CostTier(workflow wexu1isx1·6 agent·主代核查)
|
||||
|
||||
**工作内容**:
|
||||
- 用户回归复盘彻夜低产(串行手改+过度保守+心跳空转 1.5h),改 workflow 编排提速
|
||||
- workflow wexu1isx1 6 agent(10.7 分钟·294k tokens):B-03 router 解耦 cost/intel / B-04 model_probe 去瞎填 / SW-01 executor TOCTOU / SW-02 审批占位终态化 / SW-04 knowledge_events query / B-05 CostTier Free 清理
|
||||
- 主代审查防污染核查:cargo check --workspace EXIT 0(5 pre-existing warning) + cargo test df-ai 109/df-ai-core 24/df-workflow 23/df-storage 全 passed + vue-tsc EXIT 0
|
||||
- **主代修 agent E0502 借用冲突**:SW-02 helper finalize_pending_placeholders 签名(&mut ContextManager, &HashMap, &str) 函数参数列表 disjoint borrow 冲突(agent riskNote 预判"应无问题"错误)→ 改 &mut AiSession 整体(函数体内 disjoint borrow 合法)+ 调用点 &mut *session(DerefMut 解 guard)。印证:agent 不跑 cargo 的 riskNote 是猜测,主代 cargo 必验(同 Sprint 22 教训)
|
||||
- 主代补 B-05 前端同步:types.ts CostTier 删 'free' 对齐 Rust(防类型不一致 + DB "free" 反序列化边界)
|
||||
|
||||
**代码变更**:
|
||||
- df-ai: router.rs(struct 删 min_intelligence/max_cost + select 删步骤4/5 + max_by_key 纯 weight 删 Reverse + test 改写) / model_probe.rs(heuristic_infer 中性默认 Medium/Standard) / presets/models.json(17 条删 cost_tier/intelligence)
|
||||
- df-ai-core: model.rs(CostTier 删 Free + 注释 + test 同步)
|
||||
- df-workflow: executor.rs(Ok 分支 emit NodeCompleted 移入 !is_cancelled + 取消 emit NodeCancelled 对齐 Err)
|
||||
- df-storage: crud.rs(KnowledgeEventsRepo::list_recent ORDER BY timestamp 兜底)
|
||||
- src-tauri: commands/ai/commands.rs(finalize_pending_placeholders helper + 5 处 .clear() 前终态化,主代修借用) + title/compress/knowledge_inject/agentic/project.rs(删 TaskRequirements min_intelligence/max_cost 传参 + import 清理)
|
||||
- df-nodes: ai_node.rs / df-ideas: adversarial.rs(同删传参) / 前端: api/types.ts(CostTier 删 'free')
|
||||
|
||||
**遗留问题**:
|
||||
- 1. B-03 行为变更:title/compress 纯 weight 选模型,同 weight 并列 max_by_key 返回最后一个(rust Iterator 语义),需用户核对 Settings weight 配置(用户已全局决策接受)
|
||||
- 2. SW-02 另 2 处 pending_approvals.retain(switch/delete)未改:语义不同(switch DB 占位残留需另案修持久层;delete 非活跃对话占位不在 session.messages 内存)。agent riskNote 标注建议另开任务
|
||||
|
||||
**下一步**:
|
||||
- 工作区累积 37 处 commit-policy-deferred(等架构级重构建基线)
|
||||
- 待审查 CR-260618-10 交审查会话独立 grep 核验
|
||||
|
||||
### 2026-06-18 — workflow CR-260618-11 审查回流 5 项低风险编码(workflow w999qdu86·3 agent·主代核查)
|
||||
|
||||
**工作内容**:
|
||||
- workflow w999qdu86 3 agent(1.7 分钟·83k tokens):BUG-260618-11 ai_approve 吞错修(P0) + CR-11#2 context.rs chars_ratio 注释 + CR-11#3 types.ts IntelligenceTier 注释 + CR-11#4 system role 完备性 grep 核验 + CR-11#5 audit.rs find_cached 性能注记
|
||||
- 文件域零冲突(src-tauri/commands/ai · df-ai/context.rs · 前端 types.ts),agent 不跑 cargo 主代统一验证
|
||||
- 主代审查防污染核查:cargo check --workspace EXIT 0(4 pre-existing dead_code warning)+ vue-tsc EXIT 0 + 独立 grep 印证 agent selfCheck 无污染
|
||||
|
||||
**代码变更**:
|
||||
- src-tauri/commands/ai/commands.rs(:289-303 ai_approve None 分支 match 三路分流对齐 audit_finalize,原 unwrap_or_default 消)
|
||||
- src-tauri/commands/ai/audit.rs(:402 find_cached_high_risk_result 性能注记)
|
||||
- crates/df-ai/src/context.rs(:56 chars_ratio 0.35 注释,值未变)
|
||||
- src/api/types.ts(:209 IntelligenceTier 注释)
|
||||
|
||||
**遗留问题**:
|
||||
- 1. BUG-11 行为变更:DB 故障现返明确错误(原误为「未找到挂起」),同属 Err 分支语义不变
|
||||
- 2. CR-11#2 chars_ratio 降值(0.10~0.15)属行为变更影响裁剪边界,本次仅标注未做
|
||||
- 3. CR-11#5 find_cached 批量预取属架构改签名,本次仅加注记未做
|
||||
|
||||
**下一步**:
|
||||
- 剩余 11 项需用户/架构级定夺(F-09 多会话/T-01·02 实测/S-01·02 澄清/ARC-07 排期/UX-12 二选一/B-260617-11·17 意图+运行时),workflow 推不动,交用户
|
||||
|
||||
### 2026-06-18 — workflow ToolCard.vue 工具结果渲染 8 项(workflow w2drz3ppo·8 agent 并行核验+主代统筹实施·commit-policy-deferred)
|
||||
|
||||
**工作内容**:
|
||||
- workflow w2drz3ppo 8 agent(5.1 分钟·301k tokens·132 tool_uses):并行核验 8 工具(run_workflow/patch_file/delete_file/read_file/advance_task/list_trash/list_directory/list_*)后端 tool_registry.rs 返回 vs 前端 ToolCard.vue 渲染,8/8 isReal=true,每份产出精确 old/new 实施清单 + selfCheck grep 证据
|
||||
- 主代统筹实施(单文件域 ToolCard.vue 不能并行改):合并冲突(UX-10 被 UX-12 吸收)+ 修死代码位置(write_file 改模板分支非 formatToolResult case)+ type 13 字段去重合并 + 补 task_id 漏列
|
||||
- 主代审查防污染核查:vue-tsc --noEmit EXIT 0 + 独立 grep 印证 ToolCard.vue 22 处新字段 + i18n 18 处新 key 全落地
|
||||
|
||||
**代码变更**:
|
||||
- src/components/ToolCard.vue:ToolResult type 补 13 字段(task_id/execution_id/target_status/note/diff/matches_found/backup_path/returned_lines/items/modified/old_size/encoding/cross_volume)+ 模板 read_file has_more 截断 / list_directory truncated / write_file old_size·encoding / 新增 patch_file diff 分支 + resultDiffLines computed + formatToolResult switch(run_workflow/list_*/advance_task/delete_file/rename_file)+ toolResultSummary switch(run_workflow/list_*重写修误判+list_trash/advance_task/delete_file/rename_file)+ formatFileInfoResult modified
|
||||
- src/i18n/{en,zh-CN}/aiTool.ts:加 workflowTriggered/Short/patchedMatches/bytesUnit/linesTruncated/dirTruncated/advancedTaskWithRounds/trashCount/listHasMore/renamedWithBytes/CrossVolume/writeOverwrite/encodingLabel/fileInfoModified + 改 deleteFileSoft 加 backup_path
|
||||
|
||||
**遗留问题**:
|
||||
- 1. list_directory/list_* 截断文案硬编码 {n:1000}(对齐后端 tool_registry.rs:880 max_entries=1000),后端变更需同步前端
|
||||
- 2. list_directory 的 ai-tool-file-meta--warn CSS class 未定义(功能 v-if 生效,样式可后续补)
|
||||
- 3. 本批 vue-tsc 类型层验证通过,运行时渲染待 T-260614-01 tauri dev 实测批
|
||||
|
||||
**下一步**:
|
||||
- 待审查 CR-260618-12 交审查会话独立 grep 核验
|
||||
- 工作区累积 45 处 commit-policy-deferred(等架构级重构建基线)
|
||||
- 剩余需用户项同 CR-11 段(11 项 workflow 推不动)
|
||||
|
||||
### 2026-06-18 — workflow SMELL-P0-1 吞错降级加 warn + B-260618-06 表格 CSS(workflow w5siwnipj·6 agent 并行核验+主代统筹实施·commit-policy-deferred)
|
||||
|
||||
**工作内容**:
|
||||
- workflow w5siwnipj 6 agent(135s·190k tokens·62 tool_uses):并行核验 SMELL-P0-1 吞错 5 高危(agentic/knowledge_inject×3/idea/audit)+ B-260618-06 表格 CSS + coordinator 空壳,产出 currentCode/callers/verdict/fixType/proposedFix/behaviorChange/risk/selfCheck 结构化清单
|
||||
- 主代统筹裁决:4 项实施(降级加 warn 类无行为变更 + CSS 确定性)+ 2 项 no-action(audit 误报 CR-11 已修 / coordinator 已标注保留)
|
||||
- 主代审查防污染核查:独立 Read ai-md.css/knowledge_inject.rs/idea.rs 印证 agent selfCheck 6/6 准确,cargo check -p devflow EXIT 0 + vue-tsc EXIT 0
|
||||
|
||||
**代码变更**:
|
||||
- B-260618-06:src/styles/ai-md.css:59 移除 display:block/overflow-x:auto(width:100%→max-width:100%)+ src/components/AiChat.vue:3366 移除 display:block/width:max-content/overflow-x:auto,table 留 max-width:100%,气泡加 .ai-msg-bubble--ai.ai-md { overflow-x:auto }(CSS spec table-display 下 overflow 须由 block 父容器承担)
|
||||
- SMELL-P0-1:agentic.rs:348(provider 池)+ knowledge_inject.rs:130,139,312(检索/消息解析)+ idea.rs:292-296(tags 解析)unwrap_or_default → match + tracing::warn 降级,Err 返空 Vec 走原降级路径零行为变更
|
||||
|
||||
**遗留问题**:
|
||||
- 1. SMELL-P0-1 audit.rs:344 误报(audit_finalize CR-11 已修三路 match),todo line 漂移+旧版本,workflow 独立核验拦截
|
||||
- 2. 全局 ai-md.css 移除 display:block 后其他引用页(ProjectDetail/Ideas/Knowledge/TaskDetail)宽表格滚动需各自容器,当前仅 AiChat 气泡补 overflow,其他页 markdown 少宽表可接受
|
||||
- 3. SMELL-P0-1 58 处全量降 P2(5 高危已清,余低危非紧急)
|
||||
|
||||
**下一步**:
|
||||
- 待审查 CR-260618-13 交审查会话独立 grep 核验
|
||||
- 工作区累积 commit-policy-deferred 项 +5(等架构级基线)
|
||||
- 剩余需用户项同前(11 项 workflow 推不动)
|
||||
|
||||
<!--
|
||||
|
||||
### [Sprint N] YYYY-MM-DD — 简述
|
||||
|
||||
@@ -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>
|
||||
Generated
+13924
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
|
||||
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
|
||||
let _config: MiniappConfig = { ...defaultConfig }
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
_config = { ..._config, ...patch }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "会话"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "df-traceability"
|
||||
name = "df-ai-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
futures = "0.3"
|
||||
@@ -0,0 +1,13 @@
|
||||
//! df-ai-core: LLM Provider trait + AI 数据结构(轻量 crate,零 HTTP 依赖)
|
||||
//!
|
||||
//! 从 df-ai 下沉的全局 AI 接入标准。df-ai 保留 HTTP impl 与业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 等),通过 re-export
|
||||
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
|
||||
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
|
||||
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
|
||||
pub use model::*;
|
||||
pub use provider::*;
|
||||
@@ -0,0 +1,469 @@
|
||||
//! 模型能力数据模型 — F-01 阶段1
|
||||
//!
|
||||
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
|
||||
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §2
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 4 维度枚举
|
||||
// ============================================================
|
||||
|
||||
/// 维度 1 — 模态(模型能接收什么输入)
|
||||
///
|
||||
/// 设计文档 §2.2 仅列出 Text/Vision,Audio/Video 为未来扩展占位。
|
||||
/// 序列化为 snake_case(`text` / `vision` / `audio` / `video`)。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modality {
|
||||
Text,
|
||||
Vision,
|
||||
/// 未来扩展:音频输入(预留,Phase 2 多模态)
|
||||
Audio,
|
||||
/// 未来扩展:视频输入(预留)
|
||||
Video,
|
||||
}
|
||||
|
||||
/// 维度 2 — 能力(模型能做什么)
|
||||
///
|
||||
/// 设计文档 §2.2 列出 ToolUse/Embedding/CodeGen。序列化为 snake_case。
|
||||
/// §3.4 提到未来可加 Streaming/Custom(String),当前 Phase 1 仅 3 个确定变体。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Capability {
|
||||
/// function calling / tool use
|
||||
ToolUse,
|
||||
/// 向量嵌入(知识库)
|
||||
Embedding,
|
||||
/// 代码生成强项
|
||||
CodeGen,
|
||||
}
|
||||
|
||||
/// 维度 3 — 价格分级(成本)
|
||||
///
|
||||
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
|
||||
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
|
||||
/// B-260618-05 删除 Free 死档变体。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CostTier {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// 维度 4 — 聪明程度(智力分级)
|
||||
///
|
||||
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
|
||||
/// 派生 Ord:Lite < Standard < Plus < Ultra。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntelligenceTier {
|
||||
/// flash/mini/lite/nano — 快但简单任务
|
||||
Lite,
|
||||
/// air/standard — 日常够用
|
||||
Standard,
|
||||
/// plus/pro/max — 复杂推理
|
||||
Plus,
|
||||
/// 最强模型 — 兜底用
|
||||
Ultra,
|
||||
}
|
||||
|
||||
/// 探测元数据来源(只读,由 ModelProbe 填充,Phase 2)
|
||||
///
|
||||
/// 设计文档 §2.1 的 ProbeSource 字段。Phase 1 仅定义占位,Phase 2 探测器填充。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProbeSource {
|
||||
/// 用户手动设定(最高优先)
|
||||
UserSet,
|
||||
/// 内置预设表精确/模糊匹配
|
||||
PresetTable,
|
||||
/// 模型名启发式推断
|
||||
Heuristic,
|
||||
/// 默认值兜底
|
||||
Default,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 核心结构 ModelConfig
|
||||
// ============================================================
|
||||
|
||||
/// 单个模型的完整配置(4 维度 + 路由控制)。
|
||||
///
|
||||
/// 设计文档 §2.1。serde 默认值保证老库/部分字段缺失时向后兼容。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelConfig {
|
||||
// ── 基础 ──
|
||||
/// 模型名(如 "glm-4v-flash")
|
||||
pub model_id: String,
|
||||
/// 启用/禁用(false = 配了但不参与路由,相当于"备档")
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 用户自定义别名(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
|
||||
// ── 维度 1:模态(能接收什么输入)──
|
||||
#[serde(default)]
|
||||
pub modalities: Vec<Modality>,
|
||||
|
||||
// ── 维度 2:能力(能做什么)──
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<Capability>,
|
||||
|
||||
// ── 维度 3:价格(成本分级)──
|
||||
#[serde(default)]
|
||||
pub cost_tier: CostTier,
|
||||
|
||||
// ── 维度 4:聪明程度(智力分级)──
|
||||
#[serde(default)]
|
||||
pub intelligence: IntelligenceTier,
|
||||
|
||||
// ── 路由控制 ──
|
||||
/// 权重(0-100,同能力候选中优先选权重高的)
|
||||
#[serde(default = "default_weight")]
|
||||
pub weight: u32,
|
||||
/// 上下文窗口大小(tokens)
|
||||
#[serde(default = "default_context_window")]
|
||||
pub context_window: usize,
|
||||
|
||||
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub probe_source: Option<ProbeSource>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_weight() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_context_window() -> usize {
|
||||
8192
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
/// 用模型名构造默认配置(老格式字符串数组 `["glm-4-flash"]` 升级时逐项调用)。
|
||||
///
|
||||
/// 设计文档 §2.3 向后兼容:老 `models: ["model-a"]` → 每个名字转默认 ModelConfig。
|
||||
/// 默认值:enabled=true / modalities=[Text] / capabilities=[ToolUse] /
|
||||
/// cost_tier=Medium / intelligence=Standard / weight=50 / context_window=8192。
|
||||
/// (ToolUse 默认开:主流对话模型支持 function calling;精确能力由 Phase 2 探测器修正)
|
||||
pub fn with_defaults(model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities: vec![Modality::Text],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: default_weight(),
|
||||
context_window: default_context_window(),
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认值(#[serde(default)] 用于 Vec/Option 字段时 serde 要求 Default impl)
|
||||
impl Default for Modality {
|
||||
fn default() -> Self {
|
||||
Modality::Text
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CostTier {
|
||||
fn default() -> Self {
|
||||
CostTier::Medium
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IntelligenceTier {
|
||||
fn default() -> Self {
|
||||
IntelligenceTier::Standard
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 向后兼容反序列化:老格式字符串数组 → ModelConfig 数组
|
||||
// ============================================================
|
||||
|
||||
/// 反序列化 `models` JSON 字段,兼容两种格式:
|
||||
///
|
||||
/// - 老格式:`["glm-4-flash", "glm-4v"]`(纯字符串数组)→ 每个名字转 `ModelConfig::with_defaults`
|
||||
/// - 新格式:`[{ "model_id": ..., "modalities": [...] }, ...]`(ModelConfig 对象数组)
|
||||
/// - 空/非数组:null / 缺失 / 空字符串 → 空 Vec
|
||||
///
|
||||
/// 设计文档 §2.3。挂在 AiProviderRecord.models 字段上(#[serde(default, deserialize_with = ...)])。
|
||||
pub fn deserialize_model_configs<'de, D>(deserializer: D) -> Result<Vec<ModelConfig>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let opt: Option<serde_json::Value> = Option::deserialize(deserializer)?;
|
||||
let Some(value) = opt else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
match value {
|
||||
// null / 空字符串 / 空数组 → 空
|
||||
serde_json::Value::Null => Ok(Vec::new()),
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => Ok(Vec::new()),
|
||||
serde_json::Value::String(s) => {
|
||||
// 字符串内容可能是 JSON 文本(DB TEXT 列存 JSON 字符串)
|
||||
match serde_json::from_str::<serde_json::Value>(s.trim()) {
|
||||
Ok(inner) => parse_models_value(inner).map_err(D::Error::custom),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => parse_models_value(serde_json::Value::Array(arr)).map_err(D::Error::custom),
|
||||
other => parse_models_value(other).map_err(D::Error::custom),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_models_value(value: serde_json::Value) -> Result<Vec<ModelConfig>, serde_json::Error> {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
// 老格式:全为字符串 → 每个转默认配置
|
||||
if arr.iter().all(|v| v.is_string()) {
|
||||
return Ok(arr
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str().map(ModelConfig::with_defaults))
|
||||
.collect());
|
||||
}
|
||||
// 新格式:ModelConfig 对象数组
|
||||
serde_json::from_value::<Vec<ModelConfig>>(serde_json::Value::Array(arr))
|
||||
}
|
||||
serde_json::Value::Null => Ok(Vec::new()),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── 枚举 serde 映射 ──
|
||||
|
||||
#[test]
|
||||
fn modality_serde_snake_case() {
|
||||
let cases = [
|
||||
(Modality::Text, "\"text\""),
|
||||
(Modality::Vision, "\"vision\""),
|
||||
(Modality::Audio, "\"audio\""),
|
||||
(Modality::Video, "\"video\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let s = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(s, expected, "serialize {variant:?}");
|
||||
let back: Modality = serde_json::from_str(expected).unwrap();
|
||||
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_serde_snake_case() {
|
||||
let cases = [
|
||||
(Capability::ToolUse, "\"tool_use\""),
|
||||
(Capability::Embedding, "\"embedding\""),
|
||||
(Capability::CodeGen, "\"code_gen\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
let s = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(s, expected, "serialize {variant:?}");
|
||||
let back: Capability = serde_json::from_str(expected).unwrap();
|
||||
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_tier_serde_and_ordering() {
|
||||
let cases = [
|
||||
(CostTier::Low, "\"low\""),
|
||||
(CostTier::Medium, "\"medium\""),
|
||||
(CostTier::High, "\"high\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
|
||||
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
|
||||
assert!(CostTier::Low < CostTier::Medium);
|
||||
assert!(CostTier::Medium < CostTier::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelligence_tier_serde_and_ordering() {
|
||||
let cases = [
|
||||
(IntelligenceTier::Lite, "\"lite\""),
|
||||
(IntelligenceTier::Standard, "\"standard\""),
|
||||
(IntelligenceTier::Plus, "\"plus\""),
|
||||
(IntelligenceTier::Ultra, "\"ultra\""),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Lite < Standard < Plus < Ultra(枚举序,路由已解耦不再用于过滤,保留供未来判别源)
|
||||
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
|
||||
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
|
||||
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);
|
||||
}
|
||||
|
||||
// ── ModelConfig serde roundtrip ──
|
||||
|
||||
#[test]
|
||||
fn model_config_full_roundtrip() {
|
||||
let mc = ModelConfig {
|
||||
model_id: "glm-4v".into(),
|
||||
enabled: true,
|
||||
label: Some("我的视觉".into()),
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
probe_source: Some(ProbeSource::PresetTable),
|
||||
};
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, mc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_skip_none_label_and_probe() {
|
||||
let mc = ModelConfig::with_defaults("glm-4-flash");
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
// label / probe_source 为 None 应被跳过
|
||||
assert!(!json.contains("label"), "json={json}");
|
||||
assert!(!json.contains("probe_source"), "json={json}");
|
||||
// 反序列化回来相等(skip_serializing 不影响 deserialize)
|
||||
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, mc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_with_defaults_values() {
|
||||
let mc = ModelConfig::with_defaults("test-model");
|
||||
assert_eq!(mc.model_id, "test-model");
|
||||
assert!(mc.enabled);
|
||||
assert_eq!(mc.modalities, vec![Modality::Text]);
|
||||
assert_eq!(mc.capabilities, vec![Capability::ToolUse]);
|
||||
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(mc.weight, 50);
|
||||
assert_eq!(mc.context_window, 8192);
|
||||
assert_eq!(mc.label, None);
|
||||
assert_eq!(mc.probe_source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_config_defaults_applied_on_missing_fields() {
|
||||
// 最小 JSON,仅 model_id — 其余字段靠 #[serde(default)] / default fn 填充
|
||||
let json = r#"{"model_id":"minimal"}"#;
|
||||
let mc: ModelConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mc.model_id, "minimal");
|
||||
assert!(mc.enabled, "enabled 默认 true");
|
||||
assert_eq!(mc.weight, 50, "weight 默认 50");
|
||||
assert_eq!(mc.context_window, 8192, "context_window 默认 8192");
|
||||
assert!(mc.modalities.is_empty(), "Vec 字段 default 空");
|
||||
assert!(mc.capabilities.is_empty());
|
||||
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||
}
|
||||
|
||||
// ── deserialize_model_configs 向后兼容 ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Wrap {
|
||||
#[serde(default, deserialize_with = "deserialize_model_configs")]
|
||||
models: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_old_format_string_array() {
|
||||
// 老格式:["glm-4-flash", "glm-4v"]
|
||||
let json = r#"{"models":["glm-4-flash","glm-4v"]}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 2);
|
||||
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||
assert!(w.models[0].enabled);
|
||||
assert_eq!(w.models[0].modalities, vec![Modality::Text]);
|
||||
assert_eq!(w.models[1].model_id, "glm-4v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_new_format_object_array() {
|
||||
let json = r#"{"models":[{"model_id":"glm-4v","enabled":true,"modalities":["text","vision"],"capabilities":["tool_use"],"cost_tier":"medium","intelligence":"plus","weight":70,"context_window":128000}]}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 1);
|
||||
let m = &w.models[0];
|
||||
assert_eq!(m.model_id, "glm-4v");
|
||||
assert_eq!(m.modalities, vec![Modality::Text, Modality::Vision]);
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Plus);
|
||||
assert_eq!(m.context_window, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_null_or_missing_yields_empty() {
|
||||
// null
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":null}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 缺失字段
|
||||
let w: Wrap = serde_json::from_str(r#"{}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 空字符串(DB TEXT 列可能存空串)
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":""}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
// 空数组
|
||||
let w: Wrap = serde_json::from_str(r#"{"models":[]}"#).unwrap();
|
||||
assert!(w.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_json_string_text_column() {
|
||||
// DB TEXT 列存的是 JSON 文本字符串:`"[\"model-a\"]"`
|
||||
let json = r#"{"models":"[\"glm-4-flash\"]"}"#;
|
||||
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(w.models.len(), 1);
|
||||
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_via_text_column() {
|
||||
// 模拟 DB 落库:Vec<ModelConfig> → serde_json 序列化 → 存 TEXT 列
|
||||
// 读出来:TEXT 列值 → 反序列化回 Vec<ModelConfig>
|
||||
let original = vec![
|
||||
ModelConfig::with_defaults("glm-4-flash"),
|
||||
ModelConfig {
|
||||
model_id: "glm-4v".into(),
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
weight: 70,
|
||||
context_window: 128_000,
|
||||
..ModelConfig::with_defaults("glm-4v")
|
||||
},
|
||||
];
|
||||
let text = serde_json::to_string(&original).unwrap();
|
||||
let wrapped = format!(r#"{{"models":"{}"}}"#, text.replace('"', "\\\""));
|
||||
let w: Wrap = serde_json::from_str(&wrapped).unwrap();
|
||||
assert_eq!(w.models.len(), 2);
|
||||
assert_eq!(w.models[1].intelligence, IntelligenceTier::Plus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//!
|
||||
//! 本文件含 trait + 类型 impl 块(零 IO)。纯类型定义(CompletionRequest /
|
||||
//! ChatMessage / ContentPart 等 `#[derive]` struct/enum)已拆分到 `types.rs`,
|
||||
//! 经下方 `pub use crate::types::*` re-export 保持外部路径
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 不变(编译期验证)。
|
||||
//! HTTP impl(OpenAICompatProvider / AnthropicCompatProvider)+ 业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 工厂)留在 df-ai crate。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
// 透明 re-export:types.rs 中所有 pub 类型经此回流到 provider::* glob,
|
||||
// 使 df_ai::provider::*(df-ai/src/provider.rs:15 的 `pub use df_ai_core::provider::*`)
|
||||
// 与直接 `df_ai_core::provider::Type` 限定路径全部继续可用。
|
||||
pub use crate::types::*;
|
||||
use crate::types::{new_message_id, now_millis_i64};
|
||||
|
||||
impl ContentPart {
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
ContentPart::Text { text: text.into() }
|
||||
}
|
||||
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
ContentPart::Image {
|
||||
url: None,
|
||||
base64: Some(data.into()),
|
||||
media_type: Some(media_type.into()),
|
||||
alt: None,
|
||||
}
|
||||
}
|
||||
pub fn image_url(url: impl Into<String>) -> Self {
|
||||
ContentPart::Image { url: Some(url.into()), base64: None, media_type: None, alt: None }
|
||||
}
|
||||
|
||||
/// 是否图片片
|
||||
pub fn is_image(&self) -> bool {
|
||||
matches!(self, ContentPart::Image { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。
|
||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||||
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
|
||||
}
|
||||
|
||||
/// 是否含图片片(供 provider 判定走多模态分支)。
|
||||
pub fn has_image(&self) -> bool {
|
||||
self.parts.as_ref().map(|ps| ps.iter().any(|p| p.is_image())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// parts 若存在则返回引用,否则 None。
|
||||
pub fn parts(&self) -> Option<&[ContentPart]> {
|
||||
self.parts.as_deref()
|
||||
}
|
||||
|
||||
/// 把 content + parts 拍平为有序 ContentPart 序列:先 content 作 Text 片(非空时),
|
||||
/// 再追加 parts(若有)。供 provider 生成 content blocks(保证文本在前、图片在后)。
|
||||
pub fn flattened_parts(&self) -> Vec<ContentPart> {
|
||||
let mut out: Vec<ContentPart> = Vec::new();
|
||||
if !self.content.is_empty() {
|
||||
out.push(ContentPart::Text { text: self.content.clone() });
|
||||
}
|
||||
if let Some(ps) = &self.parts {
|
||||
for p in ps {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
|
||||
///
|
||||
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
|
||||
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
|
||||
/// 无需每加一个状态就来这里改。当前取值 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"))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> anyhow::Result<StreamResult>;
|
||||
|
||||
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
|
||||
///
|
||||
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
|
||||
/// Anthropic 无 embedding API,保持默认。
|
||||
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
|
||||
}
|
||||
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||||
fn endpoint(&self) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_active_whitelist() {
|
||||
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
|
||||
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
|
||||
// matches! 只认 None/active 自动落入 false,面向未来验证。
|
||||
|
||||
// None(构造默认值,向前兼容老 JSON)
|
||||
let m = ChatMessage::user("hi");
|
||||
assert!(m.is_active(), "None 应 active");
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("active".to_string());
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("truncated".to_string());
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("archived_segment".to_string());
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("compressed".to_string());
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a ContentPart ----------
|
||||
|
||||
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
|
||||
#[test]
|
||||
fn contentpart_legacy_json_no_parts() {
|
||||
let json = r#"{"role":"user","content":"hello"}"#;
|
||||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.parts.is_none(), "老 JSON 无 parts 字段 → None");
|
||||
assert!(!m.has_image());
|
||||
}
|
||||
|
||||
/// 新 JSON 带 parts(含 Image 片)反序列化 round-trip
|
||||
#[test]
|
||||
fn contentpart_with_image_roundtrip() {
|
||||
let m = ChatMessage::user_parts(
|
||||
"看这张图",
|
||||
vec![
|
||||
ContentPart::image_base64("image/png", "iVBORw0KGgo="),
|
||||
ContentPart::text("说明"),
|
||||
],
|
||||
);
|
||||
let json = serde_json::to_string(&m).expect("序列化");
|
||||
// parts 顺序:[image_base64, text] → 首元素是 image
|
||||
assert!(json.contains(r#""parts":[{"type":"image""#));
|
||||
assert!(json.contains(r#""type":"text""#));
|
||||
assert!(json.contains(r#""base64":"iVBORw0KGgo=""#));
|
||||
assert!(json.contains(r#""media_type":"image/png""#));
|
||||
|
||||
let back: ChatMessage = serde_json::from_str(&json).expect("反序列化 round-trip");
|
||||
assert_eq!(back.content, "看这张图");
|
||||
assert!(back.has_image());
|
||||
let parts = back.parts.expect("parts 应存在");
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].is_image());
|
||||
}
|
||||
|
||||
/// has_image 判定
|
||||
#[test]
|
||||
fn contentpart_has_image_detection() {
|
||||
assert!(!ChatMessage::user("纯文本").has_image());
|
||||
let m = ChatMessage::user_parts("t", vec![ContentPart::text("只文本片")]);
|
||||
assert!(!m.has_image(), "仅 Text 片不算 has_image");
|
||||
let m = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![ContentPart::text("前缀"), ContentPart::image_url("https://x/a.png")],
|
||||
);
|
||||
assert!(m.has_image());
|
||||
}
|
||||
|
||||
/// flattened_parts:content 非空 → 前置 Text 片 + parts 追加
|
||||
#[test]
|
||||
fn contentpart_flattened_parts() {
|
||||
let m = ChatMessage::user("hi");
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 1);
|
||||
assert_eq!(flat[0], ContentPart::Text { text: "hi".into() });
|
||||
|
||||
let m = ChatMessage::user_parts(
|
||||
"cap",
|
||||
vec![ContentPart::image_url("u"), ContentPart::text("尾")],
|
||||
);
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 3);
|
||||
assert_eq!(flat[0], ContentPart::Text { text: "cap".into() });
|
||||
assert!(flat[1].is_image());
|
||||
assert_eq!(flat[2], ContentPart::Text { text: "尾".into() });
|
||||
|
||||
let mut m = ChatMessage::user("");
|
||||
m.parts = Some(vec![ContentPart::text("x")]);
|
||||
let flat = m.flattened_parts();
|
||||
assert_eq!(flat.len(), 1, "空 content 不应产生空 Text 片");
|
||||
}
|
||||
|
||||
/// 向后兼容:旧代码 ChatMessage 字面量构造仍合法(audit/title/commands 零回归)
|
||||
#[test]
|
||||
fn contentpart_struct_literal_compat() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::User,
|
||||
content: "字面量构造".into(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
timestamp: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
assert!(m.parts.is_none());
|
||||
}
|
||||
|
||||
// ---------- DeepSeek reasoning_content ----------
|
||||
|
||||
/// 有 reasoning_content 时应序列化出来;None 时不出现
|
||||
#[test]
|
||||
fn completion_request_reasoning_content_serialization() {
|
||||
let req = CompletionRequest {
|
||||
model: "deepseek-r1".to_string(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: Some("let me think...".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json);
|
||||
|
||||
// None 时不应出现(skip_serializing_if)
|
||||
let req_none = CompletionRequest { reasoning_content: None, ..req };
|
||||
let json_none = serde_json::to_string(&req_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none);
|
||||
}
|
||||
|
||||
/// 所有便捷构造函数默认 reasoning_content 为 None
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_constructors() {
|
||||
assert!(ChatMessage::system("sys").reasoning_content.is_none());
|
||||
assert!(ChatMessage::user("hi").reasoning_content.is_none());
|
||||
assert!(ChatMessage::assistant("resp").reasoning_content.is_none());
|
||||
assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// ChatMessage reasoning_content 序列化 round-trip
|
||||
#[test]
|
||||
fn chat_message_reasoning_content_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: None,
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: Some("thinking process".to_string()),
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||||
}
|
||||
|
||||
// ---------- F-260619-04 消息级溯源:id 字段 ----------
|
||||
|
||||
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
|
||||
#[test]
|
||||
fn chat_message_id_generated_by_constructors() {
|
||||
assert!(ChatMessage::system("sys").id.is_some(), "system 应有 id");
|
||||
assert!(ChatMessage::user("hi").id.is_some(), "user 应有 id");
|
||||
assert!(ChatMessage::assistant("resp").id.is_some(), "assistant 应有 id");
|
||||
assert!(
|
||||
ChatMessage::assistant_with_tools("r", vec![]).id.is_some(),
|
||||
"assistant_with_tools 应有 id"
|
||||
);
|
||||
assert!(
|
||||
ChatMessage::tool_result("call_1", "result").id.is_some(),
|
||||
"tool_result 应有 id"
|
||||
);
|
||||
assert!(
|
||||
ChatMessage::user_parts("t", vec![ContentPart::text("x")]).id.is_some(),
|
||||
"user_parts 应有 id"
|
||||
);
|
||||
}
|
||||
|
||||
/// id 全局唯一性:连续构造 100 条不重复(AtomicU64 计数器保证)
|
||||
#[test]
|
||||
fn chat_message_id_uniqueness() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for _ in 0..100 {
|
||||
let m = ChatMessage::user("x");
|
||||
let id = m.id.expect("构造的消息应有 id");
|
||||
assert!(ids.insert(id), "100 条消息 id 应全部唯一");
|
||||
}
|
||||
}
|
||||
|
||||
/// id 序列化 round-trip:有 id 时序列化保留,反序列化回来一致
|
||||
#[test]
|
||||
fn chat_message_id_roundtrip() {
|
||||
let m = ChatMessage {
|
||||
id: Some("msg_1718800000000_42".to_string()),
|
||||
role: MessageRole::Assistant,
|
||||
content: "answer".to_string(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains(r#""id":"msg_1718800000000_42""#), "id 应序列化, got: {}", json);
|
||||
|
||||
let back: ChatMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.id.as_deref(), Some("msg_1718800000000_42"));
|
||||
}
|
||||
|
||||
/// 向前兼容:老 JSON 无 id 字段 → 反序列化为 None(serde default)
|
||||
#[test]
|
||||
fn chat_message_id_legacy_json_no_id() {
|
||||
let json = r#"{"role":"user","content":"hello"}"#;
|
||||
let m: ChatMessage = serde_json::from_str(json).expect("老 JSON 应可反序列化");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.id.is_none(), "老 JSON 无 id 字段 → None");
|
||||
|
||||
// 重新序列化:id=None 时不应出现 id 字段(skip_serializing_if)
|
||||
let re = serde_json::to_string(&m).unwrap();
|
||||
assert!(!re.contains(r#""id""#), "id=None 不应序列化, got: {}", re);
|
||||
}
|
||||
|
||||
/// StreamChunk reasoning_content 序列化
|
||||
#[test]
|
||||
fn stream_chunk_reasoning_content() {
|
||||
let chunk = StreamChunk {
|
||||
delta: "text".to_string(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: Some("thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let chunk_none = StreamChunk { reasoning_content: None, ..chunk };
|
||||
let json_none = serde_json::to_string(&chunk_none).unwrap();
|
||||
assert!(!json_none.contains("reasoning_content"));
|
||||
}
|
||||
|
||||
/// CompletionResponse reasoning_content 序列化
|
||||
#[test]
|
||||
fn completion_response_reasoning_content() {
|
||||
let resp = CompletionResponse {
|
||||
text: "ok".to_string(),
|
||||
model: "r1".to_string(),
|
||||
usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
tool_calls: None,
|
||||
reasoning_content: Some("r1 thought".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("reasoning_content"));
|
||||
|
||||
let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! LLM Provider 数据结构定义(纯类型,零 IO)
|
||||
//!
|
||||
//! 从 provider.rs 拆分的核心数据类型层:请求/响应/消息/工具/Token 等
|
||||
//! `#[derive]` struct 与 enum + 一个内联时间戳辅助函数。所有 impl 块与
|
||||
//! `LlmProvider` trait 留在 provider.rs,本文件只承载类型定义。
|
||||
//!
|
||||
//! 经 `provider.rs` 的 `pub use crate::types::*` re-export,外部路径
|
||||
//! `df_ai_core::provider::*` / `df_ai::provider::*` 保持不变(编译期验证)。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
|
||||
///
|
||||
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
|
||||
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
|
||||
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
|
||||
/// vision 端点,否则只用 content 文本(保持纯文本端点兼容)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentPart {
|
||||
/// 文本片
|
||||
Text { text: String },
|
||||
/// 图片片。
|
||||
/// - url:http(s) 可达 URL(provider 直接转发,不读字节)。
|
||||
/// - base64:data URI 之外的纯 base64 字符串 + media_type(provider 内嵌转发)。
|
||||
/// base64 非空时 url 忽略,便于前端上行无网络回拉的本地粘贴图。
|
||||
Image {
|
||||
url: Option<String>,
|
||||
base64: Option<String>,
|
||||
/// base64 模式必填(image/png | image/jpeg | image/webp | image/gif);url 模式可空。
|
||||
media_type: Option<String>,
|
||||
/// 可选 alt(vision 模型/降级文本时用)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alt: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
///
|
||||
/// ⚠️ **迁移耦合点**:下方各字段名(`role`/`content`/`parts`/`tool_call_id`/
|
||||
/// `tool_calls`/`model`/`status`/`reasoning_content`/`timestamp`)被
|
||||
/// `df-storage/src/migrations.rs` 的 `migrate_v21` 硬编码提取(裸 JSON 反序列化,
|
||||
/// 因 df-storage 不依赖 df-ai-core)。**改字段名必须同步更新迁移函数**,否则老库
|
||||
/// 迁移漏数据。详见消息拆分存储设计 §4.2.3 迁移耦合点。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
|
||||
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
|
||||
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 多模态内容片(F-260614-05 Phase 2a)。
|
||||
///
|
||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
|
||||
/// 连同 content(作为前置 Text 片)一起转成 OpenAI/Anthropic 的 content blocks。
|
||||
/// content 字段始终保留人类可读文本(文本消息的 Text 片内容与 content 一致),
|
||||
/// 保证 audit/title/export 等读 content 当字符串的调用方零回归。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parts: Option<Vec<ContentPart>>,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
/// 消息创建时间(Unix 毫秒)。仅前端时间戳展示用,序列化进 messages JSON 持久化;
|
||||
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
|
||||
pub(crate) fn now_millis_i64() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// ChatMessage.id 生成器:全局唯一 + 单调递增 + 零依赖。
|
||||
///
|
||||
/// 格式 `msg_{ts_ms}_{counter}` —— 时间戳(毫秒)+ 进程内 AtomicU64 计数器双保险。
|
||||
/// 决策:不用 ULID crate(workspace 无此依赖,df-ai-core 也不依赖 df-types::new_id),
|
||||
/// 沿用 `now_millis_i64` 内联模式;AtomicU64 保证并发安全(多线程构造消息 ID 不冲突),
|
||||
/// 计数器即便时间戳回拨(系统时钟调整)也单调。可读性好(含时间戳,便于排查)。
|
||||
pub(crate) fn new_message_id() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let ts = now_millis_i64();
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("msg_{}_{n}", ts)
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
/// DeepSeek thinking 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
[
|
||||
{
|
||||
"model_id": "glm-4",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 60,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-air",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 90,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4-plus",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4v",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 70,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "glm-4v-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "gpt-4o",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "gpt-4o-mini",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-5-sonnet-20241022",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 50,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-opus-20240229",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 45,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "claude-3-haiku-20240307",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 80,
|
||||
"context_window": 200000
|
||||
},
|
||||
{
|
||||
"model_id": "gemini-1.5-pro",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 55,
|
||||
"context_window": 1000000
|
||||
},
|
||||
{
|
||||
"model_id": "gemini-1.5-flash",
|
||||
"enabled": true,
|
||||
"modalities": ["text", "vision"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 85,
|
||||
"context_window": 1000000
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-chat",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 75,
|
||||
"context_window": 64000
|
||||
},
|
||||
{
|
||||
"model_id": "deepseek-coder",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use", "code_gen"],
|
||||
"weight": 75,
|
||||
"context_window": 64000
|
||||
},
|
||||
{
|
||||
"model_id": "qwen-2.5",
|
||||
"enabled": true,
|
||||
"modalities": ["text"],
|
||||
"capabilities": ["tool_use"],
|
||||
"weight": 65,
|
||||
"context_window": 128000
|
||||
},
|
||||
{
|
||||
"model_id": "embedding-3",
|
||||
"enabled": true,
|
||||
"modalities": [],
|
||||
"capabilities": ["embedding"],
|
||||
"weight": 50,
|
||||
"context_window": 8192
|
||||
}
|
||||
]
|
||||
@@ -96,6 +96,19 @@ impl AiToolRegistry {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
/// 执行指定工具 — handler 是唯一执行路径(schema+risk+实现同源,消除双轨)
|
||||
pub async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let tool = self
|
||||
.tools
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("未知工具: {}", name))?;
|
||||
(tool.handler)(args).await
|
||||
}
|
||||
|
||||
/// 获取所有已注册工具名称
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.tools.keys().cloned().collect()
|
||||
@@ -118,27 +131,6 @@ impl Default for AiToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具执行结果
|
||||
// ============================================================
|
||||
|
||||
/// 工具执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolExecutionResult {
|
||||
/// 工具调用 ID
|
||||
pub tool_call_id: String,
|
||||
/// 工具名称
|
||||
pub tool_name: String,
|
||||
/// 执行参数
|
||||
pub arguments: Value,
|
||||
/// 执行结果
|
||||
pub result: Value,
|
||||
/// 是否需要人工批准
|
||||
pub approval_required: bool,
|
||||
/// 风险级别
|
||||
pub risk_level: RiskLevel,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助: 构建 JSON Schema 参数
|
||||
// ============================================================
|
||||
@@ -164,3 +156,84 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
|
||||
"required": required,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
|
||||
fn dummy_handler() -> AiToolHandler {
|
||||
Box::new(|_args: Value| {
|
||||
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
|
||||
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_level_serde_lowercase() {
|
||||
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
|
||||
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
|
||||
RiskLevel::High
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_get_tool() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
reg.register(
|
||||
"list_tasks",
|
||||
"列出任务",
|
||||
object_schema(vec![]),
|
||||
RiskLevel::Low,
|
||||
dummy_handler(),
|
||||
);
|
||||
assert!(!reg.is_empty());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
|
||||
assert!(reg.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_same_name_overwrites() {
|
||||
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_and_names() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
|
||||
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
|
||||
assert_eq!(reg.tool_definitions().len(), 2);
|
||||
let names = reg.tool_names();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"a".to_string()));
|
||||
assert!(names.contains(&"b".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_empty() {
|
||||
let reg = AiToolRegistry::default();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_schema_collects_required() {
|
||||
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["properties"]["name"]["type"], "string");
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert_eq!(required.len(), 1);
|
||||
assert_eq!(required[0], "name");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
//! Anthropic 协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
|
||||
//!
|
||||
//! 本模块从 `anthropic_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
|
||||
//! - 请求/响应结构体(`AnthropicRequest` 等)
|
||||
//! - 协议常量(`ANTHROPIC_VERSION` / `DEFAULT_MAX_TOKENS`)
|
||||
//! - SSE 事件 → `StreamChunk` 转换纯函数(`apply_anthropic_event`)
|
||||
//!
|
||||
//! Provider struct + impl(含 HTTP 调用)仍留在 `anthropic_compat.rs`,
|
||||
//! Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/常量/纯函数。
|
||||
//! 零行为变更(纯搬迁)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// Anthropic API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<serde_json::Value>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<AnthropicToolDef>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义(input_schema 对应 OpenAI 的 parameters)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct AnthropicToolDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Anthropic 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicResponse {
|
||||
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
pub model: String,
|
||||
pub content: Vec<AnthropicContentBlock>,
|
||||
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
stop_reason: Option<String>,
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// 响应 content 块(text 或 tool_use)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
/// tool_use 块字段
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 协议常量
|
||||
// ============================================================
|
||||
|
||||
/// Anthropic 流式协议版本头
|
||||
pub(crate) const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
/// Anthropic max_tokens 必填,缺省时的兜底值
|
||||
pub(crate) const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
// ============================================================
|
||||
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
|
||||
// ============================================================
|
||||
|
||||
/// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
|
||||
///
|
||||
/// 按 `type` 字段分发:
|
||||
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
|
||||
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
|
||||
/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。
|
||||
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
|
||||
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
||||
/// - 其它(content_block_stop / ping 等)→ 空 chunk。
|
||||
///
|
||||
/// 等价性:content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致;
|
||||
/// usage 透传(message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖)
|
||||
/// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。
|
||||
pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||
// 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk
|
||||
let v: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match ty {
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
|
||||
"message_start" => {
|
||||
if let Some(inp) = v
|
||||
.get("message")
|
||||
.and_then(|m| m.get("usage"))
|
||||
.and_then(|u| u.get("input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
{
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: inp as u32,
|
||||
completion_tokens: 0,
|
||||
total_tokens: inp as u32,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
|
||||
"message_delta" => {
|
||||
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
|
||||
let acc = usage_accum
|
||||
.get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
|
||||
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None };
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id: None,
|
||||
function_name: None,
|
||||
function_arguments: Some(partial),
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
if let Some(cb) = v.get("content_block") {
|
||||
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
// id 缺失时用占位 id 兜底:流式后续 input_json_delta 按 index 累加,
|
||||
// 中途无法整体跳过;占位 id 保证回传的 tool_use_id 非空,避免 GLM 500。
|
||||
let id = match cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string()) {
|
||||
Some(id) if !id.is_empty() => Some(id),
|
||||
_ => {
|
||||
let placeholder = format!("tool_missing_{}", idx);
|
||||
warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)");
|
||||
Some(placeholder)
|
||||
}
|
||||
};
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id,
|
||||
function_name: name,
|
||||
function_arguments: None,
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None },
|
||||
}
|
||||
}
|
||||
+1932
-35
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
//! Agent 协调器 — 管理多 Agent 协作
|
||||
//!
|
||||
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
|
||||
|
||||
/// Agent 协调器
|
||||
///
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+71
-2
@@ -1,10 +1,79 @@
|
||||
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
|
||||
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
|
||||
|
||||
pub mod ai_tools;
|
||||
pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
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 intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。
|
||||
mod model_fetch_helpers;
|
||||
pub mod model_probe;
|
||||
// model_probe 的纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定),
|
||||
// crate 内复用,private mod — 外部经 model_probe 间接访问,无直接路径需求。
|
||||
mod model_probe_helpers;
|
||||
// 单对话并行多轮 Phase 0a · 0.8 plan_hint 纯函数(扩 intent::tool_subset_for 编排语义)。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md 第 0.8 节。
|
||||
// 纯函数(零 IO 零状态),Phase 1 接入主 loop,LLM 调度留 Phase 2。
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
pub mod stream;
|
||||
// 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;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
|
||||
pub use df_ai_core;
|
||||
|
||||
/// SW-260618-10: 构建 Provider 共用的 reqwest Client(OpenAI/Anthropic Provider::new DRY)。
|
||||
///
|
||||
/// connect_timeout 30s 防连接阶段静默 hang(网络静默断);不设总 timeout——reqwest 的
|
||||
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
|
||||
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用(warn 日志)。
|
||||
pub(crate) fn build_provider_client() -> Client {
|
||||
Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
|
||||
Client::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
||||
///
|
||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
|
||||
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
|
||||
/// 新增 provider 只改这一处。
|
||||
pub fn build_provider(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
) -> Box<dyn LlmProvider> {
|
||||
match provider_type {
|
||||
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
)),
|
||||
_ => Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 厂商模型列表拉取 — F-01 阶段3
|
||||
//!
|
||||
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
|
||||
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
//!
|
||||
//! 协议分派:
|
||||
//! - `openai_compat` → `GET {base_url}/v1/models`(Bearer 鉴权)
|
||||
//! - `anthropic_compat` → `GET {base_url}/v1/models`(x-api-key + anthropic-version 鉴权)
|
||||
//! - 其他 → `UnsupportedProvider` 错误
|
||||
//!
|
||||
//! 本模块仅含网络 IO 入口;URL 拼接 / 噪音过滤 / 响应反序列化等纯逻辑见
|
||||
//! [`model_fetch_helpers`]。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5
|
||||
//! (设计 §5.1 端点表 + §5.3 URL 智能拼接 + §5.4 噪音过滤)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use df_ai_core::model::ModelConfig;
|
||||
|
||||
use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
|
||||
use crate::model_probe::probe;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 公共入口
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// HTTP 请求超时(秒)。模型列表接口响应小、无需长超时。
|
||||
const FETCH_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// 拉取厂商模型列表,返回已过滤噪音的模型名 Vec。
|
||||
///
|
||||
/// 协议分派(见模块文档)。错误信息含 `provider_type` + HTTP 状态/原因,便于前端友好提示。
|
||||
///
|
||||
/// 不真实调测:本函数本身是网络 IO,单测只覆盖 URL 拼接 / 噪音过滤等纯逻辑;
|
||||
/// 集成验证留给阶段 5 的 IPC `ai_fetch_models`(用户在 Settings 点「测试连接」触发)。
|
||||
pub async fn fetch_model_names(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
match provider_type {
|
||||
"openai_compat" => fetch_openai_compat(base_url, api_key).await,
|
||||
"anthropic_compat" => fetch_anthropic_compat(base_url, api_key).await,
|
||||
other => Err(anyhow!(
|
||||
"拉取模型列表失败:不支持的 provider_type={other}(仅支持 openai_compat / anthropic_compat)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取模型列表 + 对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
///
|
||||
/// 探测为纯 CPU(无网络),不会因单模型探测失败中断整体 — 探测对任意输入都有兜底(见 model_probe)。
|
||||
pub async fn fetch_and_probe(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<ModelConfig>> {
|
||||
let names = fetch_model_names(provider_type, base_url, api_key).await?;
|
||||
Ok(names.into_iter().map(|n| probe(&n)).collect())
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 协议分派实现
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// OpenAI 兼容(GLM / DeepSeek / Kimi / 通义 / 中转站):`GET /v1/models`,Bearer 鉴权。
|
||||
async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let url = build_models_url(base_url);
|
||||
let client = build_client()?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| map_network_error("openai_compat", &url, e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(map_status_error("openai_compat", status, &url));
|
||||
}
|
||||
|
||||
// OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。
|
||||
let body: ModelsList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(body.into_ids()))
|
||||
}
|
||||
|
||||
/// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。
|
||||
async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let url = build_models_url(base_url);
|
||||
let client = build_client()?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| map_network_error("anthropic_compat", &url, e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(map_status_error("anthropic_compat", status, &url));
|
||||
}
|
||||
|
||||
// Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。
|
||||
// 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中,
|
||||
// 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。
|
||||
let body: ModelsList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?;
|
||||
|
||||
Ok(filter_chat_models(body.into_ids()))
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// HTTP 客户端 + 错误映射
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("构建 HTTP 客户端失败:{e}"))
|
||||
}
|
||||
|
||||
/// 网络错误(连接拒绝 / DNS / 超时)→ 友好提示,标注 provider_type + url 便于排查。
|
||||
fn map_network_error(provider_type: &str, url: &str, e: reqwest::Error) -> anyhow::Error {
|
||||
if e.is_timeout() {
|
||||
anyhow!("{provider_type} 拉取模型列表超时({url},>{FETCH_TIMEOUT_SECS}s)— 检查 base_url 可达性")
|
||||
} else {
|
||||
anyhow!("{provider_type} 拉取模型列表网络错误({url}):{e}")
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP 状态错误 → 按 401/403/404/5xx 分类友好提示(对齐设计 §5.5 失败降级)。
|
||||
fn map_status_error(provider_type: &str, status: reqwest::StatusCode, url: &str) -> anyhow::Error {
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
anyhow!("{provider_type} 鉴权失败({status}, {url})— 检查 api_key 是否正确 / 是否有该接口权限")
|
||||
} else if status.as_u16() == 404 {
|
||||
anyhow!("{provider_type} 端点不存在(404, {url})— 该厂商可能不支持模型列表拉取,请手动输入模型名")
|
||||
} else if status.is_server_error() {
|
||||
anyhow!("{provider_type} 厂商服务异常({status}, {url})— 稍后重试或手动输入模型名")
|
||||
} else {
|
||||
anyhow!("{provider_type} 拉取模型列表失败(HTTP {status}, {url})")
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试(协议分派:不支持类型直接报错,不触网)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── fetch_model_names 协议分派:不支持类型直接报错(不触网) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_unsupported_provider_errors_without_network() {
|
||||
// ollama / glm 等非 openai_compat / anthropic_compat 类型 → 直接错误,不发请求
|
||||
let err = fetch_model_names("ollama", "http://localhost:11434", "k")
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("ollama"), "err={msg}");
|
||||
assert!(msg.contains("provider_type"), "err={msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 厂商模型列表拉取 — 纯逻辑(无 IO)。
|
||||
//!
|
||||
//! 从 `model_fetch.rs` 抽离的纯函数 / 类型:URL 拼接、噪音过滤、响应反序列化。
|
||||
//! 这些逻辑无网络依赖,可独立单测;主文件 `model_fetch.rs` 保留有 IO 的拉取入口。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §5.3 / §5.4。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// URL 智能拼接
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 拼接模型列表端点 URL。
|
||||
///
|
||||
/// 规则(设计 §5.3,base_url 先去尾 `/`):
|
||||
/// - 以 `/chat/completions` 结尾 → 替换为 `/models`(用户把对话端点当 base_url 填了)
|
||||
/// - 以 `/v1` / `/v4` 结尾 → 直接补 `/models`(避免 `/v1/v1/models`)
|
||||
/// - 以 `/models` 结尾 → 原样用(用户已填完整端点)
|
||||
/// - 以 `/api` 结尾 → 补 `/models`(Ollama 风格 base,anthropic_compat 兜底用)
|
||||
/// - 其余 → 补 `/v1/models`(最常见:用户填 `https://open.bigmodel.cn/api/paas/v4`)
|
||||
pub fn build_models_url(base_url: &str) -> String {
|
||||
let url = base_url.trim_end_matches('/');
|
||||
if url.ends_with("/chat/completions") {
|
||||
url.replace("/chat/completions", "/models")
|
||||
} else if url.ends_with("/v1") || url.ends_with("/v4") {
|
||||
format!("{url}/models")
|
||||
} else if url.ends_with("/models") {
|
||||
url.to_string()
|
||||
} else if url.ends_with("/api") {
|
||||
format!("{url}/models")
|
||||
} else {
|
||||
format!("{url}/v1/models")
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 噪音过滤(剔除非 chat 模型)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 判断是否为非 chat 模型(应从列表中剔除)。
|
||||
///
|
||||
/// 规则(设计 §5.4 + 合理扩展,见 issues):
|
||||
/// - 图片生成:dall-e / midjourney / stable-diffusion / imagen
|
||||
/// - 语音:tts / whisper / audio / speech / voice(语音合成/识别)
|
||||
/// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions)
|
||||
/// - 转写:transcribe / transcription
|
||||
/// - 审查:moderation(内容审查模型)
|
||||
/// - 搜索:davinci-search / search(旧 GPT 搜索变种)
|
||||
///
|
||||
/// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding,
|
||||
/// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。
|
||||
pub fn is_non_chat_model(id: &str) -> bool {
|
||||
let id = id.to_lowercase();
|
||||
// 图片生成
|
||||
id.contains("dall-e")
|
||||
|| id.contains("midjourney")
|
||||
|| id.contains("stable-diffusion")
|
||||
|| id.contains("imagen")
|
||||
// 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice)
|
||||
|| id.contains("tts")
|
||||
|| id.contains("whisper")
|
||||
|| id.contains("audio")
|
||||
|| id.contains("speech")
|
||||
|| id.contains("voice")
|
||||
// 实时语音对话(OpenAI Realtime API,走独立协议非 chat completions)
|
||||
|| id.contains("realtime")
|
||||
// 转写
|
||||
|| id.contains("transcribe")
|
||||
|| id.contains("transcription")
|
||||
// 内容审查
|
||||
|| id.contains("moderation")
|
||||
// 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
|
||||
// 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
|
||||
|| id.contains("davinci-search")
|
||||
}
|
||||
|
||||
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
|
||||
pub fn filter_chat_models(ids: Vec<String>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() || is_non_chat_model(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(trimmed.to_string()) {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 响应反序列化
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 厂商 `/v1/models` 响应统一反序列化。
|
||||
///
|
||||
/// 兼容三种结构(用 `#[serde(alias)]`):
|
||||
/// - OpenAI / Anthropic:`{data:[{id, ...}]}`
|
||||
/// - Ollama:`{models:[{name, ...}]}`(anthropic_compat 走中转站时兜底)
|
||||
/// - 单字段别名:`id` / `name` 都认
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ModelsList {
|
||||
#[serde(default)]
|
||||
data: Vec<ModelEntry>,
|
||||
#[serde(default, alias = "models")]
|
||||
data_alt: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
#[serde(default, alias = "name")]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelsList {
|
||||
/// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。
|
||||
pub(crate) fn into_ids(self) -> Vec<String> {
|
||||
self.data
|
||||
.into_iter()
|
||||
.chain(self.data_alt)
|
||||
.filter_map(|e| e.id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试(纯逻辑/字符串处理,不真实调 API)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
|
||||
#[test]
|
||||
fn is_non_chat_model_keeps_search_augmented_chat() {
|
||||
// search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
|
||||
assert!(!is_non_chat_model("gpt-4o-search-preview"));
|
||||
assert!(!is_non_chat_model("qwen-search"));
|
||||
assert!(!is_non_chat_model("glm-4-search-preview"));
|
||||
// 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
|
||||
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||
}
|
||||
|
||||
// ── build_models_url:尾 / 处理 ──
|
||||
|
||||
#[test]
|
||||
fn build_url_strips_trailing_slash() {
|
||||
// 用户填 `https://api.x.com/v1/`(尾 /)→ 去 / 后命中 /v1 分支 → 补 /models
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
// 多个尾 /
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v4//"),
|
||||
"https://api.x.com/v4/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_v1_suffix_appends_models() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.openai.com/v1"),
|
||||
"https://api.openai.com/v1/models"
|
||||
);
|
||||
// 不应产生 /v1/v1/models
|
||||
assert!(!build_models_url("https://api.openai.com/v1").contains("/v1/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_v4_suffix_appends_models() {
|
||||
// GLM:`https://open.bigmodel.cn/api/paas/v4`
|
||||
assert_eq!(
|
||||
build_models_url("https://open.bigmodel.cn/api/paas/v4"),
|
||||
"https://open.bigmodel.cn/api/paas/v4/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_bare_base_appends_v1_models() {
|
||||
// 用户只填根:`https://api.deepseek.com` → 补 /v1/models
|
||||
assert_eq!(
|
||||
build_models_url("https://api.deepseek.com"),
|
||||
"https://api.deepseek.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_chat_completions_replaced() {
|
||||
// 用户误把对话端点当 base_url
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/chat/completions"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_already_models_kept() {
|
||||
// 用户已填完整端点 → 原样用
|
||||
assert_eq!(
|
||||
build_models_url("https://api.x.com/v1/models"),
|
||||
"https://api.x.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_url_api_suffix_ollama_style() {
|
||||
// Ollama 风格 base(anthropic_compat 兜底路径)
|
||||
assert_eq!(
|
||||
build_models_url("http://localhost:11434/api"),
|
||||
"http://localhost:11434/api/models"
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_non_chat_model:噪音过滤规则 ──
|
||||
|
||||
#[test]
|
||||
fn non_chat_image_generation_filtered() {
|
||||
assert!(is_non_chat_model("dall-e-3"));
|
||||
assert!(is_non_chat_model("dall-e-2"));
|
||||
assert!(is_non_chat_model("midjourney-v6"));
|
||||
assert!(is_non_chat_model("stable-diffusion-xl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_speech_filtered() {
|
||||
assert!(is_non_chat_model("tts-1"));
|
||||
assert!(is_non_chat_model("tts-1-hd"));
|
||||
assert!(is_non_chat_model("whisper-1"));
|
||||
assert!(is_non_chat_model("audio-transcribe"));
|
||||
assert!(is_non_chat_model("speech-synth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_realtime_filtered() {
|
||||
assert!(is_non_chat_model("gpt-4o-realtime"));
|
||||
assert!(is_non_chat_model("gpt-4o-mini-realtime-preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_moderation_filtered() {
|
||||
assert!(is_non_chat_model("text-moderation-stable"));
|
||||
assert!(is_non_chat_model("omni-moderation-latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chat_search_variants_filtered() {
|
||||
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_models_kept() {
|
||||
// 主流 chat 模型不应被误剔
|
||||
for ok in [
|
||||
"glm-4",
|
||||
"glm-4-flash",
|
||||
"glm-4v",
|
||||
"glm-4-air",
|
||||
"glm-4-plus",
|
||||
"deepseek-chat",
|
||||
"deepseek-coder",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"qwen-plus",
|
||||
"kimi-k2",
|
||||
] {
|
||||
assert!(!is_non_chat_model(ok), "{ok} 不应被当噪音剔除");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_kept_per_design() {
|
||||
// 设计 §5.4 明确「embedding 保留(知识库需要)」
|
||||
for emb in ["embedding-3", "text-embedding-3-large", "bge-m3"] {
|
||||
assert!(!is_non_chat_model(emb), "{emb} 按 §5.4 应保留");
|
||||
}
|
||||
}
|
||||
|
||||
// ── filter_chat_models:过滤 + 去重 + 去空 ──
|
||||
|
||||
#[test]
|
||||
fn filter_removes_noise_and_keeps_chat() {
|
||||
let raw = vec![
|
||||
"glm-4-flash".to_string(),
|
||||
"dall-e-3".to_string(), // 噪音
|
||||
"tts-1".to_string(), // 噪音
|
||||
"whisper-1".to_string(), // 噪音
|
||||
"text-moderation-stable".to_string(), // 噪音
|
||||
"glm-4v".to_string(),
|
||||
"gpt-4o".to_string(),
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4-flash", "glm-4v", "gpt-4o"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dedupes() {
|
||||
// 中转站可能返回重复模型名
|
||||
let raw = vec![
|
||||
"glm-4".to_string(),
|
||||
"glm-4".to_string(),
|
||||
"glm-4-flash".to_string(),
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4", "glm-4-flash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_drops_empty_and_whitespace_only() {
|
||||
let raw = vec![
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"glm-4".to_string(),
|
||||
" glm-4v ".to_string(), // 带空格 → trim 后保留
|
||||
];
|
||||
let got = filter_chat_models(raw);
|
||||
assert_eq!(got, vec!["glm-4", "glm-4v"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_empty_input() {
|
||||
assert!(filter_chat_models(vec![]).is_empty());
|
||||
}
|
||||
|
||||
// ── ModelsList 反序列化:兼容 OpenAI / Anthropic / Ollama 三种结构 ──
|
||||
|
||||
#[test]
|
||||
fn parse_openai_format() {
|
||||
// OpenAI:`{data:[{id, ...}]}`
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "gpt-4o", "owned_by": "openai"},
|
||||
{"id": "gpt-4o-mini", "owned_by": "openai"}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_anthropic_format() {
|
||||
// Anthropic:`{data:[{id, display_name, type}], has_more:false}`
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "type": "model"},
|
||||
{"id": "claude-3-haiku-20240307", "display_name": "Claude 3 Haiku", "type": "model"}
|
||||
],
|
||||
"has_more": false,
|
||||
"first_id": "claude-3-5-sonnet-20241022",
|
||||
"last_id": "claude-3-haiku-20240307"
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_format_via_alias() {
|
||||
// Ollama:`{models:[{name, size}]}` — 走 anthropic_compat 中转站兜底
|
||||
let json = r#"{
|
||||
"models": [
|
||||
{"name": "llama3:8b", "size": 4661214673},
|
||||
{"name": "qwen2:7b", "size": 4436122400}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_data_field_yields_empty() {
|
||||
// 厂商返回 `{}` 或缺 data 字段 → 空列表,不 panic
|
||||
let json = r#"{}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
assert!(m.into_ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entry_missing_id_skipped() {
|
||||
// data 数组项缺 id → 该项跳过
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{"id": "glm-4"},
|
||||
{"owned_by": "x"},
|
||||
{"id": "glm-4v"}
|
||||
]
|
||||
}"#;
|
||||
let m: ModelsList = serde_json::from_str(json).unwrap();
|
||||
let ids = m.into_ids();
|
||||
assert_eq!(ids, vec!["glm-4", "glm-4v"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! 模型探测器 — F-01 阶段2
|
||||
//!
|
||||
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
||||
//!
|
||||
//! 多源探测,高优先源命中即返(短路):
|
||||
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
|
||||
//! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable`
|
||||
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
|
||||
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
||||
//! (设计文档 §4.1 写的是"启发式先行 + 预设表合并";本实施按阶段 2 任务规格
|
||||
//! 收敛为"预设优先 > 启发式"链 — 预设表精确数据可信度高于命名猜测,优先短路)。
|
||||
|
||||
use df_ai_core::model::{ModelConfig, ProbeSource};
|
||||
|
||||
// 纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定)抽离至此 —
|
||||
// private mod,crate 内复用,外部经 model_probe::probe 间接访问(对齐 model_fetch_helpers)。
|
||||
use crate::model_probe_helpers::{heuristic_infer, presets};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 公共入口
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 探测单个模型,返回完整 `ModelConfig`(已填充 `probe_source`)。
|
||||
///
|
||||
/// 多源探测顺序(高优先源命中即返):
|
||||
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
|
||||
/// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感)
|
||||
/// 3. 启发式推断(模型名命名模式)
|
||||
/// 4. 默认值兜底
|
||||
///
|
||||
/// 返回的 `ModelConfig.probe_source` 标注实际命中来源。
|
||||
pub fn probe(model_id: &str) -> ModelConfig {
|
||||
// 1. 预设表精确匹配
|
||||
if let Some(mut hit) = presets().iter().find(|m| m.model_id == model_id).cloned() {
|
||||
hit.probe_source = Some(ProbeSource::PresetTable);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 2. 预设表模糊匹配(双向子串包含,大小写不敏感)
|
||||
// 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。
|
||||
let needle = model_id.to_lowercase();
|
||||
let fuzzy = presets()
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let cand = m.model_id.to_lowercase();
|
||||
!cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand))
|
||||
})
|
||||
.max_by_key(|m| m.model_id.len());
|
||||
|
||||
if let Some(mut hit) = fuzzy.cloned() {
|
||||
// 模糊命中:保留预设维度,但用入参的实际模型名覆盖 model_id(避免回写错名)
|
||||
hit.model_id = model_id.to_string();
|
||||
hit.probe_source = Some(ProbeSource::PresetTable);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// 3. 启发式推断
|
||||
let mut heuristic = heuristic_infer(model_id);
|
||||
heuristic.probe_source = Some(ProbeSource::Heuristic);
|
||||
heuristic
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 测试
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ProbeSource};
|
||||
|
||||
// ── 预设表加载 ──
|
||||
|
||||
#[test]
|
||||
fn presets_loaded_and_nonempty() {
|
||||
let p = presets();
|
||||
assert!(!p.is_empty(), "预设表应非空");
|
||||
// 确保每个预设都有 model_id
|
||||
assert!(p.iter().all(|m| !m.model_id.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_contain_expected_models() {
|
||||
let p = presets();
|
||||
let ids: Vec<&str> = p.iter().map(|m| m.model_id.as_str()).collect();
|
||||
assert!(ids.contains(&"glm-4"), "glm-4 应在预设表: {ids:?}");
|
||||
assert!(ids.contains(&"glm-4-flash"));
|
||||
assert!(ids.contains(&"glm-4v"));
|
||||
assert!(ids.contains(&"gpt-4o"));
|
||||
assert!(ids.contains(&"claude-3-5-sonnet-20241022"));
|
||||
}
|
||||
|
||||
// ── 预设精确匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_glm4() {
|
||||
let m = probe("glm-4");
|
||||
assert_eq!(m.model_id, "glm-4");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_glm4v_has_vision() {
|
||||
let m = probe("glm-4v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_deepseek_coder_has_codegen() {
|
||||
let m = probe("deepseek-coder");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_preset_exact_match_embedding_no_text_modality() {
|
||||
let m = probe("embedding-3");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(
|
||||
!m.modalities.contains(&Modality::Text),
|
||||
"embedding 模型不应有 Text 模态"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 预设模糊匹配 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_fuzzy_match_glm4v_variant() {
|
||||
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中
|
||||
let m = probe("glm-4v-x");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.model_id, "glm-4v-x", "模糊命中后 model_id 应用入参名");
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"应继承 glm-4v 的 vision 模态"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 启发式:Vision ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_v_suffix() {
|
||||
// glm-4v 不走启发式(精确命中预设);用未知名验证启发式
|
||||
let m = probe("custom-model-v");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(
|
||||
m.modalities.contains(&Modality::Vision),
|
||||
"v 后缀应推断 Vision"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_vl_token() {
|
||||
let m = probe("qwen-vl-unknown");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_vision_from_vision_word() {
|
||||
let m = probe("some-vision-7b");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.modalities.contains(&Modality::Vision));
|
||||
}
|
||||
|
||||
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_flash_keeps_neutral_tier() {
|
||||
let m = probe("unknown-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
// B-260618-04:cost/intel 一律中性,不靠名字猜
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_mini_keeps_neutral_tier() {
|
||||
let m = probe("test-mini-model");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:CodeGen ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_codegen_from_code() {
|
||||
let m = probe("acme-code-7b");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
// 对话模型仍应保留 ToolUse
|
||||
assert!(m.capabilities.contains(&Capability::ToolUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_codegen_from_coder() {
|
||||
let m = probe("acme-coder");
|
||||
assert!(m.capabilities.contains(&Capability::CodeGen));
|
||||
}
|
||||
|
||||
// ── 启发式:Embedding(特殊路径:无 Text 模态) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_embedding_no_text_modality() {
|
||||
let m = probe("acme-embedding-v3");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(!m.modalities.contains(&Modality::Text));
|
||||
assert!(!m.capabilities.contains(&Capability::ToolUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_embedding_from_text_embedding_prefix() {
|
||||
let m = probe("text-embedding-3-large");
|
||||
assert!(m.capabilities.contains(&Capability::Embedding));
|
||||
assert!(m.modalities.is_empty());
|
||||
}
|
||||
|
||||
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_pro_keeps_neutral_tier() {
|
||||
let m = probe("acme-pro");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_opus_keeps_neutral_tier() {
|
||||
let m = probe("acme-opus");
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_4o_keeps_neutral_cost() {
|
||||
let m = probe("acme-4o");
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
|
||||
|
||||
#[test]
|
||||
fn heuristic_default_for_unknown_name() {
|
||||
let m = probe("acme-unknown-model");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
assert_eq!(m.capabilities, vec![Capability::ToolUse]);
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
|
||||
// ── 优先级链:预设精确 > 启发式 ──
|
||||
|
||||
#[test]
|
||||
fn probe_preset_beats_heuristic() {
|
||||
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
|
||||
// 此处仅校验 source 标注为 PresetTable
|
||||
let m = probe("glm-4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_priority_chain_returns_config() {
|
||||
// 任何名字都应返回完整 ModelConfig,不 panic
|
||||
for name in ["glm-4", "unknown-xyz", "qwen-vl", "acme-embedding", "gpt-5"] {
|
||||
let m = probe(name);
|
||||
assert_eq!(m.model_id, name);
|
||||
assert!(
|
||||
m.probe_source.is_some(),
|
||||
"probe_source 应被填充: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
|
||||
//!
|
||||
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
|
||||
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
|
||||
//! 对齐 `model_fetch_helpers` 拆分先例(private mod,外部零直接路径需求)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use df_ai_core::model::{
|
||||
Capability, CostTier, IntelligenceTier, Modality, ModelConfig,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 预设表:编译期嵌入(include_str! 相对 crate 根),零运行时文件依赖
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 预设表原始 JSON(编译期从 `crates/df-ai/presets/models.json` 嵌入)。
|
||||
pub(super) const PRESETS_JSON: &str = include_str!("../presets/models.json");
|
||||
|
||||
/// 解析后的预设表(进程内单例,首次访问惰性解析一次)。
|
||||
pub(super) fn presets() -> &'static [ModelConfig] {
|
||||
static PRESETS: OnceLock<Vec<ModelConfig>> = OnceLock::new();
|
||||
PRESETS.get_or_init(|| {
|
||||
// include_str! 内容由仓库控制,解析失败属编译期/仓库错误,panic 合理。
|
||||
serde_json::from_str::<Vec<ModelConfig>>(PRESETS_JSON)
|
||||
.expect("presets/models.json 解析失败 — 检查 JSON 格式与 ModelConfig serde 映射")
|
||||
})
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 启发式推断(模型名命名模式 → 4 维度)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
|
||||
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
|
||||
///
|
||||
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
|
||||
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
|
||||
///
|
||||
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
|
||||
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
|
||||
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
|
||||
/// - `code`/`coder` → capabilities 加 CodeGen
|
||||
/// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
|
||||
pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
let name = model_id.to_lowercase();
|
||||
let mut modalities: Vec<Modality> = Vec::new();
|
||||
let mut capabilities: Vec<Capability> = Vec::new();
|
||||
// 中性默认:不靠模型名猜档位(B-260618-04)
|
||||
let cost_tier = CostTier::Medium;
|
||||
let intelligence = IntelligenceTier::Standard;
|
||||
|
||||
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
|
||||
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
|
||||
let is_embedding = name.contains("embed")
|
||||
|| name.contains("embedding")
|
||||
|| name.starts_with("e-")
|
||||
|| name.starts_with("embedding-")
|
||||
|| name.starts_with("text-embedding");
|
||||
|
||||
if is_embedding {
|
||||
capabilities.push(Capability::Embedding);
|
||||
// embedding 模型不走对话模态,modalities 留空(设计 §4.4 embedding-3 例:modalities=[])
|
||||
return ModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities,
|
||||
capabilities,
|
||||
cost_tier,
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
probe_source: None,
|
||||
};
|
||||
}
|
||||
|
||||
// —— 对话模型默认 Text 模态 ——
|
||||
modalities.push(Modality::Text);
|
||||
|
||||
// —— Vision 词素:含 "v"(独立词素:glm-4v / qwen-vl)、"vision"、"vl" ——
|
||||
// 注:裸 "v" 子串误伤大(如 "review"),故仅在边界词素命中:
|
||||
// 以 "v" 结尾、含 "-v" / "v-" 分隔、含 "vl" / "vision"
|
||||
if has_vision_token(&name) {
|
||||
modalities.push(Modality::Vision);
|
||||
}
|
||||
|
||||
// —— CodeGen:含 code / coder ——
|
||||
if name.contains("code") || name.contains("coder") {
|
||||
capabilities.push(Capability::CodeGen);
|
||||
}
|
||||
|
||||
// —— 对话模型默认 ToolUse(主流支持 function calling) ——
|
||||
capabilities.push(Capability::ToolUse);
|
||||
|
||||
ModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities,
|
||||
capabilities,
|
||||
cost_tier,
|
||||
intelligence,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
|
||||
|
||||
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
|
||||
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
|
||||
pub(super) fn has_vision_token(name: &str) -> bool {
|
||||
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
|
||||
}
|
||||
+498
-226
@@ -3,122 +3,30 @@
|
||||
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
|
||||
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
|
||||
TokenUsage, ToolCall, ToolCallDelta,
|
||||
CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall,
|
||||
};
|
||||
// ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。
|
||||
#[cfg(test)]
|
||||
use crate::provider::ChatMessage;
|
||||
use crate::retry::{
|
||||
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OpenAiMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponse {
|
||||
choices: Vec<OpenAiChoice>,
|
||||
model: String,
|
||||
usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiChoice {
|
||||
message: OpenAiMessageResp,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiMessageResp {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiToolCallResp {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
call_type: String,
|
||||
function: OpenAiFunctionResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiFunctionResp {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiUsage {
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
total_tokens: u32,
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChunk {
|
||||
choices: Vec<OpenAiStreamChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChoice {
|
||||
delta: OpenAiStreamDelta,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamDelta {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamToolCall {
|
||||
index: u32,
|
||||
id: Option<String>,
|
||||
function: Option<OpenAiStreamFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamFunction {
|
||||
name: Option<String>,
|
||||
arguments: Option<String>,
|
||||
}
|
||||
// 协议数据结构(OpenAiRequest / OpenAiResponse / OpenAiStreamChunk …)与 SSE 解析纯函数
|
||||
// apply_openai_sse 抽离到 openai_helpers.rs,结构对齐 anthropic_helpers.rs。
|
||||
// Rust impl 块不可跨文件,Provider struct + impl 仍留本文件。
|
||||
use crate::openai_helpers::{
|
||||
apply_openai_sse, OpenAiMessage, OpenAiRequest, OpenAiResponse, OpenAiToolCallResp,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI Compat Provider
|
||||
@@ -139,16 +47,9 @@ impl OpenAICompatProvider {
|
||||
/// - `api_key`: API 密钥
|
||||
/// - `default_model`: 默认模型名称
|
||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
|
||||
// connect_timeout 防连接阶段无限 hang(网络静默断);不设总 timeout——
|
||||
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍。
|
||||
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。
|
||||
let client = Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout): {}", e);
|
||||
Client::new()
|
||||
});
|
||||
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client(与 Anthropic 共用 DRY)。
|
||||
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client)。
|
||||
let client = crate::build_provider_client();
|
||||
Self {
|
||||
client,
|
||||
api_key: api_key.into(),
|
||||
@@ -184,6 +85,18 @@ impl OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 embeddings API URL(与 chat_url 同套智能拼接规则)
|
||||
fn embed_url(&self) -> String {
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
if base.ends_with("/embeddings") {
|
||||
return base.to_string();
|
||||
}
|
||||
if Self::ends_with_version(base) {
|
||||
return format!("{}/embeddings", base);
|
||||
}
|
||||
format!("{}/v1/embeddings", base)
|
||||
}
|
||||
|
||||
/// 将通用请求转换为 OpenAI 格式
|
||||
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
|
||||
let model = if req.model.is_empty() {
|
||||
@@ -192,7 +105,7 @@ impl OpenAICompatProvider {
|
||||
req.model
|
||||
};
|
||||
|
||||
let messages: Vec<OpenAiMessage> = req
|
||||
let mut messages: Vec<OpenAiMessage> = req
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
@@ -202,6 +115,43 @@ impl OpenAICompatProvider {
|
||||
crate::provider::MessageRole::Assistant => "assistant",
|
||||
crate::provider::MessageRole::Tool => "tool",
|
||||
};
|
||||
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
||||
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
||||
let content = if m.has_image() {
|
||||
let parts: Vec<serde_json::Value> = m
|
||||
.flattened_parts()
|
||||
.into_iter()
|
||||
.map(|p| match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
let final_url = match (base64, url, media_type) {
|
||||
(Some(b), _, Some(mt)) => {
|
||||
format!("data:{};base64,{}", mt, b)
|
||||
}
|
||||
(None, Some(u), _) => u,
|
||||
// 完整性兜底:当前 image_base64 构造器强制 media_type:Some,
|
||||
// image_url 构造器提供 url:Some,二者分别命中上两个分支;
|
||||
// 此分支仅在 parts 来源被外部直接构造且字段均缺时才可达
|
||||
//(如 url:None+base64:None 或 base64:Some+media_type:None)。
|
||||
// 退化为空串(OpenAI 对空 image_url.url 会 400),
|
||||
// 由调用方保证 parts 合法性,provider 层不做静默伪造。
|
||||
_ => String::new(),
|
||||
};
|
||||
serde_json::json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": final_url },
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Array(parts)
|
||||
} else {
|
||||
serde_json::Value::String(m.content.clone())
|
||||
};
|
||||
let tool_calls = m.tool_calls.map(|calls| {
|
||||
calls
|
||||
.into_iter()
|
||||
@@ -219,13 +169,20 @@ impl OpenAICompatProvider {
|
||||
});
|
||||
OpenAiMessage {
|
||||
role: role.to_string(),
|
||||
content: m.content,
|
||||
content,
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_calls,
|
||||
reasoning_content: m.reasoning_content,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
|
||||
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
@@ -240,9 +197,46 @@ impl OpenAICompatProvider {
|
||||
stream: req.stream,
|
||||
tools,
|
||||
tool_choice: req.tool_choice,
|
||||
reasoning_content: req.reasoning_content,
|
||||
// 流式请求末 chunk 带 usage(同步调用 complete 不需要)
|
||||
stream_options: if req.stream {
|
||||
Some(serde_json::json!({ "include_usage": true }))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -254,6 +248,34 @@ impl OpenAICompatProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenAICompatProvider {
|
||||
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
|
||||
async fn embed(&self, model: &str, texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EmbedData { embedding: Vec<f32>, index: usize }
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EmbedResponse { data: Vec<EmbedData> }
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.embed_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({ "model": model, "input": texts }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Embedding API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
let mut body: EmbedResponse = resp.json().await?;
|
||||
// 按 index 排序保证与输入顺序一致(API 不保证返回顺序)
|
||||
body.data.sort_by_key(|d| d.index);
|
||||
Ok(body.data.into_iter().map(|d| d.embedding).collect())
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> {
|
||||
let mut req = request;
|
||||
req.stream = false;
|
||||
@@ -261,48 +283,76 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 同步调用");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
error!(%status, %body, "LLM API 调用失败");
|
||||
anyhow::bail!("LLM API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
let body: OpenAiResponse = resp.json().await?;
|
||||
let choice = body
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("LLM 响应无 choices"))?;
|
||||
|
||||
let text = choice.message.content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
|
||||
|
||||
let usage = body.usage.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
}).unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
|
||||
Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 由 retry_with_backoff 内部 30s 总预算主动止损。
|
||||
let label = format!("OpenAI[{}]", openai_req.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
let url = self.chat_url();
|
||||
let api_key = self.api_key.clone();
|
||||
let openai_req = openai_req.clone();
|
||||
async move {
|
||||
// send
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration::from_secs(60))
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if is_reqwest_error_retryable(&e) {
|
||||
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
||||
}
|
||||
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
|
||||
}
|
||||
};
|
||||
// 状态码判定
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = format!("LLM API 错误 {}: {}", status, body);
|
||||
if is_status_retryable(status) {
|
||||
warn!(%status, "OpenAI 同步调用可重试状态码");
|
||||
return AttemptOutcome::Retryable(msg);
|
||||
}
|
||||
error!(%status, %body, "LLM API 调用失败(不可重试)");
|
||||
return AttemptOutcome::Fatal(msg);
|
||||
}
|
||||
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
|
||||
let body: OpenAiResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
|
||||
};
|
||||
let choice = match body.choices.into_iter().next() {
|
||||
Some(c) => c,
|
||||
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
|
||||
};
|
||||
let text = choice.message.content.unwrap_or_default();
|
||||
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
|
||||
let usage = body.usage.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
}).unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
AttemptOutcome::Ok(CompletionResponse {
|
||||
text,
|
||||
model: body.model,
|
||||
usage,
|
||||
tool_calls,
|
||||
reasoning_content: choice.message.reasoning_content,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {
|
||||
@@ -328,68 +378,23 @@ 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。
|
||||
let mut last_usage: Option<TokenUsage> = None;
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束
|
||||
if event.data == "[DONE]" {
|
||||
return Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
|
||||
Ok(chunk) => {
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
let delta_text = choice.delta.content.unwrap_or_default();
|
||||
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
|
||||
let finished = choice.finish_reason.as_deref() == Some("stop")
|
||||
|| choice.finish_reason.as_deref() == Some("tool_calls")
|
||||
|| choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
tcs.into_iter()
|
||||
.map(|tc| ToolCallDelta {
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(StreamChunk {
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
})
|
||||
} else {
|
||||
Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, event.data);
|
||||
Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("SSE 流错误: {}", e);
|
||||
Err(anyhow::anyhow!("SSE 流错误: {}", e))
|
||||
}
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
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))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -400,11 +405,278 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
&self.default_model
|
||||
}
|
||||
|
||||
fn supported_features(&self) -> ProviderFeatures {
|
||||
ProviderFeatures {
|
||||
streaming: true,
|
||||
function_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
fn endpoint(&self) -> String {
|
||||
self.chat_url()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 辅助:构造普通文本 delta chunk 的 SSE data
|
||||
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
|
||||
let fr = match finish_reason {
|
||||
Some(r) => format!(", \"finish_reason\": \"{}\"", r),
|
||||
None => String::from(", \"finish_reason\": null"),
|
||||
};
|
||||
format!(
|
||||
r#"{{"choices":[{{"delta":{{"content":"{}"}}{}}}]}}"#,
|
||||
content, fr
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造带 usage 的 chunk(choices 为空 → usage-only 末 chunk,对应 include_usage)
|
||||
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
|
||||
format!(
|
||||
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
|
||||
prompt,
|
||||
completion,
|
||||
prompt + completion
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造既有 content 又带 usage 的末段 chunk(部分兼容端点会把 usage 挂到正常末 chunk 上)
|
||||
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
|
||||
format!(
|
||||
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
|
||||
content,
|
||||
finish_reason,
|
||||
prompt,
|
||||
completion,
|
||||
prompt + completion
|
||||
)
|
||||
}
|
||||
|
||||
/// 多 chunk 文本流后,末 chunk 携带 usage(include_usage 覆盖语义)
|
||||
#[test]
|
||||
fn openai_sse_multi_chunk_with_terminal_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
|
||||
// 1) 首个文本增量,无 usage
|
||||
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
|
||||
assert_eq!(c.delta, "Hello");
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none());
|
||||
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
|
||||
|
||||
// 2) 第二个文本增量
|
||||
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
|
||||
assert_eq!(c.delta, " world");
|
||||
assert!(!c.finished);
|
||||
assert!(acc.is_none());
|
||||
|
||||
// 3) 末段正常 chunk 带 finish_reason=stop(仍是文本 delta,不带 usage)
|
||||
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
|
||||
assert!(c.finished);
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
|
||||
|
||||
// 4) usage-only chunk(choices=[])携带累计 usage → 覆盖累加器
|
||||
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none(), "非 [DONE] chunk 不带出 usage");
|
||||
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
|
||||
assert_eq!(acc.prompt_tokens, 12);
|
||||
assert_eq!(acc.completion_tokens, 34);
|
||||
assert_eq!(acc.total_tokens, 46);
|
||||
}
|
||||
|
||||
/// usage 挂在正常末段 chunk(含 finish_reason)上,而非独立 usage-only chunk
|
||||
#[test]
|
||||
fn openai_sse_usage_on_terminal_text_chunk() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
|
||||
assert!(c.finished);
|
||||
assert!(c.usage.is_none(), "非 [DONE] 不带出 usage,仅覆盖累加器");
|
||||
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
|
||||
assert_eq!(acc.prompt_tokens, 100);
|
||||
assert_eq!(acc.completion_tokens, 200);
|
||||
assert_eq!(acc.total_tokens, 300);
|
||||
}
|
||||
|
||||
/// [DONE] 时 take() 带出累积 usage,且取走后累加器清空
|
||||
#[test]
|
||||
fn openai_sse_done_takes_accumulated_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_openai_sse(&text_chunk("x", None), &mut acc);
|
||||
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
|
||||
|
||||
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||
assert!(c.finished);
|
||||
let u = c.usage.expect("[DONE] 应带出累积 usage");
|
||||
assert_eq!(u.prompt_tokens, 5);
|
||||
assert_eq!(u.completion_tokens, 7);
|
||||
assert_eq!(u.total_tokens, 12);
|
||||
assert!(acc.is_none(), "take() 后累加器应清空");
|
||||
}
|
||||
|
||||
/// 无 usage 的流:[DONE] 时 usage 字段为 None
|
||||
#[test]
|
||||
fn openai_sse_done_without_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_openai_sse(&text_chunk("hi", None), &mut acc);
|
||||
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||
assert!(c.finished);
|
||||
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
/// 后续 usage chunk 覆盖先前 usage(多轮 / 重发场景)
|
||||
#[test]
|
||||
fn openai_sse_later_usage_overrides_earlier() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
|
||||
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
|
||||
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||
let u = c.usage.unwrap();
|
||||
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
|
||||
assert_eq!(u.completion_tokens, 60);
|
||||
assert_eq!(u.total_tokens, 110);
|
||||
}
|
||||
|
||||
/// finish_reason=length(max_tokens 截断)按正常终止处理
|
||||
#[test]
|
||||
fn openai_sse_length_finish_reason_treated_as_finished() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
|
||||
assert!(c.finished, "length 应视为正常终止");
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
/// 非法 JSON data → 返回空 chunk,不 panic、不改累加器
|
||||
#[test]
|
||||
fn openai_sse_malformed_json_yields_empty_chunk() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_openai_sse("not a json", &mut acc);
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none());
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
/// tool_calls 增量解析
|
||||
#[test]
|
||||
fn openai_sse_tool_call_delta() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
|
||||
let c = apply_openai_sse(data, &mut acc);
|
||||
assert!(acc.is_none());
|
||||
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
|
||||
assert_eq!(tcs.len(), 1);
|
||||
assert_eq!(tcs[0].index, 0);
|
||||
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
|
||||
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
|
||||
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
|
||||
#[test]
|
||||
fn openai_convert_multimodal_content() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![ChatMessage::user_parts(
|
||||
"看图",
|
||||
vec![
|
||||
crate::provider::ContentPart::image_base64("image/png", "iVBOR"),
|
||||
crate::provider::ContentPart::image_url("https://x/a.png"),
|
||||
],
|
||||
)],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
let msg = &out.messages[0];
|
||||
// content 是数组:[text "看图", image_url(data URI), image_url(http url)]
|
||||
let arr = msg.content.as_array().expect("含图 → content 数组");
|
||||
assert_eq!(arr.len(), 3);
|
||||
assert_eq!(arr[0]["type"], "text");
|
||||
assert_eq!(arr[0]["text"], "看图");
|
||||
assert_eq!(arr[1]["type"], "image_url");
|
||||
assert_eq!(
|
||||
arr[1]["image_url"]["url"],
|
||||
"data:image/png;base64,iVBOR"
|
||||
);
|
||||
assert_eq!(arr[2]["image_url"]["url"], "https://x/a.png");
|
||||
}
|
||||
|
||||
/// 纯文本消息 → content 仍是字符串简写(无图不数组化,对齐纯文本端点兼容)
|
||||
#[test]
|
||||
fn openai_convert_text_only_remains_string() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! OpenAI 兼容协议适配 — 协议数据结构与 SSE 事件解析(纯函数)。
|
||||
//!
|
||||
//! 本模块从 `openai_compat.rs` 抽离,承载与 HTTP 无关的纯协议逻辑:
|
||||
//! - 请求/响应结构体(`OpenAiRequest` / `OpenAiResponse` / `OpenAiStreamChunk` 等)
|
||||
//! - SSE 事件 data → `StreamChunk` 转换纯函数(`apply_openai_sse`)
|
||||
//!
|
||||
//! Provider struct + impl(含 HTTP 调用 / `convert_request` / `chat_url`)仍留在
|
||||
//! `openai_compat.rs`,Rust impl 块不可跨文件,故仅搬迁 impl 块外部的类型/纯函数。
|
||||
//! 零行为变更(纯搬迁)。结构对齐 `anthropic_helpers.rs`。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta};
|
||||
|
||||
// ============================================================
|
||||
// OpenAI API 请求/响应结构体
|
||||
// ============================================================
|
||||
|
||||
/// OpenAI 兼容请求体
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct OpenAiRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
/// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<serde_json::Value>,
|
||||
/// DeepSeek thinking 模式
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
///
|
||||
/// `content` 为 `serde_json::Value`:纯文本消息走字符串简写(与老端点兼容),
|
||||
/// 含图消息走 `[{type:"text",text},{type:"image_url",image_url:{url}}]` 数组。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenAiMessage {
|
||||
pub role: String,
|
||||
pub content: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<serde_json::Value>>,
|
||||
/// DeepSeek thinking 模式推理内容
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 同步响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiResponse {
|
||||
pub choices: Vec<OpenAiChoice>,
|
||||
pub model: String,
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiChoice {
|
||||
pub message: OpenAiMessageResp,
|
||||
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiMessageResp {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiToolCallResp>>,
|
||||
/// DeepSeek thinking 模式响应中的推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiToolCallResp {
|
||||
pub id: String,
|
||||
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: OpenAiFunctionResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiFunctionResp {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// SSE 流式响应 chunk
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChunk {
|
||||
pub choices: Vec<OpenAiStreamChoice>,
|
||||
/// 末 chunk(choices 为空)携带的累计 usage
|
||||
#[serde(default)]
|
||||
pub usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamChoice {
|
||||
pub delta: OpenAiStreamDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamDelta {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<OpenAiStreamToolCall>>,
|
||||
/// DeepSeek thinking 模式流式推理内容
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamToolCall {
|
||||
pub index: u32,
|
||||
pub id: Option<String>,
|
||||
pub function: Option<OpenAiStreamFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OpenAiStreamFunction {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
|
||||
// ============================================================
|
||||
|
||||
/// 将一条 OpenAI 兼容 SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
|
||||
///
|
||||
/// - `[DONE]` → 返回 `finished=true` 的终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - 普通文本/工具增量 chunk → 返回对应 `StreamChunk`,usage 字段恒为 None(usage 仅在终态带出)。
|
||||
/// - usage(`stream_options.include_usage` 时末段或 usage-only chunk 携带)→ 覆盖累加器(覆盖语义保对)。
|
||||
/// - 解析失败 → 返回空 chunk(与原内联实现一致)。
|
||||
///
|
||||
/// 等价性:delta / tool_calls / finished / 解析失败等分支与原 stream() 闭包逐字一致;
|
||||
/// usage 透传([DONE] 终态 take() 带出、usage chunk 覆盖累加器)为本次新增能力,
|
||||
/// 对应 StreamChunk 新增的 usage 字段 + 请求体新增 stream_options.include_usage。
|
||||
pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束,带出累积 usage
|
||||
if data == "[DONE]" {
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(data) {
|
||||
Ok(chunk) => {
|
||||
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
|
||||
if let Some(u) = chunk.usage {
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
});
|
||||
}
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
let delta_text = choice.delta.content.unwrap_or_default();
|
||||
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
|
||||
let finished = choice.finish_reason.as_deref() == Some("stop")
|
||||
|| choice.finish_reason.as_deref() == Some("tool_calls")
|
||||
|| choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
tcs.into_iter()
|
||||
.map(|tc| ToolCallDelta {
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
StreamChunk {
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, data);
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
|
||||
//!
|
||||
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
|
||||
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
|
||||
//!
|
||||
//! ## 定位
|
||||
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
|
||||
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
|
||||
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
|
||||
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
|
||||
//!
|
||||
//! ## 纯函数边界(零 IO 零状态)
|
||||
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
|
||||
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
|
||||
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
|
||||
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
|
||||
//! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
|
||||
//! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
|
||||
//!
|
||||
//! ## 与 0.7 planner.rs 的关系
|
||||
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
|
||||
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
|
||||
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
|
||||
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
|
||||
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
|
||||
//!
|
||||
//! ## feature flag
|
||||
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
|
||||
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。
|
||||
|
||||
// ---- feature flag 占位 ------------------------------------------------------
|
||||
|
||||
/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
|
||||
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
|
||||
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
|
||||
pub const PLAN_HINT_ENABLED: bool = true;
|
||||
|
||||
// ---- 数据结构 --------------------------------------------------------------
|
||||
|
||||
/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
|
||||
///
|
||||
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
|
||||
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParallelGroup {
|
||||
/// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
|
||||
pub tools: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl ParallelGroup {
|
||||
/// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
|
||||
pub fn new(tools: Vec<&'static str>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
/// 组内工具数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.tools.len()
|
||||
}
|
||||
|
||||
/// 是否为空组。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tools.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
|
||||
///
|
||||
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
|
||||
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequentialDep {
|
||||
/// 前置工具(先执行)。
|
||||
pub from: &'static str,
|
||||
/// 后置工具(待 from 完成后执行)。
|
||||
pub to: &'static str,
|
||||
}
|
||||
|
||||
impl SequentialDep {
|
||||
/// 构造一条顺序依赖边。
|
||||
pub fn new(from: &'static str, to: &'static str) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
/// 编排提示(轻量 PlanHint)。
|
||||
///
|
||||
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
|
||||
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
|
||||
///
|
||||
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
|
||||
/// 1. `groups` 内各组 `tools` 非空;
|
||||
/// 2. `sequence` 不含自环(`from != to`);
|
||||
/// 3. `sequence` 无重复边(去重);
|
||||
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
|
||||
///
|
||||
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PlanHint {
|
||||
/// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
|
||||
pub groups: Vec<ParallelGroup>,
|
||||
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
|
||||
pub sequence: Vec<SequentialDep>,
|
||||
}
|
||||
|
||||
impl PlanHint {
|
||||
/// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 是否为空提示(无任何编排信息)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.groups.is_empty() && self.sequence.is_empty()
|
||||
}
|
||||
|
||||
/// 工具总数(所有并行组工具去重后的并集大小)。
|
||||
pub fn tool_count(&self) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for g in &self.groups {
|
||||
for t in &g.tools {
|
||||
seen.insert(*t);
|
||||
}
|
||||
}
|
||||
seen.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------
|
||||
|
||||
/// PlanHint 合法性校验结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HintValidity {
|
||||
/// 合法,可交付主 loop 调度。
|
||||
Ok,
|
||||
/// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
/// 校验 PlanHint 合法性(纯函数)。
|
||||
///
|
||||
/// 检查四项(见 `PlanHint` 文档约束):
|
||||
/// 1. 组内非空;
|
||||
/// 2. 无自环(`from != to`);
|
||||
/// 3. 无重复边;
|
||||
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
|
||||
/// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
|
||||
///
|
||||
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
|
||||
pub fn validate(hint: &PlanHint) -> HintValidity {
|
||||
// 1. 组内非空
|
||||
for g in &hint.groups {
|
||||
if g.tools.is_empty() {
|
||||
return HintValidity::Invalid("empty_parallel_group");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无自环
|
||||
for d in &hint.sequence {
|
||||
if d.from == d.to {
|
||||
return HintValidity::Invalid("self_loop_dep");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 无重复边(同 from→to 不重复)
|
||||
let mut seen_edges = std::collections::HashSet::new();
|
||||
for d in &hint.sequence {
|
||||
if !seen_edges.insert((d.from, d.to)) {
|
||||
return HintValidity::Invalid("duplicate_dep_edge");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kahn 拓扑判无环
|
||||
if has_cycle(&hint.sequence) {
|
||||
return HintValidity::Invalid("cyclic_dep");
|
||||
}
|
||||
|
||||
HintValidity::Ok
|
||||
}
|
||||
|
||||
/// Kahn 算法判有向边集合是否含环(纯函数)。
|
||||
///
|
||||
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
|
||||
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
|
||||
///
|
||||
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
|
||||
/// 最终已出队节点数 < 总节点数 → 存在环。
|
||||
fn has_cycle(edges: &[SequentialDep]) -> bool {
|
||||
if edges.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集所有节点(去重)。
|
||||
let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
|
||||
for d in edges {
|
||||
nodes.insert(d.from);
|
||||
nodes.insert(d.to);
|
||||
}
|
||||
let n = nodes.len();
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 节点 → 索引(便于用 Vec 计数)。
|
||||
let mut idx: std::collections::HashMap<&'static str, usize> =
|
||||
std::collections::HashMap::with_capacity(n);
|
||||
for (i, name) in nodes.iter().enumerate() {
|
||||
idx.insert(*name, i);
|
||||
}
|
||||
|
||||
// 入度表 + 邻接表。
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for d in edges {
|
||||
let u = idx[&d.from];
|
||||
let v = idx[&d.to];
|
||||
adj[u].push(v);
|
||||
indeg[v] += 1;
|
||||
}
|
||||
|
||||
// BFS from 入度 0 节点。
|
||||
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
|
||||
for (i, &d) in indeg.iter().enumerate() {
|
||||
if d == 0 {
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited = 0usize;
|
||||
while let Some(u) = queue.pop_front() {
|
||||
visited += 1;
|
||||
for &v in &adj[u] {
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push_back(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
|
||||
visited < n
|
||||
}
|
||||
|
||||
// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------
|
||||
|
||||
/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
|
||||
///
|
||||
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
|
||||
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
|
||||
///
|
||||
/// | 场景关键词 | 产出 |
|
||||
/// |---|---|
|
||||
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
|
||||
/// | grep/find 后改 | search_files → write_file/patch_file |
|
||||
/// | http + 写文件 | http_request → write_file(先取再存) |
|
||||
/// | 仅读多文件 | 全并行单组 |
|
||||
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
|
||||
///
|
||||
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
|
||||
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
|
||||
///
|
||||
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
|
||||
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
|
||||
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
|
||||
// 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
|
||||
if !PLAN_HINT_ENABLED {
|
||||
return PlanHint::empty();
|
||||
}
|
||||
|
||||
let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。
|
||||
|
||||
let ctx = context_keywords;
|
||||
let lower = ctx.to_lowercase();
|
||||
|
||||
// ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
|
||||
let is_search = lower.contains("grep")
|
||||
|| lower.contains("搜索")
|
||||
|| lower.contains("查找")
|
||||
|| lower.contains("find ");
|
||||
let is_modify = lower.contains("修改")
|
||||
|| lower.contains("改")
|
||||
|| lower.contains("write")
|
||||
|| lower.contains("patch")
|
||||
|| lower.contains("写入")
|
||||
|| lower.contains("重命名")
|
||||
|| lower.contains("rename");
|
||||
if is_search && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["search_files"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("search_files", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
|
||||
let is_http = lower.contains("http")
|
||||
|| lower.contains("api")
|
||||
|| lower.contains("请求")
|
||||
|| lower.contains("fetch");
|
||||
if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["http_request"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("http_request", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 规则 3:读多文件(无写) → 全并行单组 ----
|
||||
let read_count = [
|
||||
("read_file", lower.matches("read").count() + lower.matches("读取").count()),
|
||||
("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, c)| *c >= 1)
|
||||
.count();
|
||||
if read_count >= 2 && !is_modify {
|
||||
// 多种读工具信号 → 单并行组(可同时调度)
|
||||
let mut tools: Vec<&'static str> = Vec::new();
|
||||
if lower.contains("read") || lower.contains("读取") {
|
||||
tools.push("read_file");
|
||||
}
|
||||
if lower.contains("目录") || lower.contains("directory") {
|
||||
tools.push("list_directory");
|
||||
}
|
||||
if tools.len() >= 2 {
|
||||
return PlanHint {
|
||||
groups: vec![ParallelGroup::new(tools)],
|
||||
sequence: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 规则 4:读后写 → read_file → write_file/patch_file ----
|
||||
let is_read = lower.contains("read") || lower.contains("读取");
|
||||
if is_read && is_modify {
|
||||
return PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
|
||||
PlanHint::empty()
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- feature flag 占位 ---
|
||||
|
||||
#[test]
|
||||
fn plan_hint_enabled_is_true_in_phase0a() {
|
||||
// Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
|
||||
assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
|
||||
}
|
||||
|
||||
// --- 数据结构基本 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_new_and_len() {
|
||||
let g = ParallelGroup::new(vec!["read_file", "write_file"]);
|
||||
assert_eq!(g.len(), 2);
|
||||
assert!(!g.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_group_empty_construct_allowed_but_invalid() {
|
||||
// 空 tools 允许构造(调用方失误),validate 阶段标记非法
|
||||
let g = ParallelGroup::new(vec![]);
|
||||
assert!(g.is_empty());
|
||||
assert_eq!(g.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_dep_new() {
|
||||
let d = SequentialDep::new("read_file", "write_file");
|
||||
assert_eq!(d.from, "read_file");
|
||||
assert_eq!(d.to, "write_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_empty_is_empty() {
|
||||
let h = PlanHint::empty();
|
||||
assert!(h.is_empty());
|
||||
assert_eq!(h.tool_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_hint_tool_count_dedup_across_groups() {
|
||||
// 两组含同名工具 → tool_count 去重
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file", "write_file"]),
|
||||
ParallelGroup::new(vec!["read_file", "patch_file"]),
|
||||
],
|
||||
sequence: vec![],
|
||||
};
|
||||
// read_file/write_file/patch_file = 3(去重)
|
||||
assert_eq!(h.tool_count(), 3);
|
||||
}
|
||||
|
||||
// --- plan_hint 基本编排提示 ---
|
||||
|
||||
#[test]
|
||||
fn hint_read_then_modify_yields_sequence() {
|
||||
// 规则 4:读后写 → read_file → write_file
|
||||
let h = plan_hint("code", "先 read 这个文件再修改它");
|
||||
assert!(!h.is_empty(), "读后写应产非空 hint");
|
||||
assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
|
||||
assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
|
||||
"应含 read_file→write_file 依赖边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_grep_then_modify_yields_search_first() {
|
||||
// 规则 1:grep 后改 → search_files → write_file
|
||||
let h = plan_hint("search", "grep 一下再修改 write_file");
|
||||
assert!(!h.is_empty());
|
||||
// 首组应是 search_files
|
||||
assert!(h.groups[0].tools.contains(&"search_files"));
|
||||
assert!(h.sequence.iter().any(|d| d.from == "search_files"),
|
||||
"search_files 应作为顺序依赖起点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_http_then_save_yields_http_first() {
|
||||
// 规则 2:http + 保存 → http_request → write_file
|
||||
let h = plan_hint("http", "请求这个 api 并保存写入到文件");
|
||||
assert!(!h.is_empty());
|
||||
assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_multi_read_only_yields_single_parallel_group() {
|
||||
// 规则 3:读多文件(无写)→ 单并行组
|
||||
let h = plan_hint("file", "read 文件,再看一下 directory 目录");
|
||||
assert!(!h.is_empty());
|
||||
assert_eq!(h.groups.len(), 1, "应单并行组");
|
||||
let tools = &h.groups[0].tools;
|
||||
assert!(tools.contains(&"read_file"), "组内应含 read_file");
|
||||
assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
|
||||
assert!(h.sequence.is_empty(), "全并行无顺序依赖");
|
||||
}
|
||||
|
||||
// --- 并行组识别 ---
|
||||
|
||||
#[test]
|
||||
fn parallel_group_tools_are_registry_names() {
|
||||
// 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
|
||||
// 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
|
||||
// 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
|
||||
// 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
|
||||
// intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
|
||||
let registry: std::collections::HashSet<&str> = [
|
||||
// Data domain
|
||||
"list_projects", "create_project", "update_project", "delete_project",
|
||||
"bind_directory", "list_tasks", "create_task", "update_task", "advance_task",
|
||||
"delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash",
|
||||
"restore_project", "purge_project", "get_project_count", "get_task_count",
|
||||
// File domain
|
||||
"read_file", "write_file", "patch_file", "list_directory", "search_files",
|
||||
"file_info", "append_file", "delete_file", "rename_file",
|
||||
// Exec domain
|
||||
"run_command",
|
||||
// Http domain
|
||||
"http_request",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
let h = plan_hint("file", "read 再读取目录 directory");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
for t in &g.tools {
|
||||
assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_groups_tools_non_empty_when_non_empty_hint() {
|
||||
// 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
if !h.is_empty() {
|
||||
for g in &h.groups {
|
||||
assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 顺序依赖 ---
|
||||
|
||||
#[test]
|
||||
fn hint_sequence_edges_from_less_than_to() {
|
||||
// 顺序依赖边 from/to 都是合法工具名(from 先于 to)
|
||||
let h = plan_hint("code", "read 后修改 write_file");
|
||||
for d in &h.sequence {
|
||||
assert_ne!(d.from, d.to, "依赖边不应自环");
|
||||
assert!(!d.from.is_empty());
|
||||
assert!(!d.to.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_no_duplicate_sequence_edge() {
|
||||
// 产出的依赖边不应重复
|
||||
let h = plan_hint("code", "read 后 write_file 修改");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for d in &h.sequence {
|
||||
assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 空 hint ---
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_plain_chat() {
|
||||
// 无编排信号 → 空 PlanHint
|
||||
let h = plan_hint("chat", "你好谢谢");
|
||||
assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_for_unknown_intent_no_signal() {
|
||||
let h = plan_hint("unknown", "今天的天气不错");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_empty_when_flag_off() {
|
||||
// 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
|
||||
// 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
|
||||
// 短路逻辑(若常量改 false,此测会失败提醒对齐)。
|
||||
// 用单工具避免命中多工具规则。
|
||||
let h = plan_hint("file", "read");
|
||||
// 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
|
||||
assert!(h.is_empty(), "单工具无编排信号应空 hint");
|
||||
}
|
||||
|
||||
// --- validate 合法性 ---
|
||||
|
||||
#[test]
|
||||
fn validate_empty_hint_is_ok() {
|
||||
let h = PlanHint::empty();
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_normal_dag_is_ok() {
|
||||
// read_file → write_file(单边 DAG)合法
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_empty_parallel_group_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec![])],
|
||||
sequence: vec![],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_self_loop_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![SequentialDep::new("read_file", "read_file")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_duplicate_edge_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
|
||||
}
|
||||
|
||||
// --- Kahn 环检测 ---
|
||||
|
||||
#[test]
|
||||
fn has_cycle_empty_edges_false() {
|
||||
assert!(!has_cycle(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_single_edge_no_cycle() {
|
||||
let edges = vec![SequentialDep::new("a", "b")];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_two_node_cycle_detected() {
|
||||
// a→b→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges), "a↔b 环应被检测");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_three_node_cycle_detected() {
|
||||
// a→b→c→a 环
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("b", "c"),
|
||||
SequentialDep::new("c", "a"),
|
||||
];
|
||||
assert!(has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cycle_dag_with_branch_no_cycle() {
|
||||
// a→b, a→c, b→d, c→d(菱形 DAG,无环)
|
||||
let edges = vec![
|
||||
SequentialDep::new("a", "b"),
|
||||
SequentialDep::new("a", "c"),
|
||||
SequentialDep::new("b", "d"),
|
||||
SequentialDep::new("c", "d"),
|
||||
];
|
||||
assert!(!has_cycle(&edges));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cyclic_dep_is_invalid() {
|
||||
let h = PlanHint {
|
||||
groups: vec![
|
||||
ParallelGroup::new(vec!["read_file"]),
|
||||
ParallelGroup::new(vec!["write_file"]),
|
||||
],
|
||||
sequence: vec![
|
||||
SequentialDep::new("read_file", "write_file"),
|
||||
SequentialDep::new("write_file", "read_file"),
|
||||
],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
|
||||
}
|
||||
|
||||
// --- 对抗/边界 ---
|
||||
|
||||
#[test]
|
||||
fn validate_check_order_self_loop_before_cycle() {
|
||||
// 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["a"])],
|
||||
sequence: vec![SequentialDep::new("a", "a")],
|
||||
};
|
||||
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_intent_label_ignored_in_phase0a() {
|
||||
// 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
|
||||
// 同关键词不同 intent_label → 同产出
|
||||
let h1 = plan_hint("code", "read 后修改 write_file");
|
||||
let h2 = plan_hint("debug", "read 后修改 write_file");
|
||||
assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_single_tool_no_signal_is_empty() {
|
||||
// 单 read(无写无多读)→ 不命中任何规则 → 空
|
||||
let h = plan_hint("file", "读取文件");
|
||||
assert!(h.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ignores_nodes_not_in_groups() {
|
||||
// validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
|
||||
// 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
|
||||
let h = PlanHint {
|
||||
groups: vec![ParallelGroup::new(vec!["read_file"])],
|
||||
sequence: vec![SequentialDep::new("read_file", "write_file")],
|
||||
};
|
||||
// write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
|
||||
assert_eq!(validate(&h), HintValidity::Ok);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+13
-205
@@ -1,207 +1,15 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//! LLM Provider trait + 数据结构 re-export
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方
|
||||
//! 如 df-ideas 可直接依赖 trait 不引入 reqwest/eventsource-stream)。
|
||||
//! 本文件保留为 df-ai 的 re-export 入口,使 `df_ai::provider::*` 路径不变
|
||||
//! —— df-ai 内部模块(openai_compat / anthropic_compat / context / ai_tools)
|
||||
//! 与外部消费方(df-nodes / src-tauri)的 `use df_ai::provider::LlmProvider`
|
||||
//! 等引用全部透明继续可用(编译期验证)。
|
||||
//!
|
||||
//! 留在 df-ai 的部分:
|
||||
//! - `OpenAICompatProvider` / `AnthropicCompatProvider`(HTTP impl,见 openai_compat.rs / anthropic_compat.rs)
|
||||
//! - `build_provider()` 工厂(见 lib.rs,按协议选 impl)
|
||||
//! - `ContextManager` / `AiToolRegistry` / `StreamCollector`(业务逻辑)
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls) }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// Provider 支持的特性标志
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderFeatures {
|
||||
pub streaming: bool,
|
||||
pub function_calling: bool,
|
||||
pub vision: bool,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> anyhow::Result<StreamResult>;
|
||||
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 支持的特性
|
||||
fn supported_features(&self) -> ProviderFeatures;
|
||||
}
|
||||
pub use df_ai_core::provider::*;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream()
|
||||
//!
|
||||
//! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。
|
||||
//! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级,
|
||||
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
|
||||
//!
|
||||
//! 策略:
|
||||
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
|
||||
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
|
||||
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
|
||||
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
|
||||
//!
|
||||
//! 流式 stream() 路径不进入此模块(流式长生成不能整体 timeout,重试会丢增量)。
|
||||
//!
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
pub const MAX_COMPLETE_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// 退避基准(秒): 第 n 次重试前 sleep `BASE_BACKOFF_SECS * 2^(n-1)`。
|
||||
/// 即 1s → 2s → 4s。
|
||||
const BASE_BACKOFF_SECS: u64 = 1;
|
||||
|
||||
/// 整体重试挂钟预算(含 sleep 与各次请求耗时)。超过即放弃,返回最后错误。
|
||||
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptOutcome<T> {
|
||||
/// 成功,携带结果。
|
||||
Ok(T),
|
||||
/// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。
|
||||
Retryable(String),
|
||||
/// 不可重试错误(4xx 非 429 / 鉴权 / 参数错 / 解析错)。携带描述,直接放弃。
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
/// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。
|
||||
///
|
||||
/// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。
|
||||
pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder())
|
||||
}
|
||||
|
||||
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
|
||||
///
|
||||
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
|
||||
pub fn is_status_retryable(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 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]
|
||||
let ms = (base_ms as f64 * factor).max(0.0) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
/// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。
|
||||
///
|
||||
/// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。
|
||||
/// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。
|
||||
/// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。
|
||||
///
|
||||
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder),
|
||||
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
|
||||
///
|
||||
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
|
||||
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
|
||||
/// backoff_delay + is_status_retryable)。
|
||||
pub(crate) async fn retry_with_backoff<T, F, Fut>(
|
||||
label: &str,
|
||||
attempt_fn: F,
|
||||
) -> anyhow::Result<T>
|
||||
where
|
||||
F: Fn(u32) -> Fut,
|
||||
Fut: Future<Output = AttemptOutcome<T>>,
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + MAX_TOTAL_BUDGET;
|
||||
let mut last_err = String::from("未尝试");
|
||||
|
||||
for attempt in 1..=MAX_COMPLETE_ATTEMPTS {
|
||||
match attempt_fn(attempt).await {
|
||||
AttemptOutcome::Ok(v) => return Ok(v),
|
||||
AttemptOutcome::Fatal(msg) => {
|
||||
// 不可重试: 立即放弃,不浪费预算
|
||||
return Err(anyhow::anyhow!("{}", msg));
|
||||
}
|
||||
AttemptOutcome::Retryable(msg) => {
|
||||
last_err = msg;
|
||||
let is_last = attempt == MAX_COMPLETE_ATTEMPTS;
|
||||
if is_last {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试已达上限({}次),放弃", MAX_COMPLETE_ATTEMPTS
|
||||
);
|
||||
break;
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
"重试预算({:?})已耗尽,放弃", MAX_TOTAL_BUDGET
|
||||
);
|
||||
break;
|
||||
}
|
||||
let delay = backoff_delay(attempt).min(deadline.saturating_duration_since(now));
|
||||
warn!(
|
||||
label = label,
|
||||
attempt = attempt,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
err = %last_err,
|
||||
"可重试错误,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("{} 重试耗尽: {}", label, last_err))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(纯逻辑,不发 HTTP)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn backoff_is_monotonic_within_jitter() {
|
||||
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围
|
||||
for attempt in 1..=3 {
|
||||
let d = backoff_delay(attempt);
|
||||
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
|
||||
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
|
||||
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
|
||||
assert!(
|
||||
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
|
||||
"attempt={} delay={:?} 应在 [{},{}]ms",
|
||||
attempt,
|
||||
d,
|
||||
lo,
|
||||
hi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
assert!(is_status_retryable(500));
|
||||
assert!(is_status_retryable(503));
|
||||
assert!(!is_status_retryable(400));
|
||||
assert!(!is_status_retryable(401));
|
||||
assert!(!is_status_retryable(403));
|
||||
assert!(!is_status_retryable(404));
|
||||
}
|
||||
|
||||
/// 初次成功 → 不重试
|
||||
#[tokio::test]
|
||||
async fn first_attempt_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::Ok(42u32)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 42);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// 第二次成功 → 恰好 2 次调用
|
||||
#[tokio::test]
|
||||
async fn retry_once_then_ok() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == 1 {
|
||||
AttemptOutcome::Retryable("timeout".into())
|
||||
} else {
|
||||
AttemptOutcome::Ok(7u32)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r, 7);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// 全部 Retryable → 用尽 3 次,返回错误
|
||||
#[tokio::test]
|
||||
async fn all_retryable_exhausts() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Retryable("503".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("重试耗尽"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), MAX_COMPLETE_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// Fatal 立即放弃,不重试
|
||||
#[tokio::test]
|
||||
async fn fatal_aborts_immediately() {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_clone = calls.clone();
|
||||
let r = retry_with_backoff("test", move |_| {
|
||||
let c = calls_clone.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
AttemptOutcome::<u32>::Fatal("401".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(r.is_err());
|
||||
assert!(r.unwrap_err().to_string().contains("401"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "Fatal 不应触发重试");
|
||||
}
|
||||
}
|
||||
+355
-39
@@ -1,51 +1,367 @@
|
||||
//! 模型路由 — 根据任务类型选择最优模型
|
||||
//! 模型路由器 — F-01 阶段4
|
||||
//!
|
||||
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
|
||||
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! df-ideas / df-nodes ai_node.rs)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
|
||||
//!
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskType {
|
||||
/// 代码生成
|
||||
CodeGeneration,
|
||||
/// 代码审查
|
||||
CodeReview,
|
||||
/// 文档生成
|
||||
Documentation,
|
||||
/// 分析推理
|
||||
Analysis,
|
||||
/// 摘要总结
|
||||
Summarization,
|
||||
/// 通用对话
|
||||
Chat,
|
||||
}
|
||||
|
||||
/// 模型路由器
|
||||
/// 任务对模型的需求(3 维度)。
|
||||
///
|
||||
/// 根据任务类型、成本、延迟等选择最优模型
|
||||
pub struct ModelRouter {
|
||||
/// 默认模型
|
||||
default_model: String,
|
||||
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRequirements {
|
||||
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
|
||||
pub modalities: Vec<Modality>,
|
||||
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
|
||||
pub needs_tool_use: bool,
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
|
||||
pub estimated_context: usize,
|
||||
}
|
||||
|
||||
/// 模型路由器(单元结构,无状态)。
|
||||
///
|
||||
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
|
||||
pub struct ModelRouter;
|
||||
|
||||
impl ModelRouter {
|
||||
/// 创建路由器
|
||||
pub fn new(default_model: &str) -> Self {
|
||||
Self {
|
||||
default_model: default_model.to_string(),
|
||||
/// 在候选池中选出最优模型(过滤链)。
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. enabled — 只选启用的
|
||||
/// 2. 模态匹配 — 任务所需模态全在模型模态里
|
||||
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
||||
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
||||
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
||||
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
|
||||
pool.iter()
|
||||
.filter(|m| m.enabled) // 1. 只选启用的
|
||||
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
|
||||
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
|
||||
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
|
||||
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
///
|
||||
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
||||
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
||||
/// → `Option<String>`;None 时兜底 `provider.default_model`(行为不变,平滑过渡)。
|
||||
///
|
||||
/// 行为不变保证:
|
||||
/// - 池空(用户未通过 Settings 拉取模型)→ 返回 None → 调用点兜底 default_model
|
||||
/// - 池非空但无候选满足需求 → 返回 None → 兜底 default_model
|
||||
/// - 池非空命中 → 返回 model_id(F-01 路由目标,拉取即启用路由)
|
||||
pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<String> {
|
||||
ModelRouter::select(req, pool).map(|m| m.model_id.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一个全维度可定制的 ModelConfig(默认全过过滤,调用方按需覆盖字段)。
|
||||
fn model(model_id: &str) -> ModelConfig {
|
||||
ModelConfig {
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
label: None,
|
||||
modalities: vec![Modality::Text],
|
||||
capabilities: vec![Capability::ToolUse],
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务类型路由到合适的模型
|
||||
pub fn route(&self, task_type: &TaskType) -> String {
|
||||
// TODO: 实现基于规则的模型路由
|
||||
match task_type {
|
||||
TaskType::CodeGeneration => self.default_model.clone(),
|
||||
TaskType::CodeReview => self.default_model.clone(),
|
||||
TaskType::Documentation => self.default_model.clone(),
|
||||
TaskType::Analysis => self.default_model.clone(),
|
||||
TaskType::Summarization => self.default_model.clone(),
|
||||
TaskType::Chat => self.default_model.clone(),
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
|
||||
fn req() -> TaskRequirements {
|
||||
TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段5 select_model_id helper ──
|
||||
|
||||
#[test]
|
||||
fn select_model_id_empty_pool_returns_none() {
|
||||
// 池空(用户未拉取模型)→ None,调用点兜底 default_model
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(select_model_id(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_model_id_hit_returns_model_id() {
|
||||
// 池非空命中 → 返回 model_id 字符串(非引用)
|
||||
let pool = vec![model("glm-4-flash")];
|
||||
assert_eq!(select_model_id(&req(), &pool).as_deref(), Some("glm-4-flash"));
|
||||
}
|
||||
|
||||
// ── 步骤 1:enabled 过滤 ──
|
||||
|
||||
#[test]
|
||||
fn empty_pool_returns_none() {
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_disabled_returns_none() {
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
..model("b")
|
||||
},
|
||||
];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 2:模态匹配 ──
|
||||
|
||||
#[test]
|
||||
fn modality_mismatch_filtered() {
|
||||
// 任务需要 Vision,但池里模型只有 Text
|
||||
let pool = vec![model("text-only")];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Vision],
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modality_subset_multimodal_required_filters_text_only() {
|
||||
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
|
||||
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
|
||||
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
|
||||
let pool = vec![ModelConfig {
|
||||
modalities: vec![Modality::Text],
|
||||
..model("text-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
|
||||
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
|
||||
let pool_ok = vec![ModelConfig {
|
||||
modalities: vec![Modality::Text, Modality::Vision],
|
||||
..model("vision-capable")
|
||||
}];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
|
||||
"vision-capable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_false_excluded_from_routing() {
|
||||
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
|
||||
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
|
||||
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
|
||||
..model("disabled-heavy")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: true,
|
||||
weight: 30,
|
||||
..model("enabled-light")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"enabled-light"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_candidate_pool_returns_none() {
|
||||
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
|
||||
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
|
||||
let pool: Vec<ModelConfig> = vec![];
|
||||
assert!(ModelRouter::select(&req(), &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 3:能力匹配(ToolUse) ──
|
||||
|
||||
#[test]
|
||||
fn needs_tool_use_filters_non_tool() {
|
||||
// 任务需要 ToolUse,池里模型 capabilities 无 ToolUse
|
||||
let pool = vec![ModelConfig {
|
||||
capabilities: vec![Capability::Embedding],
|
||||
..model("embed-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
needs_tool_use: true,
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_tool_use_false_allows_non_tool() {
|
||||
// 任务不需 ToolUse,池里模型无 ToolUse 仍入选
|
||||
let pool = vec![ModelConfig {
|
||||
capabilities: vec![Capability::Embedding],
|
||||
..model("embed-only")
|
||||
}];
|
||||
let r = TaskRequirements {
|
||||
needs_tool_use: false,
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"embed-only"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
|
||||
|
||||
#[test]
|
||||
fn context_window_insufficient() {
|
||||
// 任务预估 100000,池里模型 8192
|
||||
let pool = vec![model("small-window")];
|
||||
let r = TaskRequirements {
|
||||
estimated_context: 100_000,
|
||||
..req()
|
||||
};
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by_key (纯 weight) ──
|
||||
|
||||
#[test]
|
||||
fn single_match_returns_it() {
|
||||
let pool = vec![model("only-one")];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"only-one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weight_priority_higher_wins() {
|
||||
// 两候选都满足,weight 70 胜 50
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
..model("low-weight")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
..model("high-weight")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"high-weight"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_weight_picks_first_match() {
|
||||
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
|
||||
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
cost_tier: CostTier::High,
|
||||
..model("expensive")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
cost_tier: CostTier::Low,
|
||||
..model("low-cost")
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
ModelRouter::select(&req(), &pool).unwrap().model_id,
|
||||
"low-cost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
||||
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
//
|
||||
// 候选:
|
||||
// a: weight 60 → 通过全部过滤,key=60
|
||||
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
|
||||
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
|
||||
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 60,
|
||||
cost_tier: CostTier::Medium,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 80,
|
||||
cost_tier: CostTier::High,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 80,
|
||||
cost_tier: CostTier::Low,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("c")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
cost_tier: CostTier::Low,
|
||||
intelligence: IntelligenceTier::Lite,
|
||||
..model("d")
|
||||
},
|
||||
ModelConfig {
|
||||
enabled: false,
|
||||
weight: 100,
|
||||
..model("e")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
};
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 流式处理 — LLM 响应的流式输出管理
|
||||
|
||||
use crate::provider::StreamChunk;
|
||||
|
||||
/// 流式响应收集器
|
||||
pub struct StreamCollector {
|
||||
/// 已收集的文本
|
||||
text: String,
|
||||
/// 是否完成
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl StreamCollector {
|
||||
/// 创建收集器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一个 chunk
|
||||
pub fn push(&mut self, chunk: &StreamChunk) {
|
||||
self.text.push_str(&chunk.delta);
|
||||
if chunk.finished {
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已收集的文本
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// 是否已完成
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//! 工作流事件定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::NodeId;
|
||||
|
||||
/// 人工审批响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HumanApprovalResponse {
|
||||
pub execution_id: String,
|
||||
pub node_id: NodeId,
|
||||
pub decision: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
/// 工作流事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WorkflowEvent {
|
||||
/// 节点开始执行
|
||||
NodeStarted {
|
||||
node_id: NodeId,
|
||||
},
|
||||
/// 节点执行进度更新
|
||||
NodeProgress {
|
||||
node_id: NodeId,
|
||||
progress: f32,
|
||||
message: String,
|
||||
},
|
||||
/// 节点产生输出
|
||||
NodeOutput {
|
||||
node_id: NodeId,
|
||||
output: String,
|
||||
},
|
||||
/// 节点执行完成
|
||||
NodeCompleted {
|
||||
node_id: NodeId,
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// 节点执行失败
|
||||
NodeFailed {
|
||||
node_id: NodeId,
|
||||
error: String,
|
||||
},
|
||||
/// 工作流暂停(等待外部输入)
|
||||
WorkflowPaused {
|
||||
reason: String,
|
||||
waiting_node: NodeId,
|
||||
},
|
||||
/// 工作流执行完成
|
||||
WorkflowCompleted {
|
||||
total_duration_ms: u64,
|
||||
},
|
||||
/// 工作流执行失败
|
||||
WorkflowFailed {
|
||||
error: String,
|
||||
failed_node: NodeId,
|
||||
},
|
||||
/// 人工审批请求
|
||||
HumanApprovalRequest {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
title: String,
|
||||
description: String,
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// 人工审批响应
|
||||
HumanApprovalResponse {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
decision: String,
|
||||
comment: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! df-core: 核心类型定义,所有 crate 的基础依赖
|
||||
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "df-evolve"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -1,58 +0,0 @@
|
||||
//! 进化引擎:知识沉淀的核心闭环
|
||||
//!
|
||||
//! 使用 → 沉淀 → 复用 → 改进 → 再沉淀
|
||||
|
||||
use crate::knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
|
||||
use crate::pattern::PatternExtractor;
|
||||
|
||||
/// 进化引擎
|
||||
pub struct EvolveEngine {
|
||||
extractor: PatternExtractor,
|
||||
}
|
||||
|
||||
impl EvolveEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
extractor: PatternExtractor,
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动扫描项目事件,提取可沉淀的知识
|
||||
///
|
||||
/// 触发时机:
|
||||
/// - 工作流节点完成后
|
||||
/// - 代码审查完成后
|
||||
/// - Bug 修复完成后
|
||||
/// - 发布完成后
|
||||
pub async fn evolve_from_events(&self, _events: &[serde_json::Value]) -> Vec<Knowledge> {
|
||||
let mut new_knowledge = Vec::new();
|
||||
|
||||
// TODO: 遍历事件,分类处理
|
||||
// 1. 审查事件 → 提取审查规则
|
||||
// 2. Bug 修复事件 → 提取诊断知识
|
||||
// 3. 发布事件 → 提取部署经验
|
||||
// 4. Prompt 事件 → 提取 Prompt 模板
|
||||
|
||||
new_knowledge
|
||||
}
|
||||
|
||||
/// 查询当前任务相关的知识(供 AI 节点使用)
|
||||
///
|
||||
/// AI 在执行任务前可以查询知识库,获取相关经验和规则
|
||||
pub fn query_relevant(
|
||||
&self,
|
||||
_context: &str,
|
||||
_kind: Option<&KnowledgeKind>,
|
||||
) -> Vec<Knowledge> {
|
||||
// TODO: 语义搜索知识库
|
||||
KnowledgeStore::search(_context, _kind, 5)
|
||||
}
|
||||
|
||||
/// 验证知识的有效性(定期执行)
|
||||
///
|
||||
/// 检查知识是否仍然适用(依赖版本是否过时、规则是否仍有意义等)
|
||||
pub async fn validate_knowledge(&self) -> Vec<String> {
|
||||
// TODO: 遍历知识库,标记过时的知识
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! 知识条目:经验沉淀的基本单元
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 知识类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KnowledgeKind {
|
||||
/// 代码审查规则(如"禁止在循环中创建连接")
|
||||
ReviewRule,
|
||||
/// 有效的 Prompt 模板
|
||||
PromptTemplate,
|
||||
/// 踩坑经验
|
||||
Pitfall,
|
||||
/// 架构模式
|
||||
ArchitecturePattern,
|
||||
/// 诊断知识(Bug 根因分析)
|
||||
Diagnosis,
|
||||
/// 部署经验
|
||||
DeploymentNote,
|
||||
/// 工作流优化建议
|
||||
WorkflowOptimization,
|
||||
}
|
||||
|
||||
/// 知识条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Knowledge {
|
||||
pub id: String,
|
||||
pub kind: KnowledgeKind,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 内容
|
||||
pub content: String,
|
||||
/// 标签
|
||||
pub tags: Vec<String>,
|
||||
/// 来源项目
|
||||
pub source_project: Option<ProjectId>,
|
||||
/// 来源实体(如某次审查、某个 Bug 修复)
|
||||
pub source_ref: Option<String>,
|
||||
/// 被复用次数
|
||||
pub reuse_count: usize,
|
||||
/// 效果评分 (0-100,由用户或 AI 评估)
|
||||
pub effectiveness: Option<f32>,
|
||||
/// 是否已验证有效
|
||||
pub verified: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl Knowledge {
|
||||
pub fn new(kind: KnowledgeKind, title: String, content: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
Self {
|
||||
id: df_core::types::new_id(),
|
||||
kind,
|
||||
title,
|
||||
content,
|
||||
tags: vec![],
|
||||
source_project: None,
|
||||
source_ref: None,
|
||||
reuse_count: 0,
|
||||
effectiveness: None,
|
||||
verified: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次复用
|
||||
pub fn record_reuse(&mut self) {
|
||||
self.reuse_count += 1;
|
||||
self.updated_at = chrono::Utc::now().timestamp();
|
||||
}
|
||||
}
|
||||
|
||||
/// 知识库(内存索引,持久化到 SQLite)
|
||||
pub struct KnowledgeStore;
|
||||
|
||||
impl KnowledgeStore {
|
||||
/// 搜索相关知识
|
||||
pub fn search(_query: &str, _kind: Option<&KnowledgeKind>, _limit: usize) -> Vec<Knowledge> {
|
||||
// TODO: SQLite 全文搜索或向量搜索
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 获取最常用的知识
|
||||
pub fn top_used(_limit: usize) -> Vec<Knowledge> {
|
||||
// TODO: 按 reuse_count 降序
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 保存知识条目
|
||||
pub fn save(_knowledge: &Knowledge) -> anyhow::Result<()> {
|
||||
// TODO: SQLite INSERT/UPDATE
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! 经验进化引擎:从开发过程中自动沉淀知识,持续进化复用
|
||||
//!
|
||||
//! 核心闭环:使用 → 沉淀 → 复用 → 改进 → 再沉淀
|
||||
|
||||
pub mod knowledge;
|
||||
pub mod pattern;
|
||||
pub mod prompt_template;
|
||||
pub mod review_rule;
|
||||
pub mod evolve_engine;
|
||||
|
||||
pub use evolve_engine::EvolveEngine;
|
||||
pub use knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
|
||||
@@ -1,47 +0,0 @@
|
||||
//! 模式提取器:从开发过程中自动识别可沉淀的模式
|
||||
|
||||
use crate::knowledge::{Knowledge, KnowledgeKind};
|
||||
|
||||
/// 模式提取器
|
||||
///
|
||||
/// 自动从以下场景中识别可沉淀的模式:
|
||||
/// - 代码审查 → 审查规则
|
||||
/// - Bug 修复 → 诊断知识
|
||||
/// - 发布流程 → 部署经验
|
||||
/// - Prompt 调优 → Prompt 模板
|
||||
pub struct PatternExtractor;
|
||||
|
||||
impl PatternExtractor {
|
||||
/// 从代码审查结果中提取审查规则
|
||||
///
|
||||
/// 如果同一类问题在多次审查中重复出现,自动沉淀为规则
|
||||
pub fn extract_review_rule(
|
||||
_findings: &[serde_json::Value],
|
||||
_occurrence_threshold: usize,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO:
|
||||
// 1. 分析 findings 的共性
|
||||
// 2. 如果出现次数 >= threshold,生成规则
|
||||
// 3. 去重(与已有规则比较)
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 Bug 修复过程中提取诊断知识
|
||||
pub fn extract_diagnosis(
|
||||
_bug_description: &str,
|
||||
_root_cause: &str,
|
||||
_fix_description: &str,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO: AI 总结为可复用的诊断知识
|
||||
None
|
||||
}
|
||||
|
||||
/// 从成功的 Prompt 中提取模板
|
||||
pub fn extract_prompt_template(
|
||||
_prompt: &str,
|
||||
_result_quality: f32,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO: 如果 result_quality > 0.8,提取为模板
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Prompt 模板管理:AI 交互经验的沉淀与复用
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Prompt 模板
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptTemplate {
|
||||
pub id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 模板内容(支持 {variable} 占位符)
|
||||
pub template: String,
|
||||
/// 变量说明
|
||||
pub variables: Vec<TemplateVariable>,
|
||||
/// 适用场景
|
||||
pub applicable_scenarios: Vec<String>,
|
||||
/// 效果评分
|
||||
pub avg_score: f32,
|
||||
/// 使用次数
|
||||
pub use_count: usize,
|
||||
}
|
||||
|
||||
/// 模板变量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateVariable {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub default_value: Option<String>,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl PromptTemplate {
|
||||
/// 渲染模板(替换变量)
|
||||
pub fn render(&self, vars: &std::collections::HashMap<String, String>) -> String {
|
||||
let mut result = self.template.clone();
|
||||
for (key, value) in vars {
|
||||
result = result.replace(&format!("{{{}}}", key), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 审查规则:从历史审查经验中沉淀的代码审查规则
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 审查规则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReviewRule {
|
||||
pub id: String,
|
||||
/// 规则标题
|
||||
pub title: String,
|
||||
/// 规则描述
|
||||
pub description: String,
|
||||
/// 严重级别
|
||||
pub severity: RuleSeverity,
|
||||
/// 适用的语言/框架
|
||||
pub scope: Vec<String>,
|
||||
/// 检查方式(正则/AST/AI)
|
||||
pub check_method: CheckMethod,
|
||||
/// 发现次数(历史累计)
|
||||
pub found_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleSeverity {
|
||||
/// 必须修复
|
||||
MustFix,
|
||||
/// 建议改进
|
||||
ShouldFix,
|
||||
/// 可选优化
|
||||
NiceToHave,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckMethod {
|
||||
/// 正则匹配
|
||||
Regex,
|
||||
/// AST 分析
|
||||
Ast,
|
||||
/// AI 判断
|
||||
AiAnalysis,
|
||||
/// 人工判断
|
||||
Manual,
|
||||
}
|
||||
@@ -4,10 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Docker 执行器 — 在容器中运行任务
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Docker 容器执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerRequest {
|
||||
/// 镜像名称
|
||||
pub image: String,
|
||||
/// 容器内执行的命令
|
||||
pub command: Option<String>,
|
||||
/// 环境变量
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 挂载卷
|
||||
pub volumes: Vec<VolumeMount>,
|
||||
/// 是否在执行后自动删除容器
|
||||
pub auto_remove: bool,
|
||||
}
|
||||
|
||||
/// 卷挂载
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeMount {
|
||||
pub host_path: String,
|
||||
pub container_path: String,
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Docker 执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// 在 Docker 容器中执行命令
|
||||
///
|
||||
/// TODO: 实现 Docker API 调用或 CLI 包装
|
||||
pub async fn execute(_request: DockerRequest) -> anyhow::Result<DockerResult> {
|
||||
tracing::info!("Docker 执行: TODO");
|
||||
Ok(DockerResult {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: None,
|
||||
})
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Git 操作 — 克隆、提交、推送、合并等
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Git 操作类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GitAction {
|
||||
Clone,
|
||||
Commit,
|
||||
Push,
|
||||
Pull,
|
||||
Merge,
|
||||
Checkout,
|
||||
CreateBranch,
|
||||
}
|
||||
|
||||
/// Git 操作请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitRequest {
|
||||
/// 操作类型
|
||||
pub action: GitAction,
|
||||
/// 仓库路径(本地路径或远程 URL)
|
||||
pub repo: String,
|
||||
/// 分支名
|
||||
pub branch: Option<String>,
|
||||
/// 提交消息
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Git 操作结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 执行 Git 操作
|
||||
///
|
||||
/// TODO: 实现完整的 Git 操作(可包装 git CLI 或使用 git2 crate)
|
||||
pub async fn execute(_request: GitRequest) -> anyhow::Result<GitResult> {
|
||||
tracing::info!("Git 操作: TODO");
|
||||
Ok(GitResult {
|
||||
success: true,
|
||||
message: "TODO: 未实现".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
//! df-execute: 执行运行时 — Shell、Docker、SSH、Git 操作
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
|
||||
pub mod docker;
|
||||
pub mod git_ops;
|
||||
pub mod shell;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shell 执行器 — 通过 tokio::process 执行 shell 命令
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
|
||||
/// Shell 命令执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -15,6 +16,55 @@ pub struct ShellResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Shell 类型选择
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShellType {
|
||||
/// Windows cmd.exe (默认 Windows)
|
||||
Cmd,
|
||||
/// PowerShell 5.x(Windows 自带,不支持 && 运算符)
|
||||
PowerShell,
|
||||
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
|
||||
Pwsh,
|
||||
/// Unix sh (默认非 Windows)
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
// 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 缓存(只探一次)。
|
||||
if cfg!(windows) {
|
||||
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||
///
|
||||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
fn probe_pwsh() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellRequest {
|
||||
@@ -26,24 +76,61 @@ pub struct ShellRequest {
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 超时时间(秒),None 表示不超时
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
|
||||
#[serde(default)]
|
||||
pub shell_type: Option<ShellType>,
|
||||
}
|
||||
|
||||
/// 执行 Shell 命令
|
||||
///
|
||||
/// TODO: 完整实现,支持超时、环境变量、工作目录等
|
||||
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
|
||||
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
|
||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(&request.command);
|
||||
c
|
||||
} else {
|
||||
let mut c = tokio::process::Command::new("sh");
|
||||
c.arg("-c").arg(&request.command);
|
||||
c
|
||||
let shell_type = request.shell_type.unwrap_or_default();
|
||||
let mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
let mut c = tokio::process::Command::new("powershell");
|
||||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||||
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);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Sh => {
|
||||
let mut c = tokio::process::Command::new("sh");
|
||||
c.arg("-c").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
|
||||
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
|
||||
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
|
||||
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||||
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
||||
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
if let Some(dir) = &request.working_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
@@ -58,7 +145,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
cmd.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
|
||||
None => cmd.output().await?,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
//! SSH 执行器 — 远程命令执行
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SSH 执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshRequest {
|
||||
/// 主机地址
|
||||
pub host: String,
|
||||
/// 端口
|
||||
pub port: u16,
|
||||
/// 用户名
|
||||
pub user: String,
|
||||
/// 要执行的命令
|
||||
pub command: String,
|
||||
/// 超时时间(秒)
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// SSH 执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// 通过 SSH 执行远程命令
|
||||
///
|
||||
/// TODO: 实现SSH连接(可用 ssh2 crate 或包装 ssh 命令)
|
||||
pub async fn execute(_request: SshRequest) -> anyhow::Result<SshResult> {
|
||||
tracing::info!("SSH 执行: TODO");
|
||||
Ok(SshResult {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Shell 执行器基础集成测试。
|
||||
//!
|
||||
//! 覆盖 execute() 五类行为,跨平台(Windows=Cmd, 非 Windows=Sh):
|
||||
//! 1. 成功:exit_code=Some(0) + stdout 非空 + duration_ms>0
|
||||
//! 2. 非零退出:exit_code=Some(非0),返回 Ok(退出码在 ShellResult 内,非 Err)
|
||||
//! 3. 超时:timeout_secs=1 + 长睡命令,返回 Err
|
||||
//! 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证
|
||||
//! 5. working_dir:传临时目录,命令回显当前目录验证
|
||||
//!
|
||||
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
|
||||
|
||||
use df_execute::shell::{execute, ShellRequest, ShellType};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
|
||||
fn default_shell() -> ShellType {
|
||||
if cfg!(windows) {
|
||||
ShellType::Cmd
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造最小 ShellRequest,默认无超时/无 env/无 working_dir
|
||||
fn req(command: &str) -> ShellRequest {
|
||||
ShellRequest {
|
||||
command: command.to_string(),
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
timeout_secs: None,
|
||||
shell_type: Some(default_shell()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 1. 成功:exit_code=0 + stdout 非空 + duration_ms>0 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_success_echo() {
|
||||
// 平台无关:Cmd/Sh 都认 echo。Cmd 输出无尾换行,Sh 输出带 \n——只断言非空与包含。
|
||||
let result = execute(req("echo hello_df_execute")).await;
|
||||
assert!(result.is_ok(), "execute 应返回 Ok: {:?}", result.err());
|
||||
let res = result.unwrap();
|
||||
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
|
||||
assert!(
|
||||
res.stdout.trim().contains("hello_df_execute"),
|
||||
"stdout 应包含回显文本,实际: {:?}",
|
||||
res.stdout
|
||||
);
|
||||
assert!(res.duration_ms > 0, "duration_ms 应 > 0,实际: {}", res.duration_ms);
|
||||
}
|
||||
|
||||
// ---------- 2. 非零退出:exit_code=Some(非0),返回 Ok ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_nonzero_exit() {
|
||||
// 平台无关:false 是 POSIX 内建 + Windows 的 cmd 也内置(返回退出码 1)。
|
||||
// 非 Windows 用 sh -c 'false';Windows 用 cmd /C 'cmd /C exit 1' 更稳——
|
||||
// 这里统一用 false,Windows cmd 内置 false.exe 自 Win10 起可用,失败则 exit_code 非 0 即满足断言。
|
||||
let result = execute(req("exit 1")).await;
|
||||
assert!(result.is_ok(), "非零退出应仍返回 Ok(退出码在 ShellResult 内),实际: {:?}", result);
|
||||
let res = result.unwrap();
|
||||
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0,实际: {:?}", res.exit_code);
|
||||
}
|
||||
|
||||
// ---------- 3. 超时:timeout_secs=1 + 长睡命令,返回 Err ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_timeout_returns_err() {
|
||||
// 平台无关睡眠命令:Cmd 用 timeout(Windows 内置),Sh 用 sleep。各自分支。
|
||||
let sleep_cmd = if cfg!(windows) {
|
||||
// cmd 的 timeout /T 需按键中断,用 ping 假睡更稳(>1s);但 timeout 内置更简洁,
|
||||
// 这里用 ping -n 5 (≈4s) 跨 Win 版本稳。
|
||||
"ping -n 5 127.0.0.1 > nul".to_string()
|
||||
} else {
|
||||
"sleep 5".to_string()
|
||||
};
|
||||
let request = ShellRequest {
|
||||
command: sleep_cmd,
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(1),
|
||||
shell_type: Some(default_shell()),
|
||||
};
|
||||
let result = execute(request).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"超时应返回 Err,实际: {:?}",
|
||||
result.as_ref().err()
|
||||
);
|
||||
// 错误信息应含「超时」(对齐 shell.rs:101 文案)
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
|
||||
"错误信息应含超时提示,实际: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_env_injection() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("DF_EXECUTE_TEST_KEY".to_string(), "env_value_42".to_string());
|
||||
// 平台无关回显 env 变量:Cmd 用 %VAR%,Sh 用 $VAR
|
||||
let echo_cmd = if cfg!(windows) {
|
||||
"echo %DF_EXECUTE_TEST_KEY%"
|
||||
} else {
|
||||
"echo $DF_EXECUTE_TEST_KEY"
|
||||
};
|
||||
let request = ShellRequest {
|
||||
command: echo_cmd.to_string(),
|
||||
working_dir: None,
|
||||
env,
|
||||
timeout_secs: None,
|
||||
shell_type: Some(default_shell()),
|
||||
};
|
||||
let res = execute(request).await.expect("env 注入命令应返回 Ok");
|
||||
assert_eq!(res.exit_code, Some(0));
|
||||
assert!(
|
||||
res.stdout.trim().contains("env_value_42"),
|
||||
"stdout 应含注入的 env 值,实际: {:?}",
|
||||
res.stdout
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 5. working_dir:传临时目录,命令回显当前目录验证 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_working_dir() {
|
||||
// 用 std::env::temp_dir()(标准库,零新增依赖)。生成唯一子目录避免并行测试互相干扰。
|
||||
let unique = format!("df_exec_test_{}", std::process::id());
|
||||
let tmp = std::env::temp_dir().join(&unique);
|
||||
std::fs::create_dir_all(&tmp).expect("创建临时目录失败");
|
||||
// 测试后清理(即便失败也尽量不残留)
|
||||
let tmp_for_cleanup = tmp.clone();
|
||||
|
||||
// 平台无关回显当前目录:Cmd 用 cd(无参数)不靠谱,用 echo %CD%;Sh 用 pwd。
|
||||
let pwd_cmd = if cfg!(windows) { "echo %CD%" } else { "pwd" };
|
||||
let request = ShellRequest {
|
||||
command: pwd_cmd.to_string(),
|
||||
working_dir: Some(tmp.to_string_lossy().to_string()),
|
||||
env: HashMap::new(),
|
||||
timeout_secs: None,
|
||||
shell_type: Some(default_shell()),
|
||||
};
|
||||
let res = execute(request).await.expect("working_dir 命令应返回 Ok");
|
||||
assert_eq!(res.exit_code, Some(0));
|
||||
// 规范化比较(去尾空白/大小写在 Windows 不敏感但路径用户自定义,只断言包含)
|
||||
let stdout_norm = res.stdout.trim().to_lowercase().replace('\\', "/");
|
||||
let tmp_norm = tmp.to_string_lossy().to_lowercase().replace('\\', "/");
|
||||
assert!(
|
||||
stdout_norm.contains(&tmp_norm),
|
||||
"stdout 应含 working_dir 路径,实际: {:?},期望包含: {:?}",
|
||||
res.stdout,
|
||||
tmp.to_string_lossy()
|
||||
);
|
||||
|
||||
// 清理
|
||||
let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
|
||||
}
|
||||
@@ -4,10 +4,17 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
# F-01 阶段5: df-ideas 对抗评估接入 df_ai::router::select_model_id(纯函数路由)。
|
||||
# 注:df-ai 引入 reqwest/futures/eventsource-stream 重依赖,但 router 模块仅依赖
|
||||
# df-ai-core::model,实际编译期 df-ideas 仅引用 router 符号(零 HTTP 代码路径)。
|
||||
df-ai = { path = "../df-ai" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
|
||||
+537
-249
@@ -1,206 +1,303 @@
|
||||
//! 对抗式评估系统 — 正反方辩论 + AI 分析师
|
||||
//!
|
||||
//! 双轨实现:
|
||||
//! - **启发式**(默认/降级):基于评分与内容信号生成正反方论点,稳定有区分度。
|
||||
//! - **LLM**(注入 provider 后):调一次 `complete()` 让论点由 LLM 生成,失败自动降级启发式。
|
||||
//!
|
||||
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
|
||||
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
|
||||
//!
|
||||
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
|
||||
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
|
||||
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
|
||||
//!
|
||||
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
|
||||
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
|
||||
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
|
||||
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
use df_ai_core::model::ModelConfig;
|
||||
use df_ai_core::provider::LlmProvider;
|
||||
use df_types::types::Priority;
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::IdeaScores;
|
||||
|
||||
/// 对抗评估结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdversarialEval {
|
||||
pub idea_id: IdeaId,
|
||||
pub positive: Argument,
|
||||
pub negative: Argument,
|
||||
pub analyst: AnalystAnalysis,
|
||||
pub final_score: f64,
|
||||
pub recommendation: Recommendation,
|
||||
}
|
||||
|
||||
/// 正方论点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Argument {
|
||||
pub thesis: String, // 核心观点
|
||||
pub evidence: Vec<String>, // 证据支持
|
||||
pub reasoning: Vec<String>, // 推理过程
|
||||
pub confidence: f64, // 置信度 0-1
|
||||
}
|
||||
|
||||
/// 反方论点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CounterArgument {
|
||||
pub thesis: String, // 反对观点
|
||||
pub evidence: Vec<String>, // 反对证据
|
||||
pub reasoning: Vec<String>, // 反驳推理
|
||||
pub confidence: f64, // 置信度 0-1
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalystAnalysis {
|
||||
pub summary: String, // 综合总结
|
||||
pub strengths: Vec<String>, // 主要优势
|
||||
pub weaknesses: Vec<String>, // 主要劣势
|
||||
pub risks: Vec<String>, // 潜在风险
|
||||
pub opportunities: Vec<String>, // 机会点
|
||||
pub final_assessment: AssessmentLevel, // 最终评估
|
||||
}
|
||||
|
||||
/// 评估等级
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AssessmentLevel {
|
||||
StrongGo, // 强烈推荐执行
|
||||
Recommended, // 推荐执行
|
||||
Conditional, // 有条件执行
|
||||
Revised, // 需要修改后执行
|
||||
Defer, // 推迟执行
|
||||
Reject, // 不推荐执行
|
||||
}
|
||||
|
||||
/// 最终建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Recommendation {
|
||||
ImmediateAction, // 立即行动
|
||||
Soon, // 尽快行动
|
||||
WithResources, // 配置资源后行动
|
||||
ResearchMore, // 需要更多研究
|
||||
Monitor, // 持续监控
|
||||
Cancel, // 取消想法
|
||||
}
|
||||
// 类型 / 常量 / 纯函数自 adversarial_helpers 引回,并 re-export:外部调用方经
|
||||
// `df_ideas::adversarial::{...}` 路径访问的类型零变更(pub use 同时将符号带入当前
|
||||
// 命名空间供 impl/tests 使用,无需额外 use)。AdversarialEngine 本身定义在本文件。
|
||||
pub use crate::adversarial_helpers::{
|
||||
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
|
||||
};
|
||||
use crate::adversarial_helpers::{
|
||||
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval,
|
||||
priority_label,
|
||||
};
|
||||
|
||||
/// 对抗评估引擎
|
||||
pub struct AdversarialEngine;
|
||||
pub struct AdversarialEngine {
|
||||
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
|
||||
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
|
||||
provider: Option<Arc<dyn LlmProvider>>,
|
||||
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
|
||||
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
|
||||
/// (与接入前行为一致,平稳过渡)。
|
||||
model_pool: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
impl AdversarialEngine {
|
||||
/// 执行完整的对抗评估
|
||||
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
|
||||
// 1. 生成正方观点
|
||||
let positive = Self::generate_positive_argument(idea).await?;
|
||||
/// 注入 LLM provider 构造(provider Some 时走 LLM,调用失败自动降级启发式)
|
||||
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
|
||||
Self { provider: Some(provider), model_pool: Vec::new() }
|
||||
}
|
||||
|
||||
// 2. 生成反方观点
|
||||
let negative = Self::generate_negative_argument(idea, &positive).await?;
|
||||
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
|
||||
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
|
||||
Self { provider: Some(provider), model_pool }
|
||||
}
|
||||
|
||||
// 3. AI 分析师综合分析
|
||||
let analyst = Self::analyst_analysis(idea, &positive, &negative).await?;
|
||||
/// 纯启发式构造(无 LLM 配置时的默认模式)
|
||||
pub fn heuristic() -> Self {
|
||||
Self { provider: None, model_pool: Vec::new() }
|
||||
}
|
||||
|
||||
// 4. 计算最终分数和建议
|
||||
let (final_score, recommendation) = Self::compute_final_assessment(&analyst);
|
||||
/// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级)
|
||||
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
match &self.provider {
|
||||
Some(p) => match self.evaluate_with_llm(idea, p).await {
|
||||
Ok(mut eval) => {
|
||||
eval.evaluated_by = EvaluatedBy::Llm;
|
||||
Ok(eval)
|
||||
}
|
||||
Err(e) => {
|
||||
// LLM 调用失败/超时/格式异常 → 自动降级启发式,保证前端结构完整返回
|
||||
tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}");
|
||||
let mut eval = self.evaluate_heuristic(idea)?;
|
||||
eval.evaluated_by = EvaluatedBy::HeuristicFallback;
|
||||
Ok(eval)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let mut eval = self.evaluate_heuristic(idea)?;
|
||||
eval.evaluated_by = EvaluatedBy::Heuristic;
|
||||
Ok(eval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM 对抗评估(注入 provider 后走此路)。
|
||||
///
|
||||
/// 构造三角色对抗式辩论 prompt(正方/反方/分析师),调一次 `complete()`,要求 LLM
|
||||
/// 返回对齐 [`AdversarialEval`] 结构的 JSON。任一环节失败(HTTP / 非 JSON / 字段
|
||||
/// 缺失 / 枚举非法 / 数值越界无法修正)→ `bail` 由 [`Self::evaluate`] 捕获降级启发式。
|
||||
///
|
||||
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
|
||||
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
|
||||
///
|
||||
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
|
||||
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
|
||||
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
|
||||
let prompt = build_adversarial_prompt(idea);
|
||||
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
|
||||
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
|
||||
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
|
||||
let eval_req = df_ai::router::TaskRequirements {
|
||||
modalities: vec![df_ai_core::model::Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
};
|
||||
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
|
||||
let request = df_ai_core::provider::CompletionRequest {
|
||||
// 路由命中 → 用 model_id;否则留空让 provider impl 回填自身 default_model。
|
||||
// (OpenAICompatProvider::convert_request 在 req.model.is_empty() 时回退 default_model)
|
||||
model,
|
||||
messages: vec![
|
||||
df_ai_core::provider::ChatMessage::system(SYSTEM_PROMPT),
|
||||
df_ai_core::provider::ChatMessage::user(prompt),
|
||||
],
|
||||
temperature: Some(0.4),
|
||||
max_tokens: Some(2048),
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
let resp = provider
|
||||
.complete(request)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("LLM complete 调用失败: {e}"))?;
|
||||
|
||||
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
|
||||
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
|
||||
parse_llm_eval(&resp.text, &idea.id)
|
||||
}
|
||||
|
||||
/// 启发式评估(基于评分与内容信号,稳定有区分度)
|
||||
fn evaluate_heuristic(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
// 先做多维评分,作为正反方论点与置信度的依据
|
||||
let scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||
|
||||
let positive = self.generate_positive_argument(idea, &scores)?;
|
||||
let negative = self.generate_negative_argument(idea, &scores)?;
|
||||
let analyst = self.analyst_analysis(idea, &scores)?;
|
||||
let recommendation = self.recommendation_for(&analyst.final_assessment);
|
||||
|
||||
Ok(AdversarialEval {
|
||||
idea_id: idea.id.clone(),
|
||||
positive,
|
||||
negative,
|
||||
analyst,
|
||||
final_score,
|
||||
final_score: scores.overall,
|
||||
recommendation,
|
||||
// 由 evaluate() 调用方按调度路径覆盖(Heuristic / HeuristicFallback)
|
||||
evaluated_by: EvaluatedBy::Heuristic,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成正方观点(支持执行)
|
||||
async fn generate_positive_argument(idea: &Idea) -> Result<Argument> {
|
||||
// TODO: 接入 AI 生成正方观点
|
||||
// 当前使用启发式模板
|
||||
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
|
||||
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
|
||||
fn generate_positive_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
let desc = idea.description.trim();
|
||||
let mut evidence = Vec::new();
|
||||
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
|
||||
if desc.is_empty() {
|
||||
evidence.push("需求待补充(建议补全描述)".to_string());
|
||||
} else {
|
||||
let head: String = desc.chars().take(60).collect();
|
||||
evidence.push(format!("明确需求:{}", head));
|
||||
}
|
||||
if idea.tags.is_empty() {
|
||||
evidence.push("关联领域待界定".to_string());
|
||||
} else {
|
||||
evidence.push(format!("关联领域:{}", idea.tags.join("、")));
|
||||
}
|
||||
if scores.impact >= 7.0 {
|
||||
evidence.push("业务价值显著,影响面较广".to_string());
|
||||
}
|
||||
|
||||
let title = &idea.title;
|
||||
let desc = &idea.description;
|
||||
// 正方置信度:可行性+影响力等权折算到 [0.1, 0.95],满分≈0.95 留质疑余地
|
||||
let confidence =
|
||||
((scores.feasibility * 0.5 + scores.impact * 0.5) / 10.0).clamp(0.1, 0.95);
|
||||
|
||||
let reasoning = vec![
|
||||
format!("可行性评分 {:.1}/10,路径相对清晰", scores.feasibility),
|
||||
format!("影响力评分 {:.1}/10,预期回报可观", scores.impact),
|
||||
"整体风险可控,适合推进".to_string(),
|
||||
];
|
||||
|
||||
Ok(Argument {
|
||||
thesis: format!("{} 具有很高的价值和可行性,应该优先执行", title),
|
||||
evidence: vec![
|
||||
format!("满足业务需求:{}", desc),
|
||||
"投入产出比高".to_string(),
|
||||
"技术实现可行".to_string(),
|
||||
"时间窗口合适".to_string(),
|
||||
],
|
||||
reasoning: vec![
|
||||
"能够解决现有痛点".to_string(),
|
||||
"竞争优势明显".to_string(),
|
||||
"风险可控".to_string(),
|
||||
],
|
||||
confidence: 0.75,
|
||||
thesis: format!("「{}」具备明确价值与可行性,建议优先推进", idea.title),
|
||||
evidence,
|
||||
reasoning,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成反方观点(反对或谨慎)
|
||||
async fn generate_negative_argument(idea: &Idea, positive: &Argument) -> Result<CounterArgument> {
|
||||
// TODO: 接入 AI 生成反方观点,考虑正方观点
|
||||
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升
|
||||
fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
let desc = idea.description.trim();
|
||||
let mut evidence = Vec::new();
|
||||
if desc.is_empty() {
|
||||
evidence.push("描述过于简略,需求边界不清".to_string());
|
||||
} else if desc.chars().count() < 50 {
|
||||
evidence.push("描述偏短,实现细节尚未论证".to_string());
|
||||
}
|
||||
if idea.tags.is_empty() {
|
||||
evidence.push("缺少标签,影响范围未界定".to_string());
|
||||
}
|
||||
if scores.feasibility < 6.0 {
|
||||
evidence.push(format!("可行性 {:.1}/10 偏低,实现路径存疑", scores.feasibility));
|
||||
}
|
||||
if matches!(idea.priority, Priority::Low) {
|
||||
evidence.push("优先级偏低,可能非当前关键路径".to_string());
|
||||
}
|
||||
if evidence.is_empty() {
|
||||
evidence.push("机会成本需权衡,可能存在更优替代方案".to_string());
|
||||
}
|
||||
|
||||
let title = &idea.title;
|
||||
// 反方强度:feasibility 每降 1 分 +0.04,impact 每降 1 分 +0.03,基线 0.25(满分也保留最低质疑),clamp [0.1, 0.9]
|
||||
let confidence = ((10.0 - scores.feasibility) * 0.04 + (10.0 - scores.impact) * 0.03 + 0.25)
|
||||
.clamp(0.1, 0.9);
|
||||
|
||||
Ok(CounterArgument {
|
||||
thesis: format!("{} 需要谨慎评估,存在一定风险", title),
|
||||
evidence: vec![
|
||||
"资源投入较大".to_string(),
|
||||
"市场不确定性高".to_string(),
|
||||
"技术挑战存在".to_string(),
|
||||
"机会成本高".to_string(),
|
||||
],
|
||||
reasoning: vec![
|
||||
"ROI 需要进一步验证".to_string(),
|
||||
"优先级可能过高".to_string(),
|
||||
"存在更优替代方案".to_string(),
|
||||
],
|
||||
confidence: 0.65,
|
||||
let reasoning = vec![
|
||||
format!("资源投入与当前综合评分 {:.1} 需匹配", scores.overall),
|
||||
"ROI 需进一步验证".to_string(),
|
||||
"需评估是否存在更优解".to_string(),
|
||||
];
|
||||
|
||||
Ok(Argument {
|
||||
thesis: format!("「{}」需谨慎评估,存在风险与机会成本", idea.title),
|
||||
evidence,
|
||||
reasoning,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
async fn analyst_analysis(
|
||||
idea: &Idea,
|
||||
positive: &Argument,
|
||||
negative: &CounterArgument,
|
||||
) -> Result<AnalystAnalysis> {
|
||||
// TODO: 接入 AI 进行深度分析
|
||||
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
||||
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
|
||||
let final_assessment = match scores.overall {
|
||||
x if x >= 7.5 => AssessmentLevel::StrongGo,
|
||||
x if x >= 6.0 => AssessmentLevel::Recommended,
|
||||
x if x >= 4.5 => AssessmentLevel::Conditional,
|
||||
x if x >= 3.0 => AssessmentLevel::Revised,
|
||||
_ => AssessmentLevel::Defer,
|
||||
};
|
||||
|
||||
let positive_strengths = vec![
|
||||
"方向正确,符合业务战略".to_string(),
|
||||
"技术创新性较强".to_string(),
|
||||
"用户价值明确".to_string(),
|
||||
];
|
||||
let mut strengths = Vec::new();
|
||||
if scores.impact >= 6.0 {
|
||||
strengths.push("业务价值明确".to_string());
|
||||
}
|
||||
if scores.feasibility >= 6.0 {
|
||||
strengths.push("技术路径清晰".to_string());
|
||||
}
|
||||
if scores.urgency >= 7.0 {
|
||||
strengths.push("时间窗口合适".to_string());
|
||||
}
|
||||
if strengths.is_empty() {
|
||||
strengths.push("方向值得探索".to_string());
|
||||
}
|
||||
|
||||
let weaknesses = vec![
|
||||
"资源需求评估不足".to_string(),
|
||||
"风险控制需要加强".to_string(),
|
||||
"时间规划可能过于乐观".to_string(),
|
||||
];
|
||||
let mut weaknesses = Vec::new();
|
||||
if scores.feasibility < 6.0 {
|
||||
weaknesses.push("可行性论证不足".to_string());
|
||||
}
|
||||
if idea.description.trim().is_empty() {
|
||||
weaknesses.push("需求描述缺失".to_string());
|
||||
}
|
||||
if scores.urgency < 4.0 {
|
||||
weaknesses.push("紧急度偏低,易被搁置".to_string());
|
||||
}
|
||||
if weaknesses.is_empty() {
|
||||
weaknesses.push("资源需求待评估".to_string());
|
||||
}
|
||||
|
||||
// 启发式占位:固定风险模板,与具体想法无关,接 LLM 后改动态生成
|
||||
let risks = vec![
|
||||
"技术实现难度超出预期".to_string(),
|
||||
"市场竞争加剧".to_string(),
|
||||
"用户接受度不确定".to_string(),
|
||||
"技术实现难度可能超出预期".to_string(),
|
||||
"优先级与资源争夺".to_string(),
|
||||
"需求范围蔓延".to_string(),
|
||||
];
|
||||
|
||||
let opportunities = vec![
|
||||
"可能形成新的竞争优势".to_string(),
|
||||
"技术积累价值显著".to_string(),
|
||||
"市场机会窗口良好".to_string(),
|
||||
"可能形成可复用能力".to_string(),
|
||||
"积累技术资产".to_string(),
|
||||
];
|
||||
|
||||
// 基于正反方观点的强度计算
|
||||
let positive_strength = positive.confidence;
|
||||
let negative_strength = negative.confidence;
|
||||
let net_positive = (positive_strength - negative_strength + 1.0) / 2.0;
|
||||
|
||||
let final_assessment = if net_positive > 0.7 {
|
||||
AssessmentLevel::StrongGo
|
||||
} else if net_positive > 0.5 {
|
||||
AssessmentLevel::Recommended
|
||||
} else if net_positive > 0.3 {
|
||||
AssessmentLevel::Conditional
|
||||
} else if net_positive > 0.1 {
|
||||
AssessmentLevel::Revised
|
||||
} else {
|
||||
AssessmentLevel::Defer
|
||||
};
|
||||
let summary = format!(
|
||||
"「{}」综合评分 {:.1}/10,{}。建议{}。",
|
||||
idea.title,
|
||||
scores.overall,
|
||||
assessment_desc(&final_assessment),
|
||||
action_hint(&final_assessment)
|
||||
);
|
||||
|
||||
Ok(AnalystAnalysis {
|
||||
summary: format!(
|
||||
"该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于{},需要关注{}。",
|
||||
idea.title,
|
||||
if net_positive > 0.5 { "风险控制" } else { "价值验证" }
|
||||
),
|
||||
strengths: positive_strengths,
|
||||
summary,
|
||||
strengths,
|
||||
weaknesses,
|
||||
risks,
|
||||
opportunities,
|
||||
@@ -208,101 +305,292 @@ impl AdversarialEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// 计算最终评估分数和建议
|
||||
fn compute_final_assessment(analyst: &AnalystAnalysis) -> (f64, Recommendation) {
|
||||
// 基于评估等级映射分数
|
||||
let base_score = match analyst.final_assessment {
|
||||
AssessmentLevel::StrongGo => 8.5,
|
||||
AssessmentLevel::Recommended => 7.0,
|
||||
AssessmentLevel::Conditional => 5.5,
|
||||
AssessmentLevel::Revised => 4.0,
|
||||
AssessmentLevel::Defer => 2.5,
|
||||
AssessmentLevel::Reject => 1.0,
|
||||
};
|
||||
|
||||
// 根据优劣势微调分数
|
||||
let strength_count = analyst.strengths.len() as f64;
|
||||
let weakness_count = analyst.weaknesses.len() as f64;
|
||||
let score_adjustment = (strength_count - weakness_count) * 0.3;
|
||||
|
||||
let final_score = (base_score + score_adjustment).clamp(0.0, 10.0);
|
||||
|
||||
let recommendation = match analyst.final_assessment {
|
||||
/// 评估等级 → 最终建议
|
||||
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
|
||||
AssessmentLevel::Recommended => Recommendation::Soon,
|
||||
AssessmentLevel::Conditional => Recommendation::WithResources,
|
||||
AssessmentLevel::Revised => Recommendation::ResearchMore,
|
||||
AssessmentLevel::Defer => Recommendation::Monitor,
|
||||
AssessmentLevel::Reject => Recommendation::Cancel,
|
||||
};
|
||||
|
||||
(final_score, recommendation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 评估结果展示格式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EvalDisplay {
|
||||
pub idea_title: String,
|
||||
pub positive_strength: f64,
|
||||
pub negative_strength: f64,
|
||||
pub net_sentiment: f64, // -1 到 1,正为正面
|
||||
pub assessment_level: String,
|
||||
pub key_takeaways: Vec<String>,
|
||||
pub action_items: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<AdversarialEval> for EvalDisplay {
|
||||
fn from(eval: AdversarialEval) -> Self {
|
||||
let net_sentiment = (eval.positive.confidence - eval.negative.confidence) as f64;
|
||||
|
||||
let key_takeaways = vec![
|
||||
format!("优势:{}", eval.analyst.strengths.join("、")),
|
||||
format!("风险:{}", eval.analyst.risks.join("、")),
|
||||
format!("建议:{:?}", eval.recommendation),
|
||||
];
|
||||
|
||||
let action_items = match eval.recommendation {
|
||||
Recommendation::ImmediateAction => vec![
|
||||
"立即组建项目团队".to_string(),
|
||||
"制定详细执行计划".to_string(),
|
||||
"分配必要资源".to_string(),
|
||||
],
|
||||
Recommendation::Soon => vec![
|
||||
"下周启动项目".to_string(),
|
||||
"准备资源需求".to_string(),
|
||||
"制定时间表".to_string(),
|
||||
],
|
||||
Recommendation::WithResources => vec![
|
||||
"确认资源预算".to_string(),
|
||||
"评估ROI".to_string(),
|
||||
"制定风险预案".to_string(),
|
||||
],
|
||||
Recommendation::ResearchMore => vec![
|
||||
"进行市场调研".to_string(),
|
||||
"收集用户反馈".to_string(),
|
||||
"验证技术可行性".to_string(),
|
||||
],
|
||||
Recommendation::Monitor => vec![
|
||||
"持续跟踪相关指标".to_string(),
|
||||
"定期评估进展".to_string(),
|
||||
"等待更好的时机".to_string(),
|
||||
],
|
||||
Recommendation::Cancel => vec![
|
||||
"记录归档原因".to_string(),
|
||||
"释放相关资源".to_string(),
|
||||
"提取经验教训".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
EvalDisplay {
|
||||
idea_title: eval.positive.thesis.split(' ').take(3).collect::<Vec<_>>().join(" "),
|
||||
positive_strength: eval.positive.confidence,
|
||||
negative_strength: eval.negative.confidence,
|
||||
net_sentiment,
|
||||
assessment_level: format!("{:?}", eval.analyst.final_assessment),
|
||||
key_takeaways,
|
||||
action_items,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::ScoringEngine;
|
||||
use df_types::types::{IdeaStatus, Priority};
|
||||
|
||||
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
|
||||
Idea {
|
||||
id: "test-id".to_string(),
|
||||
title: title.to_string(),
|
||||
description: desc.to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
scores: None,
|
||||
tags: tags.into_iter().map(String::from).collect(),
|
||||
source: None,
|
||||
related_ids: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a1_high_score_immediate_action() {
|
||||
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
|
||||
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a1] 高分想法 → 期望 ImmediateAction");
|
||||
println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall);
|
||||
println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation);
|
||||
println!(" 正方 confidence={:.2} 反方 confidence={:.2}", eval.positive.confidence, eval.negative.confidence);
|
||||
assert!(eval.final_score >= 7.5, "final_score 应≥7.5, 实际 {:.2}", eval.final_score);
|
||||
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a2_mid_score_soon() {
|
||||
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
|
||||
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a2] 中分想法 → 期望 Soon");
|
||||
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a3_low_score_monitor() {
|
||||
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
|
||||
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a3] 低分想法 → 期望 Monitor");
|
||||
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
||||
assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score);
|
||||
assert_eq!(eval.recommendation, Recommendation::Monitor);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a4_confidence_ranges() {
|
||||
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a4] confidence 区间校验");
|
||||
println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.confidence);
|
||||
assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95);
|
||||
assert!(eval.negative.confidence >= 0.1 && eval.negative.confidence <= 0.9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a5_positive_thesis_contains_title() {
|
||||
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a5] 正方论点含标题");
|
||||
println!(" thesis: {}", eval.positive.thesis);
|
||||
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a6_negative_evidence_nonempty() {
|
||||
let idea = make_idea("待质疑想法", "短", Priority::Low, vec![]);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
|
||||
for (i, e) in eval.negative.evidence.iter().enumerate() {
|
||||
println!(" 证据{}: {}", i + 1, e);
|
||||
}
|
||||
assert!(!eval.negative.evidence.is_empty(), "反方 evidence 不应为空");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a7_final_score_consistency() {
|
||||
let desc = "面向用户的核心功能".to_string();
|
||||
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a7] final_score == scores.overall 一致性");
|
||||
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
|
||||
println!(" analyst.summary: {}", eval.analyst.summary);
|
||||
assert!((eval.final_score - scores.overall).abs() < 0.001, "final_score 应等于 overall");
|
||||
assert!(eval.analyst.summary.contains("一致性测试"), "summary 应含标题");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// LLM 路径测试(F-260614-03)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
|
||||
///
|
||||
/// `complete` 返回预设 `text`(或预设错误),`stream` 不被 adversarial 路径调用,
|
||||
/// 返回 Err 即可。不引入 futures 依赖。
|
||||
struct MockProvider {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: df_ai_core::provider::CompletionRequest,
|
||||
) -> anyhow::Result<df_ai_core::provider::CompletionResponse> {
|
||||
Ok(df_ai_core::provider::CompletionResponse {
|
||||
text: self.text.clone(),
|
||||
model: "mock-model".to_string(),
|
||||
usage: df_ai_core::provider::TokenUsage::default(),
|
||||
tool_calls: None,
|
||||
// 对抗评估路径不消费推理内容,留 None 即可(CompletionResponse 新增字段,
|
||||
// 老构造点漏填 → 测试编译失败,回归补齐)。
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: df_ai_core::provider::CompletionRequest,
|
||||
) -> anyhow::Result<df_ai_core::provider::StreamResult> {
|
||||
anyhow::bail!("MockProvider 不支持 stream(adversarial 路径不调用)")
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"mock-model"
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM mock 返回结构良好的 JSON → 解析正确,evaluated_by=Llm
|
||||
#[tokio::test]
|
||||
async fn a8_llm_mock_parse_success() {
|
||||
let idea = make_idea(
|
||||
"AI 增长引擎",
|
||||
"面向用户的核心功能,带来显著增长。集成成熟方案。",
|
||||
Priority::Critical,
|
||||
vec!["增长"],
|
||||
);
|
||||
// 一份对齐 schema 的合法 LLM 返回(含 ```json 围栏,验证容错)
|
||||
let llm_text = r#"```json
|
||||
{
|
||||
"positive": {
|
||||
"thesis": "该想法价值高,路径清晰,应立即推进",
|
||||
"evidence": ["用户增长是当前关键路径", "已有成熟方案可复用"],
|
||||
"reasoning": ["可行性高", "ROI 明显"],
|
||||
"confidence": 0.9
|
||||
},
|
||||
"negative": {
|
||||
"thesis": "资源投入与替代方案需权衡",
|
||||
"evidence": ["机会成本存在"],
|
||||
"reasoning": ["需验证更优解"],
|
||||
"confidence": 0.35
|
||||
},
|
||||
"analyst": {
|
||||
"summary": "综合双方,价值明确且路径清晰,建议立即推进",
|
||||
"strengths": ["业务价值明确", "技术路径清晰"],
|
||||
"weaknesses": ["资源需求待评估"],
|
||||
"risks": ["需求范围蔓延"],
|
||||
"opportunities": ["可形成可复用能力"],
|
||||
"final_assessment": "StrongGo"
|
||||
},
|
||||
"final_score": 8.5,
|
||||
"recommendation": "ImmediateAction"
|
||||
}
|
||||
```"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a8] LLM mock 成功路径");
|
||||
println!(" evaluated_by = {:?}", eval.evaluated_by);
|
||||
println!(" final_score = {:.2} recommendation = {:?}", eval.final_score, eval.recommendation);
|
||||
println!(" positive.confidence = {:.2} negative.confidence = {:.2}",
|
||||
eval.positive.confidence, eval.negative.confidence);
|
||||
println!(" analyst.final_assessment = {:?}", eval.analyst.final_assessment);
|
||||
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "成功路径应为 Llm");
|
||||
assert!((eval.final_score - 8.5).abs() < 1e-9);
|
||||
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
|
||||
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
|
||||
assert!((eval.positive.confidence - 0.9).abs() < 1e-9);
|
||||
assert!((eval.negative.confidence - 0.35).abs() < 1e-9);
|
||||
assert_eq!(eval.positive.evidence.len(), 2);
|
||||
assert!(eval.positive.thesis.contains("立即推进"));
|
||||
}
|
||||
|
||||
/// LLM mock 返回非法 JSON → bail → 降级 HeuristicFallback(evaluated_by 标记)
|
||||
#[tokio::test]
|
||||
async fn a9_llm_mock_bad_json_fallback() {
|
||||
let idea = make_idea("坏想法", "测试降级", Priority::Medium, vec![]);
|
||||
let provider = Arc::new(MockProvider { text: "这不是 JSON".to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a9] LLM mock 非 JSON → 降级");
|
||||
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback, "非 JSON 应降级");
|
||||
}
|
||||
|
||||
/// LLM mock 返回枚举非法值 → bail → 降级
|
||||
#[tokio::test]
|
||||
async fn a10_llm_mock_bad_enum_fallback() {
|
||||
let idea = make_idea("枚举非法", "测试枚举映射", Priority::High, vec![]);
|
||||
let llm_text = r#"{
|
||||
"positive": {"thesis":"x","evidence":[],"reasoning":[],"confidence":0.5},
|
||||
"negative": {"thesis":"y","evidence":[],"reasoning":[],"confidence":0.5},
|
||||
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"GoNow"},
|
||||
"final_score": 5.0,
|
||||
"recommendation": "Soon"
|
||||
}"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a10] LLM mock 枚举非法 → 降级");
|
||||
println!(" evaluated_by = {:?} (期望 HeuristicFallback)", eval.evaluated_by);
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::HeuristicFallback);
|
||||
}
|
||||
|
||||
/// 数值越界 → clamp(confidence>1 / final_score>10)应被截断,不触发降级
|
||||
#[tokio::test]
|
||||
async fn a11_llm_mock_value_clamp() {
|
||||
let idea = make_idea("越界测试", "数值超出区间", Priority::Medium, vec![]);
|
||||
let llm_text = r#"{
|
||||
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":1.5},
|
||||
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":-0.3},
|
||||
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Recommended"},
|
||||
"final_score": 99.0,
|
||||
"recommendation": "Soon"
|
||||
}"#;
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a11] LLM mock 数值越界 clamp");
|
||||
println!(" positive.confidence={:.2} (原 1.5 → 期望 1.0)", eval.positive.confidence);
|
||||
println!(" negative.confidence={:.2} (原 -0.3 → 期望 0.0)", eval.negative.confidence);
|
||||
println!(" final_score={:.2} (原 99 → 期望 10.0)", eval.final_score);
|
||||
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅越界不降级");
|
||||
assert!((eval.positive.confidence - 1.0).abs() < 1e-9, "confidence>1 应 clamp 到 1.0");
|
||||
assert!((eval.negative.confidence - 0.0).abs() < 1e-9, "confidence<0 应 clamp 到 0.0");
|
||||
assert!((eval.final_score - 10.0).abs() < 1e-9, "final_score>10 应 clamp 到 10.0");
|
||||
}
|
||||
|
||||
/// LLM 返回「前缀文字 + ```json + {...} + ```」(围栏不在开头)→ extract_json
|
||||
/// 正则兜底提取首个 JSON 对象 → 解析成功不降级(CR-40-2 兜底路径)。
|
||||
#[tokio::test]
|
||||
async fn a12_llm_mock_fenced_not_at_start() {
|
||||
let idea = make_idea("围栏不在开头", "测试正则兜底", Priority::High, vec![]);
|
||||
let llm_text = "好的,以下是我的评估:\n```json\n{\n \"positive\": {\"thesis\":\"p\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.7},\n \"negative\": {\"thesis\":\"n\",\"evidence\":[],\"reasoning\":[],\"confidence\":0.4},\n \"analyst\": {\"summary\":\"s\",\"strengths\":[],\"weaknesses\":[],\"risks\":[],\"opportunities\":[],\"final_assessment\":\"Recommended\"},\n \"final_score\": 7.0,\n \"recommendation\": \"Soon\"\n}\n```\n希望对你有帮助。";
|
||||
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
|
||||
let engine = AdversarialEngine::new(provider);
|
||||
let eval = engine.evaluate(&idea).await.unwrap();
|
||||
|
||||
println!("\n[a12] 围栏不在开头 → 正则兜底提取");
|
||||
println!(" evaluated_by = {:?} (期望 Llm,不应降级)", eval.evaluated_by);
|
||||
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "围栏不在开头应正则兜底解析成功");
|
||||
assert!((eval.final_score - 7.0).abs() < 1e-9);
|
||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
|
||||
//!
|
||||
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
|
||||
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
|
||||
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
|
||||
//!
|
||||
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
|
||||
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
|
||||
//! 由 adversarial.rs 通过 `pub use` 重新导出,调用方零变更。
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_types::types::{IdeaId, Priority};
|
||||
use crate::capture::Idea;
|
||||
|
||||
/// 评估来源标记
|
||||
///
|
||||
/// `Default = Heuristic`:老数据(F-07 之前)序列化时无 evaluated_by 字段,
|
||||
/// 反序列化回落启发式(与 F-07 之前行为一致)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EvaluatedBy {
|
||||
/// LLM 深度评估
|
||||
Llm,
|
||||
/// 启发式评估(无 LLM 配置时的默认模式,也是老数据反序列化默认值)
|
||||
#[default]
|
||||
Heuristic,
|
||||
/// 启发式降级(LLM 调用失败后 fallback)
|
||||
HeuristicFallback,
|
||||
}
|
||||
|
||||
/// 对抗评估结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdversarialEval {
|
||||
pub idea_id: IdeaId,
|
||||
pub positive: Argument,
|
||||
pub negative: Argument,
|
||||
pub analyst: AnalystAnalysis,
|
||||
pub final_score: f64,
|
||||
pub recommendation: Recommendation,
|
||||
/// 评估来源(Llm / Heuristic / HeuristicFallback),前端据此显示评估深度标签
|
||||
#[serde(default)]
|
||||
pub evaluated_by: EvaluatedBy,
|
||||
}
|
||||
|
||||
/// 论点(正方/反方共用同一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Argument {
|
||||
pub thesis: String, // 核心观点
|
||||
pub evidence: Vec<String>, // 证据支持
|
||||
pub reasoning: Vec<String>, // 推理过程
|
||||
pub confidence: f64, // 置信度 0-1
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalystAnalysis {
|
||||
pub summary: String, // 综合总结
|
||||
pub strengths: Vec<String>, // 主要优势
|
||||
pub weaknesses: Vec<String>, // 主要劣势
|
||||
pub risks: Vec<String>, // 潜在风险
|
||||
pub opportunities: Vec<String>, // 机会点
|
||||
pub final_assessment: AssessmentLevel, // 最终评估
|
||||
}
|
||||
|
||||
/// 评估等级
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AssessmentLevel {
|
||||
StrongGo, // 强烈推荐执行
|
||||
Recommended, // 推荐执行
|
||||
Conditional, // 有条件执行
|
||||
Revised, // 需要修改后执行
|
||||
Defer, // 推迟执行
|
||||
}
|
||||
|
||||
/// 最终建议
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Recommendation {
|
||||
ImmediateAction, // 立即行动
|
||||
Soon, // 尽快行动
|
||||
WithResources, // 配置资源后行动
|
||||
ResearchMore, // 需要更多研究
|
||||
Monitor, // 持续监控
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
|
||||
// ============================================================
|
||||
|
||||
/// LLM 角色 / 输出契约的系统级约束。
|
||||
///
|
||||
/// 放为 const 便于在 prompt 头部注入,与 [`build_adversarial_prompt`] 的「想法上下文」
|
||||
/// 分离:角色设定稳定不变,想法上下文按评估对象动态拼。
|
||||
pub(crate) const SYSTEM_PROMPT: &str = "\
|
||||
你是一位资深的技术产品决策顾问。你将主持一场三角色对抗式辩论,对一个软件想法做结构化评估:
|
||||
- 正方(Advocate):论证为什么应该立即推进该想法,挖掘价值与可行性证据。
|
||||
- 反方(Skeptic):质疑该想法,挖掘风险、机会成本与替代方案,不放过任何隐患。
|
||||
- 分析师(Analyst):综合正反方观点,给出客观、平衡的最终裁决。
|
||||
|
||||
规则:
|
||||
1. 你必须只输出一个 JSON 对象,不要输出任何解释性文字、Markdown 代码块标记或前后缀。
|
||||
2. 所有枚举字段只能取下方 schema 列出的字符串字面量(区分大小写)。
|
||||
3. 数值字段必须落在指定区间内。
|
||||
4. 论点(thesis / evidence / reasoning / summary 等)用中文表达,简洁有信息密度。";
|
||||
|
||||
/// 构造对抗评估 prompt(三角色辩论 + 严格 JSON schema)。
|
||||
///
|
||||
/// 输出 prompt 包含:想法上下文(title/description/priority/tags)+ 角色 + JSON 输出
|
||||
/// 契约(含字段说明与枚举字面量),让 LLM 单轮产出可解析的结构化评估。
|
||||
pub(crate) fn build_adversarial_prompt(idea: &Idea) -> String {
|
||||
// 描述截断防 prompt 过长:> 800 字按 800 截并附省略号提示。
|
||||
let desc_raw = idea.description.trim();
|
||||
let description = if desc_raw.chars().count() > 800 {
|
||||
let head: String = desc_raw.chars().take(800).collect();
|
||||
format!("{head}……(描述已截断,原长超过 800 字)")
|
||||
} else if desc_raw.is_empty() {
|
||||
"(描述为空,需求边界待补充)".to_string()
|
||||
} else {
|
||||
desc_raw.to_string()
|
||||
};
|
||||
|
||||
let tags = if idea.tags.is_empty() {
|
||||
"(无标签)".to_string()
|
||||
} else {
|
||||
idea.tags.join("、")
|
||||
};
|
||||
|
||||
format!(
|
||||
"\
|
||||
请对以下软件想法进行三角色对抗式评估。
|
||||
|
||||
【想法上下文】
|
||||
标题:{title}
|
||||
描述:{description}
|
||||
优先级:{priority}
|
||||
标签:{tags}
|
||||
|
||||
【输出 JSON schema】(只输出该 JSON 对象,字段名与枚举字面量严格一致)
|
||||
{{
|
||||
\"positive\": {{
|
||||
\"thesis\": \"正方核心观点(一句话)\",
|
||||
\"evidence\": [\"证据 1\", \"证据 2\"],
|
||||
\"reasoning\": [\"推理 1\", \"推理 2\"],
|
||||
\"confidence\": 0.0到1.0之间的浮点数,正方置信度
|
||||
}},
|
||||
\"negative\": {{
|
||||
\"thesis\": \"反方核心观点(一句话)\",
|
||||
\"evidence\": [\"证据 1\"],
|
||||
\"reasoning\": [\"推理 1\"],
|
||||
\"confidence\": 0.0到1.0之间的浮点数,反方置信度
|
||||
}},
|
||||
\"analyst\": {{
|
||||
\"summary\": \"分析师综合总结(一段话)\",
|
||||
\"strengths\": [\"主要优势 1\"],
|
||||
\"weaknesses\": [\"主要劣势 1\"],
|
||||
\"risks\": [\"潜在风险 1\"],
|
||||
\"opportunities\": [\"机会点 1\"],
|
||||
\"final_assessment\": \"StrongGo | Recommended | Conditional | Revised | Defer\"
|
||||
}},
|
||||
\"final_score\": 0到10之间的浮点数,综合评分,
|
||||
\"recommendation\": \"ImmediateAction | Soon | WithResources | ResearchMore | Monitor\"
|
||||
}}
|
||||
|
||||
枚举字段说明:
|
||||
- final_assessment: StrongGo=强烈推荐执行 / Recommended=推荐执行 / Conditional=有条件执行 / Revised=需修改后执行 / Defer=推迟执行
|
||||
- recommendation: ImmediateAction=立即行动 / Soon=尽快行动 / WithResources=配置资源后行动 / ResearchMore=需更多研究 / Monitor=持续监控
|
||||
|
||||
要求:
|
||||
- positive 与 negative 的论点必须真实基于上方想法上下文,不要泛泛而谈。
|
||||
- analyst.summary 必须总结双方并给出明确倾向,不要模棱两可。
|
||||
- final_score 与 recommendation 应与 analyst.final_assessment 自洽(如 Defer 对应低分与 Monitor)。
|
||||
- 只输出 JSON,不要任何额外文字。",
|
||||
title = idea.title,
|
||||
description = description,
|
||||
priority = priority_label(&idea.priority),
|
||||
tags = tags,
|
||||
)
|
||||
}
|
||||
|
||||
/// LLM 返回的非类型化 JSON 表示(与 [`AdversarialEval`] 结构对齐,但枚举为字符串、
|
||||
/// evaluated_by 字段省略——由 [`AdversarialEngine::evaluate`] 调用方覆盖)。
|
||||
///
|
||||
/// 采用独立中间结构而非直接 serde 到 [`AdversarialEval`]:便于在枚举非法时给出
|
||||
/// 字段级错误信息(`final_assessment: "GoNow" 不在合法集合`),而非整条记录丢弃;
|
||||
/// 同时留出数值 clamp 的统一收口。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct LlmEvalRaw {
|
||||
pub(crate) positive: ArgumentRaw,
|
||||
pub(crate) negative: ArgumentRaw,
|
||||
pub(crate) analyst: AnalystRaw,
|
||||
pub(crate) final_score: f64,
|
||||
pub(crate) recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ArgumentRaw {
|
||||
pub(crate) thesis: String,
|
||||
#[serde(default)]
|
||||
pub(crate) evidence: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) reasoning: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) confidence: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AnalystRaw {
|
||||
#[serde(default)]
|
||||
pub(crate) summary: String,
|
||||
#[serde(default)]
|
||||
pub(crate) strengths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) weaknesses: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) risks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) opportunities: Vec<String>,
|
||||
pub(crate) final_assessment: String,
|
||||
}
|
||||
|
||||
/// 解析 LLM 返回文本为 [`AdversarialEval`]。
|
||||
///
|
||||
/// 容错策略(任一失败 `bail` → 由 [`AdversarialEngine::evaluate`] 降级启发式):
|
||||
/// 1. 剥离 ```json … ``` 代码块围栏与首尾空白(部分 LLM 会无视「只输出 JSON」要求)。
|
||||
/// 2. `serde_json` 反序列化为 [`LlmEvalRaw`];字段类型错 / 缺失必填 → bail。
|
||||
/// 3. 枚举字符串映射([`parse_assessment_level`] / [`parse_recommendation`]);非法值 → bail。
|
||||
/// 4. 数值 clamp:confidence ∈ [0,1],final_score ∈ [0,10]。
|
||||
pub(crate) fn parse_llm_eval(text: &str, idea_id: &str) -> Result<AdversarialEval> {
|
||||
let json_str = extract_json(text);
|
||||
let raw: LlmEvalRaw = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!("LLM 返回非合法 JSON 或字段缺失: {e}")
|
||||
})?;
|
||||
|
||||
let final_assessment = parse_assessment_level(&raw.analyst.final_assessment)?;
|
||||
let recommendation = parse_recommendation(&raw.recommendation)?;
|
||||
|
||||
let positive = Argument {
|
||||
thesis: raw.positive.thesis,
|
||||
evidence: raw.positive.evidence,
|
||||
reasoning: raw.positive.reasoning,
|
||||
confidence: raw.positive.confidence.clamp(0.0, 1.0),
|
||||
};
|
||||
let negative = Argument {
|
||||
thesis: raw.negative.thesis,
|
||||
evidence: raw.negative.evidence,
|
||||
reasoning: raw.negative.reasoning,
|
||||
confidence: raw.negative.confidence.clamp(0.0, 1.0),
|
||||
};
|
||||
let analyst = AnalystAnalysis {
|
||||
summary: raw.analyst.summary,
|
||||
strengths: raw.analyst.strengths,
|
||||
weaknesses: raw.analyst.weaknesses,
|
||||
risks: raw.analyst.risks,
|
||||
opportunities: raw.analyst.opportunities,
|
||||
final_assessment,
|
||||
};
|
||||
|
||||
Ok(AdversarialEval {
|
||||
idea_id: idea_id.to_string(),
|
||||
positive,
|
||||
negative,
|
||||
analyst,
|
||||
// final_score clamp 单点收口于此(evaluate_with_llm 不再重复 clamp,CR-40-1)
|
||||
final_score: raw.final_score.clamp(0.0, 10.0),
|
||||
recommendation,
|
||||
// 由 evaluate() 按 LLM 调度路径覆盖为 EvaluatedBy::Llm
|
||||
evaluated_by: EvaluatedBy::Llm,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 LLM 返回文本中提取 JSON 主体。
|
||||
///
|
||||
/// 提取优先级:
|
||||
/// 1. 代码围栏在开头(```json … ``` 或 ``` … ```)→ 快路径剥离围栏返回主体。
|
||||
/// 2. 围栏不在开头(LLM 输出「前缀文字 + ```json + {...} + ```」)→ 正则
|
||||
/// `(?s)\{.*\}` 兜底提取首个 `{` 到最后一个 `}` 的片段(增强命中率)。
|
||||
///
|
||||
/// 注意:兜底返回的是「原始文本中首个 JSON 对象字面量」,**不再** trim 后整段交给
|
||||
/// serde——由 [`parse_llm_eval`] 的 serde 步骤校验合法性,失败即 bail 降级。
|
||||
pub(crate) fn extract_json(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
// 快路径:围栏在开头(```json ... ``` 或 ``` ... ```)
|
||||
if let Some(rest) = trimmed.strip_prefix("```") {
|
||||
// 跳过语言标记(json/JSON 等)到首个换行
|
||||
let after_lang = match rest.find('\n') {
|
||||
Some(idx) => &rest[idx + 1..],
|
||||
None => rest,
|
||||
};
|
||||
let body = after_lang.trim_end();
|
||||
if let Some(body_inner) = body.strip_suffix("```") {
|
||||
return body_inner.trim().to_string();
|
||||
}
|
||||
// 围栏闭合缺失(如 JSON 与 ``` 同行 / 闭合后仍有文字 / 多行 JSON 末尾混噪声):
|
||||
// 快路径无法干净剥离 → 落到正则兜底提取首个 JSON 对象,提升命中率。
|
||||
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
|
||||
// 必然失败降级启发式,即便正则本可救回。)
|
||||
}
|
||||
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象(CR-40-2)。
|
||||
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
|
||||
// 提取失败(无 { })则返回 trimmed 走原文 serde 报错降级,语义不变。
|
||||
static JSON_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
let re = JSON_RE.get_or_init(|| regex::Regex::new(r"(?s)\{.*\}").expect("合法静态正则"));
|
||||
match re.captures(trimmed) {
|
||||
Some(c) => c.get(0).map(|m| m.as_str().to_string()).unwrap_or_else(|| trimmed.to_string()),
|
||||
None => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举字面量 → [`AssessmentLevel`]。区分大小写匹配 schema 文档约定。
|
||||
pub(crate) fn parse_assessment_level(s: &str) -> Result<AssessmentLevel> {
|
||||
match s.trim() {
|
||||
"StrongGo" => Ok(AssessmentLevel::StrongGo),
|
||||
"Recommended" => Ok(AssessmentLevel::Recommended),
|
||||
"Conditional" => Ok(AssessmentLevel::Conditional),
|
||||
"Revised" => Ok(AssessmentLevel::Revised),
|
||||
"Defer" => Ok(AssessmentLevel::Defer),
|
||||
other => anyhow::bail!(
|
||||
"final_assessment 枚举值非法: {:?}(合法: StrongGo|Recommended|Conditional|Revised|Defer)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 枚举字面量 → [`Recommendation`]。区分大小写匹配 schema 文档约定。
|
||||
pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
|
||||
match s.trim() {
|
||||
"ImmediateAction" => Ok(Recommendation::ImmediateAction),
|
||||
"Soon" => Ok(Recommendation::Soon),
|
||||
"WithResources" => Ok(Recommendation::WithResources),
|
||||
"ResearchMore" => Ok(Recommendation::ResearchMore),
|
||||
"Monitor" => Ok(Recommendation::Monitor),
|
||||
other => anyhow::bail!(
|
||||
"recommendation 枚举值非法: {:?}(合法: ImmediateAction|Soon|WithResources|ResearchMore|Monitor)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn priority_label(p: &Priority) -> &'static str {
|
||||
match p {
|
||||
Priority::Critical => "紧急",
|
||||
Priority::High => "高",
|
||||
Priority::Medium => "中",
|
||||
Priority::Low => "低",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn assessment_desc(level: &AssessmentLevel) -> &'static str {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => "价值高且可行性强",
|
||||
AssessmentLevel::Recommended => "整体值得推进",
|
||||
AssessmentLevel::Conditional => "有条件地推进",
|
||||
AssessmentLevel::Revised => "需调整后再评估",
|
||||
AssessmentLevel::Defer => "建议暂缓",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_hint(level: &AssessmentLevel) -> &'static str {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => "立即立项启动",
|
||||
AssessmentLevel::Recommended => "尽快排期",
|
||||
AssessmentLevel::Conditional => "配置资源后启动",
|
||||
AssessmentLevel::Revised => "补充信息后重新评估",
|
||||
AssessmentLevel::Defer => "持续观察时机",
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,8 @@
|
||||
//! 想法捕获 — 快速记录与管理
|
||||
//! 想法实体与评分类型 — 供 scoring/adversarial/promotion 共享
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::{IdeaId, Priority};
|
||||
|
||||
/// 捕获一个新想法的输入
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureInput {
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 详细描述
|
||||
pub description: String,
|
||||
/// 优先级
|
||||
#[serde(default)]
|
||||
pub priority: Priority,
|
||||
/// 标签
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 来源(如 "用户输入"、"AI 生成"、"会议记录")
|
||||
pub source: Option<String>,
|
||||
}
|
||||
use df_types::types::{IdeaId, Priority};
|
||||
|
||||
/// 想法实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -31,7 +14,7 @@ pub struct Idea {
|
||||
/// 详细描述
|
||||
pub description: String,
|
||||
/// 当前状态
|
||||
pub status: df_core::types::IdeaStatus,
|
||||
pub status: df_types::types::IdeaStatus,
|
||||
/// 优先级
|
||||
pub priority: Priority,
|
||||
/// 评分
|
||||
@@ -60,39 +43,3 @@ pub struct IdeaScores {
|
||||
/// 综合评分 (加权平均)
|
||||
pub overall: f64,
|
||||
}
|
||||
|
||||
/// 想法捕获器
|
||||
pub struct IdeaCapture;
|
||||
|
||||
impl IdeaCapture {
|
||||
/// 捕获一个新想法
|
||||
///
|
||||
/// TODO: 接入存储层持久化
|
||||
pub fn capture(input: CaptureInput) -> Idea {
|
||||
let now = chrono::Utc::now();
|
||||
Idea {
|
||||
id: df_core::types::new_id(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: df_core::types::IdeaStatus::Draft,
|
||||
priority: input.priority,
|
||||
scores: None,
|
||||
tags: input.tags,
|
||||
source: input.source,
|
||||
related_ids: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 快速捕获(仅标题)
|
||||
pub fn quick_capture(title: String) -> Idea {
|
||||
Self::capture(CaptureInput {
|
||||
title,
|
||||
description: String::new(),
|
||||
priority: Priority::default(),
|
||||
tags: Vec::new(),
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//! 想法评估器 — 对想法进行多维度评估
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
|
||||
use crate::adversarial::{AdversarialEngine, AdversarialEval};
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::IdeaScores;
|
||||
|
||||
/// 评估维度
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum EvalDimension {
|
||||
/// 可行性
|
||||
Feasibility,
|
||||
/// 影响力
|
||||
Impact,
|
||||
/// 紧急度
|
||||
Urgency,
|
||||
}
|
||||
|
||||
/// 评估结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalResult {
|
||||
pub idea_id: IdeaId,
|
||||
pub scores: IdeaScores,
|
||||
pub recommendation: Recommendation,
|
||||
pub comments: Vec<String>,
|
||||
}
|
||||
|
||||
/// 评估建议
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Recommendation {
|
||||
/// 强烈推荐立即执行
|
||||
StrongApprove,
|
||||
/// 推荐执行
|
||||
Approve,
|
||||
/// 需要更多信息
|
||||
NeedsInfo,
|
||||
/// 建议推迟
|
||||
Defer,
|
||||
/// 不推荐
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// 想法评估器
|
||||
pub struct IdeaEvaluator;
|
||||
|
||||
impl IdeaEvaluator {
|
||||
/// 评估一个想法 - 使用对抗式评估
|
||||
pub async fn evaluate_adversarial(idea: &Idea) -> Result<AdversarialEval> {
|
||||
AdversarialEngine::evaluate(idea).await
|
||||
}
|
||||
|
||||
/// 评估一个想法 - 保持向后兼容
|
||||
pub fn evaluate(idea: &Idea) -> Result<EvalResult> {
|
||||
// 使用简单评分作为后备
|
||||
let scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||
|
||||
let recommendation = if scores.overall >= 8.0 {
|
||||
Recommendation::StrongApprove
|
||||
} else if scores.overall >= 6.0 {
|
||||
Recommendation::Approve
|
||||
} else if scores.overall >= 4.0 {
|
||||
Recommendation::NeedsInfo
|
||||
} else if scores.overall >= 2.0 {
|
||||
Recommendation::Defer
|
||||
} else {
|
||||
Recommendation::Reject
|
||||
};
|
||||
|
||||
Ok(EvalResult {
|
||||
idea_id: idea.id.clone(),
|
||||
scores,
|
||||
recommendation,
|
||||
comments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! 想法关联图 — 管理想法之间的关系
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
|
||||
/// 想法之间的关系类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RelationKind {
|
||||
/// 相似(语义相近)
|
||||
Similar,
|
||||
/// 依赖(A 依赖 B)
|
||||
DependsOn,
|
||||
/// 衍生(A 衍生自 B)
|
||||
DerivedFrom,
|
||||
/// 互补(A 和 B 可以互补)
|
||||
Complementary,
|
||||
}
|
||||
|
||||
/// 想法关系边
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Relation {
|
||||
pub source_id: IdeaId,
|
||||
pub target_id: IdeaId,
|
||||
pub kind: RelationKind,
|
||||
pub strength: f64, // 0.0 ~ 1.0
|
||||
}
|
||||
|
||||
/// 想法关联图
|
||||
pub struct IdeaGraph {
|
||||
/// 邻接表(idea_id -> 相关关系列表)
|
||||
edges: HashMap<IdeaId, Vec<Relation>>,
|
||||
}
|
||||
|
||||
impl IdeaGraph {
|
||||
/// 创建空图
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
edges: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加关系
|
||||
pub fn add_relation(&mut self, source_id: IdeaId, target_id: IdeaId, kind: RelationKind, strength: f64) {
|
||||
let relation = Relation {
|
||||
source_id: source_id.clone(),
|
||||
target_id: target_id.clone(),
|
||||
kind,
|
||||
strength,
|
||||
};
|
||||
self.edges.entry(source_id).or_default().push(relation.clone());
|
||||
self.edges.entry(target_id).or_default().push(relation);
|
||||
}
|
||||
|
||||
/// 获取与指定想法相关的所有关系
|
||||
pub fn get_relations(&self, idea_id: &IdeaId) -> Vec<&Relation> {
|
||||
self.edges.get(idea_id).map(|r| r.iter().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 查找相似想法
|
||||
pub fn find_similar(&self, idea_id: &IdeaId) -> Vec<&Relation> {
|
||||
self.get_relations(idea_id)
|
||||
.into_iter()
|
||||
.filter(|r| r.kind == RelationKind::Similar)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TODO: 基于向量相似度的自动关联发现
|
||||
// TODO: 图遍历、聚类算法
|
||||
}
|
||||
|
||||
impl Default for IdeaGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! df-ideas: 想法池 — 捕获、评估、评分、关联图、晋升
|
||||
//! df-ideas: 想法池 — 捕获、评估、评分、晋升
|
||||
|
||||
pub mod adversarial;
|
||||
mod adversarial_helpers;
|
||||
pub mod capture;
|
||||
pub mod evaluator;
|
||||
pub mod graph;
|
||||
pub mod promotion;
|
||||
pub mod scoring;
|
||||
pub mod scoring_keywords;
|
||||
|
||||
@@ -1,91 +1,16 @@
|
||||
//! 想法晋升 — 将想法转为项目
|
||||
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
|
||||
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
|
||||
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use df_core::types::{IdeaId, ProjectId};
|
||||
use df_types::types::{IdeaId, ProjectId};
|
||||
|
||||
use crate::capture::Idea;
|
||||
use crate::evaluator::Recommendation;
|
||||
|
||||
/// 晋升结果
|
||||
#[derive(Debug, Clone)]
|
||||
/// 晋升结果(promote_idea IPC 返回类型)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PromotionResult {
|
||||
pub idea_id: IdeaId,
|
||||
pub project_id: ProjectId,
|
||||
pub promoted: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// 晋升策略
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PromotionPolicy {
|
||||
/// 自动晋升(评分达标时自动转为项目)
|
||||
Auto,
|
||||
/// 手动确认(需要人工审批)
|
||||
Manual,
|
||||
/// 半自动(AI 评估 + 人工确认)
|
||||
SemiAuto,
|
||||
}
|
||||
|
||||
/// 想法晋升器
|
||||
pub struct IdeaPromoter {
|
||||
policy: PromotionPolicy,
|
||||
}
|
||||
|
||||
impl IdeaPromoter {
|
||||
/// 创建晋升器
|
||||
pub fn new(policy: PromotionPolicy) -> Self {
|
||||
Self { policy }
|
||||
}
|
||||
|
||||
/// 评估并尝试晋升想法
|
||||
///
|
||||
/// TODO: 接入 df-project 创建项目
|
||||
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
|
||||
match self.policy {
|
||||
PromotionPolicy::Auto => {
|
||||
if matches!(recommendation, Recommendation::StrongApprove | Recommendation::Approve) {
|
||||
self.do_promote(idea)
|
||||
} else {
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: format!("评估建议 {:?},未达到自动晋升标准", recommendation),
|
||||
})
|
||||
}
|
||||
}
|
||||
PromotionPolicy::Manual => {
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: "手动模式,等待人工审批".to_string(),
|
||||
})
|
||||
}
|
||||
PromotionPolicy::SemiAuto => {
|
||||
// TODO: AI 评估 + 人工确认流程
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: "半自动模式,等待确认".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行晋升操作
|
||||
fn do_promote(&self, idea: &Idea) -> Result<PromotionResult> {
|
||||
let project_id = df_core::types::new_id();
|
||||
// TODO: 调用 df-project 创建项目
|
||||
tracing::info!("想法 {} 晋升为项目 {}", idea.id, project_id);
|
||||
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id,
|
||||
promoted: true,
|
||||
reason: "评估达标,自动晋升".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+273
-19
@@ -1,10 +1,25 @@
|
||||
//! 评分引擎 — 多维度加权评分
|
||||
//! 评分引擎 — 基于想法内容的多维度启发式评分
|
||||
//!
|
||||
//! 各维度分数均为 0-10(IPC 层会 *10 缩放为 0-100 以匹配前端)。
|
||||
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
|
||||
//! TODO: 接入 AI 做语义级深度评分。
|
||||
|
||||
use crate::capture::Idea;
|
||||
|
||||
/// 想法评分详情(重新导出 capture 模块中的定义)
|
||||
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 {
|
||||
@@ -28,17 +43,12 @@ pub struct ScoringEngine;
|
||||
|
||||
impl ScoringEngine {
|
||||
/// 使用默认权重计算评分
|
||||
///
|
||||
/// TODO: 接入 AI 进行深度评分,当前返回基于启发式的分数
|
||||
pub fn compute_default(idea: &Idea) -> IdeaScores {
|
||||
let weights = ScoringWeights::default();
|
||||
Self::compute(idea, &weights)
|
||||
Self::compute(idea, &ScoringWeights::default())
|
||||
}
|
||||
|
||||
/// 使用指定权重计算评分
|
||||
pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores {
|
||||
// TODO: 基于想法内容、历史数据、AI 分析等多维度评分
|
||||
// 当前使用基于启发式的占位评分
|
||||
let feasibility = Self::heuristic_feasibility(idea);
|
||||
let impact = Self::heuristic_impact(idea);
|
||||
let urgency = Self::heuristic_urgency(idea);
|
||||
@@ -55,21 +65,265 @@ impl ScoringEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启发式可行性评分
|
||||
fn heuristic_feasibility(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于描述复杂度、资源需求等评估
|
||||
5.0
|
||||
/// 启发式可行性评分(描述充实度 + 技术/资源信号词)
|
||||
fn heuristic_feasibility(idea: &Idea) -> f64 {
|
||||
let mut score = 5.0_f64;
|
||||
let desc = idea.description.trim();
|
||||
if !desc.is_empty() {
|
||||
score += 1.5;
|
||||
}
|
||||
let len = desc.chars().count();
|
||||
if (50..=500).contains(&len) {
|
||||
score += 1.0;
|
||||
} else if len > 500 {
|
||||
// 过长描述通常意味着实现复杂度上升
|
||||
score -= 0.5;
|
||||
}
|
||||
// 可行性正向信号
|
||||
let pos = count_any(desc, FEASIBILITY_POSITIVE_KEYWORDS);
|
||||
score += (pos as f64) * 0.5;
|
||||
// 复杂度负向信号
|
||||
let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
|
||||
score -= (neg as f64) * 0.6;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// 启发式影响力评分
|
||||
fn heuristic_impact(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于业务价值、用户影响等评估
|
||||
5.0
|
||||
/// 启发式影响力评分(优先级 + 价值信号词 + 标签广度)
|
||||
fn heuristic_impact(idea: &Idea) -> f64 {
|
||||
let mut score = match idea.priority {
|
||||
Priority::Critical => 8.0,
|
||||
Priority::High => 6.5,
|
||||
Priority::Medium => 5.0,
|
||||
Priority::Low => 3.5,
|
||||
};
|
||||
if !idea.tags.is_empty() {
|
||||
score += 0.5;
|
||||
// 标签越多影响面越广,上限 +1.0
|
||||
score += (idea.tags.len().min(4) as f64) * 0.25;
|
||||
}
|
||||
let desc = idea.description.trim();
|
||||
let value_hits = count_any(desc, IMPACT_KEYWORDS);
|
||||
score += (value_hits as f64) * 0.5;
|
||||
if desc.chars().count() > 100 {
|
||||
score += 0.5;
|
||||
}
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// 启发式紧急度评分
|
||||
fn heuristic_urgency(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于优先级、时间窗口等评估
|
||||
5.0
|
||||
/// 启发式紧急度评分(优先级 + 时效信号词)
|
||||
fn heuristic_urgency(idea: &Idea) -> f64 {
|
||||
let mut score = match idea.priority {
|
||||
Priority::Critical => 9.0,
|
||||
Priority::High => 7.0,
|
||||
Priority::Medium => 5.0,
|
||||
Priority::Low => 3.0,
|
||||
};
|
||||
let desc = idea.description.trim();
|
||||
let time_hits = count_any(desc, URGENCY_KEYWORDS);
|
||||
score += (time_hits as f64) * 0.5;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
||||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
||||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||||
///
|
||||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||||
/// 祈使否定「别」改用两字清单兜底(别去/别再/别要/别用 等,见 NEGATION_PREFIXES_2)。
|
||||
const NEGATION_PREFIXES_1: &[char] = &['不', '无', '非', '未', '没', '勿'];
|
||||
/// 两字否定前缀:紧邻关键词的前两字符为这些时该命中不计(「没有复用」「并非简单」「别去复用」)。
|
||||
const NEGATION_PREFIXES_2: &[&str] = &[
|
||||
"没有", "并非", "毫无", "不用", "不是",
|
||||
// 「别X」祈使否定:双字形式可精确匹配,规避「别具/别的/特别」等单字误伤
|
||||
"别去", "别再", "别要", "别用", "别做", "别给",
|
||||
];
|
||||
|
||||
/// 关键词在 text 的 `end` 字节位置前是否处于否定上下文(紧邻否定词)。
|
||||
///
|
||||
/// `end` 是关键词起始字节位置(由 `str::find` 返回,必在 char 边界)。取 `text[..end]`
|
||||
/// 末尾 1-2 个 char 判否定:单字否定(不/无/非/...)或两字否定(没有/并非/...)。
|
||||
///
|
||||
/// 仅取末尾 ≤2 个 char(rev().take(2) 流式),省去每次命中的 `Vec<char>` 分配。
|
||||
fn is_negated_before(text: &str, end: usize) -> bool {
|
||||
if end == 0 {
|
||||
return false;
|
||||
}
|
||||
let before = &text[..end];
|
||||
// 末尾最多 2 char,按 [次末, 末] 顺序收集(n=1 时仅末位)
|
||||
let tail: Vec<char> = before.chars().rev().take(2).collect();
|
||||
if tail.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 单字否定:末位 char(tail[0]) 命中
|
||||
if NEGATION_PREFIXES_1.contains(&tail[0]) {
|
||||
return true;
|
||||
}
|
||||
// 两字否定:需凑齐 2 char,顺序为 [末, 次末] 反转回原序 [次末, 末]
|
||||
if tail.len() == 2 {
|
||||
let last2: String = [tail[1], tail[0]].iter().collect();
|
||||
if NEGATION_PREFIXES_2.contains(&last2.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)。
|
||||
///
|
||||
/// 否定前缀处理:关键词紧邻否定词(不复用/无增长/非紧急...)时该命中不计,
|
||||
/// 避免「不复用」反向计入「复用」正向分。双重否定(「不是不复用」)属罕见边缘,
|
||||
/// 不处理(接 LLM 后语义层兜底)。
|
||||
fn count_any(text: &str, keywords: &[&str]) -> usize {
|
||||
let lower = text.to_lowercase();
|
||||
keywords
|
||||
.iter()
|
||||
.filter(|kw| {
|
||||
// 至少一个非否定上下文的出现即计为命中(同一词多次出现,一次有效即可)
|
||||
let mut idx = 0usize;
|
||||
while let Some(rel) = lower[idx..].find(*kw) {
|
||||
let abs = idx + rel;
|
||||
if !is_negated_before(&lower, abs) {
|
||||
return true;
|
||||
}
|
||||
idx = abs + kw.len();
|
||||
}
|
||||
false
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::Idea;
|
||||
use df_types::types::{IdeaStatus, Priority};
|
||||
|
||||
/// 辅助工厂:构造测试用 Idea(时间/ID 用默认值,不影响评分)
|
||||
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
|
||||
Idea {
|
||||
id: "test-id".to_string(),
|
||||
title: title.to_string(),
|
||||
description: desc.to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
scores: None,
|
||||
tags: tags.into_iter().map(String::from).collect(),
|
||||
source: None,
|
||||
related_ids: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s1_empty_idea_baseline() {
|
||||
let idea = make_idea("测试想法", "", Priority::Medium, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s1] 空想法 (Medium / 无描述 / 无标签)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!((s.overall - 5.0).abs() < 0.01, "空想法 overall 应为 5.0, 实际 {:.2}", s.overall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s2_high_priority_value_desc() {
|
||||
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件,快速交付价值。".repeat(3);
|
||||
let idea = make_idea("增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s2] 高优先级 + 价值描述 (Critical / ~120字 / 含价值词)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.impact >= 7.0, "impact 应≥7.0, 实际 {:.2}", s.impact);
|
||||
assert!(s.urgency >= 8.0, "urgency 应≥8.0, 实际 {:.2}", s.urgency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_low_priority_short_desc() {
|
||||
let idea = make_idea("小优化", "一句话", Priority::Low, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s3] 低优先级 + 短描述 (Low / 3字)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!((s.overall - 4.6).abs() < 0.01, "overall 应为 4.6, 实际 {:.2}", s.overall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s4_feasibility_positive_signals() {
|
||||
let idea = make_idea("复用方案", "复用已有组件,简单集成现成脚手架", Priority::Medium, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s4] 可行性正向信号 (含 复用/已有/简单/集成/现成/脚手架)");
|
||||
println!(" 可行性={:.2} (预期 9.5)", s.feasibility);
|
||||
assert!((s.feasibility - 9.5).abs() < 0.01, "正向信号 feasibility 应为 9.5, 实际 {:.2}", s.feasibility);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s5_feasibility_negative_signals() {
|
||||
let idea = make_idea("大重构", "大规模重构迁移,分布式重写从零开始", Priority::Medium, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s5] 可行性负向信号 (含 大规模/重构/迁移/分布式/重写/从零)");
|
||||
println!(" 可行性={:.2} (预期 ≤5.0)", s.feasibility);
|
||||
assert!(s.feasibility <= 5.0, "负向信号 feasibility 应≤5.0, 实际 {:.2}", s.feasibility);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s6_custom_weights() {
|
||||
let idea = make_idea("高可行低影响", "复用已有简单集成现成", Priority::Low, vec![]);
|
||||
let custom = ScoringWeights { feasibility: 0.7, impact: 0.2, urgency: 0.1 };
|
||||
let s_custom = ScoringEngine::compute(&idea, &custom);
|
||||
let s_default = ScoringEngine::compute_default(&idea);
|
||||
let manual = s_custom.feasibility * 0.7 + s_custom.impact * 0.2 + s_custom.urgency * 0.1;
|
||||
println!("\n[s6] 自定义权重 (feas:0.7 / impact:0.2 / urg:0.1)");
|
||||
println!(" 自定义综合={:.2} 默认综合={:.2} 手算加权={:.2}", s_custom.overall, s_default.overall, manual);
|
||||
assert!((s_custom.overall - manual).abs() < 0.01, "overall 应等于手算加权");
|
||||
assert!(s_custom.overall > s_default.overall, "高 feas 配高权重应让综合更高");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s7_clamp_upper_bound() {
|
||||
let desc = "复用已有简单集成现成成熟基于脚手架模板支持".repeat(20);
|
||||
let idea = make_idea("满分想法", &desc, Priority::Critical, vec!["a", "b", "c", "d"]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s7] clamp 上限 (堆正向词 + 超长描述 + Critical)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.feasibility <= 10.0 && s.impact <= 10.0 && s.urgency <= 10.0, "所有维度应≤10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s8_clamp_lower_bound() {
|
||||
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".repeat(20);
|
||||
let idea = make_idea("灾难想法", &desc, Priority::Low, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s8] clamp 下限 (堆负向词 + Low)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.feasibility >= 0.0 && s.impact >= 0.0 && s.urgency >= 0.0, "所有维度应≥0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s9_single_char_negation_suppresses_hit() {
|
||||
// 「不复用已有组件」:复用 被「不」否定不计,已有 命中 → feasibility 低于「复用已有组件」(复用+已有 双命中)
|
||||
let neg = make_idea("不复用", "不复用已有组件", Priority::Medium, vec![]);
|
||||
let pos = make_idea("复用", "复用已有组件", Priority::Medium, vec![]);
|
||||
let s_neg = ScoringEngine::compute_default(&neg);
|
||||
let s_pos = ScoringEngine::compute_default(&pos);
|
||||
println!("\n[s9] 单字否定前缀抑制命中");
|
||||
println!(" 不复用已有组件 feasibility={:.2} 复用已有组件 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||||
assert!(
|
||||
s_neg.feasibility < s_pos.feasibility,
|
||||
"单字否定应抑制正向命中致 feasibility 更低: neg={} pos={}",
|
||||
s_neg.feasibility, s_pos.feasibility
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s10_two_char_negation_suppresses_hit() {
|
||||
// 「没有复用」:两字否定 没有 抑制 复用 命中;「确实复用」无否定 复用 命中
|
||||
let neg = make_idea("t", "没有复用", Priority::Medium, vec![]);
|
||||
let pos = make_idea("t", "确实复用", Priority::Medium, vec![]);
|
||||
let s_neg = ScoringEngine::compute_default(&neg);
|
||||
let s_pos = ScoringEngine::compute_default(&pos);
|
||||
println!("\n[s10] 两字否定前缀抑制命中");
|
||||
println!(" 没有复用 feasibility={:.2} 确实复用 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||||
assert!(
|
||||
s_neg.feasibility < s_pos.feasibility,
|
||||
"两字否定「没有」应抑制命中: neg={} pos={}", s_neg.feasibility, s_pos.feasibility
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "截止", "先行", "阻塞",
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "df-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "DevFlow MCP Server — 对外暴露数据层工具(JSON-RPC 2.0 over stdio)"
|
||||
|
||||
[dependencies]
|
||||
df-storage = { path = "../df-storage" }
|
||||
df-types = { path = "../df-types" }
|
||||
# df-nodes: advance_task 复用推进链唯一 status 写入路径(advance_task_atomic,
|
||||
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
|
||||
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
|
||||
df-nodes = { path = "../df-nodes" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
futures = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full", "test-util"] }
|
||||
@@ -0,0 +1,19 @@
|
||||
//! df-mcp: DevFlow MCP Server — 对外暴露数据层工具
|
||||
//!
|
||||
//! 形态:独立 stdio JSON-RPC 2.0 server。手写最小化协议层(不引外部 MCP SDK),
|
||||
//! 复用 df-storage Repo 做实际数据读写,不重复 CRUD 实现。
|
||||
//!
|
||||
//! 安全降级(对抗评估反方核心):
|
||||
//! - High 风险(delete/purge/run_workflow)默认拒绝,返友好提示
|
||||
//! - Medium 风险(create/update/bind)默认允许 + 日志(tracing::warn)
|
||||
//! - `--read-only` 仅暴露 list/get 工具
|
||||
//! - 不暴露文件系统工具(防绕过应用内路径校验)
|
||||
//! - 仅 stdio / 本地,不暴露网络;无认证(依赖本地隔离)
|
||||
//!
|
||||
//! 入口: [`run_server`] — 由 Tauri CLI 子命令 `devflow mcp-server` 调用。
|
||||
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
|
||||
pub use server::run_server;
|
||||
@@ -0,0 +1,201 @@
|
||||
//! JSON-RPC 2.0 over stdio 协议层
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 按行读 stdin,解析 JSON-RPC Request/Notification
|
||||
//! - 构造 Response / Error 对象并序列化为单行 JSON 写 stdout
|
||||
//! - MCP 三类方法路由(initialize / tools/list / tools/call)由上层 dispatch
|
||||
//!
|
||||
//! 不解析 batch 请求(MCP 客户端实测逐条发,单条足够)。
|
||||
//! 错误码遵循 JSON-RPC 2.0 + MCP 自定义段(-32xxx 协议层 / 0xffff 工具层)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ============================================================
|
||||
// JSON-RPC 2.0 错误码
|
||||
// ============================================================
|
||||
|
||||
/// 协议层错误(JSON-RPC 2.0 预留段 -32000..-32099)
|
||||
pub const PARSE_ERROR: i32 = -32700;
|
||||
pub const INVALID_REQUEST: i32 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i32 = -32601;
|
||||
pub const INVALID_PARAMS: i32 = -32602;
|
||||
pub const INTERNAL_ERROR: i32 = -32603;
|
||||
|
||||
// ============================================================
|
||||
// 请求 / 响应数据结构
|
||||
// ============================================================
|
||||
|
||||
/// JSON-RPC 2.0 请求(请求与通知共用,Notification 时 id=None)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Request {
|
||||
pub jsonrpc: Option<String>,
|
||||
pub id: Option<Value>,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 错误对象
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorObject {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 响应
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Response {
|
||||
pub jsonrpc: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ErrorObject>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// 成功响应
|
||||
pub fn ok(id: Option<Value>, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
pub fn err(id: Option<Value>, code: i32, message: impl Into<String>, data: Option<Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: None,
|
||||
error: Some(ErrorObject {
|
||||
code,
|
||||
message: message.into(),
|
||||
data,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MCP 方法路由 — 暴露给上层 dispatch 的 enum
|
||||
// ============================================================
|
||||
|
||||
/// MCP 协议层方法(已剥离 JSON-RPC 包装)。未知方法返回 `Unknown`,由 dispatch 回 METHOD_NOT_FOUND。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum McpMethod {
|
||||
/// initialize 握手:返 server info + capabilities
|
||||
Initialize { params: Value },
|
||||
/// initialized 通知:客户端确认握手完成,无需响应
|
||||
Initialized,
|
||||
/// tools/list:列出所有工具(MCP Tool schema)
|
||||
ToolsList,
|
||||
/// tools/call:调用某工具
|
||||
ToolsCall { name: String, arguments: Value },
|
||||
/// ping 心跳(MCP 2025 规范),回空 result
|
||||
Ping,
|
||||
/// 未识别的方法(回 METHOD_NOT_FOUND)
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl McpMethod {
|
||||
/// 从 Request 路由出 MCP 语义方法。
|
||||
pub fn from_request(req: &Request) -> Self {
|
||||
match req.method.as_str() {
|
||||
"initialize" => McpMethod::Initialize { params: req.params.clone() },
|
||||
"notifications/initialized" => McpMethod::Initialized,
|
||||
"tools/list" => McpMethod::ToolsList,
|
||||
"tools/call" => {
|
||||
let name = req
|
||||
.params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let arguments = req.params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
McpMethod::ToolsCall { name, arguments }
|
||||
}
|
||||
"ping" => McpMethod::Ping,
|
||||
other => McpMethod::Unknown(other.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MCP 协议数据结构(initialize 握手 / Tool schema / CallToolResult)
|
||||
// ============================================================
|
||||
|
||||
/// MCP initialize 握手响应结构(server info + capabilities)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InitializeResult {
|
||||
#[serde(rename = "protocolVersion")]
|
||||
pub protocol_version: &'static str,
|
||||
pub capabilities: Capabilities,
|
||||
#[serde(rename = "serverInfo")]
|
||||
pub server_info: ServerInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Capabilities {
|
||||
pub tools: ToolsCapability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolsCapability {
|
||||
#[serde(rename = "listChanged")]
|
||||
pub list_changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
}
|
||||
|
||||
/// MCP Tool 描述符(tools/list 返回数组元素)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Tool {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// MCP tools/call 成功响应(CallToolResult)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CallToolResult {
|
||||
pub content: Vec<ContentBlock>,
|
||||
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// MCP 内容块(本 server 仅用 text)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
impl CallToolResult {
|
||||
/// 文本成功响应
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本错误响应(is_error=true 让客户端区分「工具业务错」与「协议错」)
|
||||
pub fn error(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
is_error: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! MCP server 主循环 — stdio JSON-RPC 2.0
|
||||
//!
|
||||
//! 协议流程:
|
||||
//! 1. 客户端发 initialize → 回 serverInfo + capabilities.tools
|
||||
//! 2. 客户端发 notifications/initialized(通知,不回)
|
||||
//! 3. 客户端发 tools/list → 回工具清单(read-only 模式过滤)
|
||||
//! 4. 客户端发 tools/call → dispatch 到 handler
|
||||
//!
|
||||
//! 高风险(High)工具:tools/list 不暴露(从清单剔除),tools/call 即便绕过也由 handler 兜底拒绝。
|
||||
//! read-only:tools/list 仅留 Low,tools/call Medium/High 一律拒绝。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_storage::db::Database;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::protocol::{
|
||||
CallToolResult, Capabilities, InitializeResult, McpMethod, Request, Response, ServerInfo,
|
||||
ToolsCapability,
|
||||
};
|
||||
use crate::tools::{self, Ctx, RiskLevel};
|
||||
|
||||
/// 协议版本(MCP 2025-06-18)
|
||||
const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const SERVER_NAME: &str = "devflow-mcp";
|
||||
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 启动 MCP server。
|
||||
///
|
||||
/// - `db_path`:SQLite 数据库文件路径(应用同库,开 WAL 模式)
|
||||
/// - `read_only`:true 则仅暴露 list/get 工具
|
||||
///
|
||||
/// 复用 [`Database::open`](df_storage::Database::open)(已含 `PRAGMA journal_mode=WAL`),
|
||||
/// 故 SQLite WAL 状态:随 df-storage 一起已启用,无需额外处理。
|
||||
pub async fn run_server(db_path: &Path, read_only: bool) -> anyhow::Result<()> {
|
||||
let db = Arc::new(Database::open(db_path).await?);
|
||||
let ctx = Ctx::new(db);
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
main_loop(stdin, stdout, &ctx, read_only).await
|
||||
}
|
||||
|
||||
/// 可单测的主循环(参数化 stdin/stdout)。
|
||||
///
|
||||
/// 协议正确性:每行一个 JSON-RPC 消息,Response 单行写回(末尾 \n)。
|
||||
/// Notification(id=None)不回响应。
|
||||
pub async fn main_loop<R, W>(
|
||||
stdin: R,
|
||||
stdout: W,
|
||||
ctx: &Ctx,
|
||||
read_only: bool,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut writer = stdout;
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
// EOF(stdin 关闭),优雅退出
|
||||
break;
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析 JSON-RPC 请求
|
||||
let req: Request = match serde_json::from_str(trimmed) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// 解析失败:无 id 时无法回响应,只能 log;有 id(尽力猜)回 PARSE_ERROR
|
||||
tracing::warn!(target: "df_mcp", line = %trimmed, err = %e, "解析 JSON-RPC 行失败");
|
||||
let resp = Response::err(None, crate::protocol::PARSE_ERROR, "Parse error", None);
|
||||
write_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_notification = req.id.is_none();
|
||||
let method = McpMethod::from_request(&req);
|
||||
|
||||
// Notification 不回响应(除解析错外)
|
||||
if is_notification {
|
||||
match method {
|
||||
McpMethod::Initialized => {
|
||||
tracing::debug!(target: "df_mcp", "客户端 initialized 通知已收");
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(target: "df_mcp", m = ?method, "忽略未识别通知");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let resp = dispatch(ctx, read_only, req.id.clone(), method).await;
|
||||
write_response(&mut writer, &resp).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 方法分发 → 构造 Response。
|
||||
///
|
||||
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
|
||||
async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, method: McpMethod) -> Response {
|
||||
match method {
|
||||
McpMethod::Initialize { .. } => {
|
||||
let result = InitializeResult {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
capabilities: Capabilities {
|
||||
tools: ToolsCapability { list_changed: false },
|
||||
},
|
||||
server_info: ServerInfo {
|
||||
name: SERVER_NAME,
|
||||
version: SERVER_VERSION,
|
||||
},
|
||||
};
|
||||
Response::ok(
|
||||
id,
|
||||
serde_json::to_value(result).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
McpMethod::Initialized => {
|
||||
// 通知分支(由 main_loop 已过滤);到这里说明带 id 的 initialized,回空 result
|
||||
Response::ok(id, Value::Object(Default::default()))
|
||||
}
|
||||
McpMethod::ToolsList => {
|
||||
let tools: Vec<_> = tools::all_tools()
|
||||
.into_iter()
|
||||
.filter(|t| visible(read_only, t.risk))
|
||||
.map(|t| serde_json::to_value(&t.tool).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
Response::ok(id, json!({ "tools": tools }))
|
||||
}
|
||||
McpMethod::Ping => Response::ok(id, Value::Object(Default::default())),
|
||||
McpMethod::ToolsCall { name, arguments } => {
|
||||
// 查工具
|
||||
let Some(spec) = tools::find(&name) else {
|
||||
return Response::ok(
|
||||
id,
|
||||
serde_json::to_value(CallToolResult::error(format!("未知工具: {name}")))
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
};
|
||||
// read-only 模式:Medium/High 全拒
|
||||
if read_only && spec.risk != RiskLevel::Low {
|
||||
let r = CallToolResult::error(format!(
|
||||
"只读模式拒绝执行 {name}(风险等级 {:?})",
|
||||
spec.risk
|
||||
));
|
||||
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
||||
}
|
||||
// 非 read-only:High 兜底拒绝(handler 内也会拒,双保险)
|
||||
if spec.risk == RiskLevel::High {
|
||||
let r = CallToolResult::error(format!(
|
||||
"High 风险操作 {name} 默认拒绝,请在 DevFlow 应用内执行。"
|
||||
));
|
||||
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
|
||||
}
|
||||
// Low / Medium:执行
|
||||
let result = (spec.handler)(ctx, arguments).await;
|
||||
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
|
||||
}
|
||||
McpMethod::Unknown(m) => Response::err(
|
||||
id,
|
||||
crate::protocol::METHOD_NOT_FOUND,
|
||||
format!("Method not found: {m}"),
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具可见性:read-only 仅 Low,否则 Low + Medium(High 永不可见)
|
||||
fn visible(read_only: bool, risk: RiskLevel) -> bool {
|
||||
if read_only {
|
||||
risk == RiskLevel::Low
|
||||
} else {
|
||||
risk != RiskLevel::High
|
||||
}
|
||||
}
|
||||
|
||||
/// 单行写 Response(末尾 \n,MCP 客户端按行 framing)
|
||||
async fn write_response<W: tokio::io::AsyncWrite + Unpin>(
|
||||
writer: &mut W,
|
||||
resp: &Response,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut buf = serde_json::to_string(resp)?;
|
||||
buf.push('\n');
|
||||
writer.write_all(buf.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:协议 framing / initialize / tools/list / tools/call / High 拒绝 / read-only 过滤
|
||||
// ============================================================
|
||||
//
|
||||
// 测试驱动 dispatch()(协议层逻辑核心),不跑真实 stdio(真实 stdio 需集成测试)。
|
||||
// main_loop 的 framing(write_response 单行 +\n、notification 不回)由 dispatch 的输入输出间接覆盖。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::RiskLevel;
|
||||
|
||||
/// 构造内存 DB + Ctx
|
||||
async fn test_ctx() -> Ctx {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
Ctx::new(db)
|
||||
}
|
||||
|
||||
/// 直接驱动 dispatch:喂一行 → 收一行(逐行,notification 跳过)
|
||||
async fn run_io_lines(lines: &[&str], read_only: bool) -> Vec<String> {
|
||||
let ctx = test_ctx().await;
|
||||
let mut out = Vec::new();
|
||||
for line in lines {
|
||||
let req: Request = serde_json::from_str(line).unwrap();
|
||||
let is_notif = req.id.is_none();
|
||||
let method = McpMethod::from_request(&req);
|
||||
if is_notif {
|
||||
continue;
|
||||
}
|
||||
let resp = dispatch(&ctx, read_only, req.id.clone(), method).await;
|
||||
out.push(serde_json::to_string(&resp).unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_returns_server_info_and_capabilities() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
assert_eq!(out.len(), 1);
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0");
|
||||
assert_eq!(v["id"], 1);
|
||||
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
|
||||
assert_eq!(v["result"]["serverInfo"]["name"], SERVER_NAME);
|
||||
// capabilities.tools 存在
|
||||
assert!(v["result"]["capabilities"]["tools"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_excludes_high_risk_by_default() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let tools = v["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
// 默认(非 read-only):Low + Medium 可见,High 不可见
|
||||
assert!(names.contains(&"list_projects"));
|
||||
assert!(names.contains(&"create_project")); // Medium
|
||||
assert!(!names.contains(&"delete_project")); // High
|
||||
assert!(!names.contains(&"run_workflow")); // High
|
||||
// MCP Tool schema:name + inputSchema
|
||||
let first = &tools[0];
|
||||
assert!(first["name"].is_string());
|
||||
assert!(first["inputSchema"]["type"] == "object");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_read_only_keeps_only_low() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#;
|
||||
let out = run_io_lines(&[input], true).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let tools = v["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"list_projects"));
|
||||
assert!(!names.contains(&"create_project")); // Medium 被过滤
|
||||
assert!(!names.contains(&"delete_project")); // High
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_unknown_method_returns_method_not_found() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":4,"method":"bogus/method","params":{}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
assert_eq!(v["error"]["code"], crate::protocol::METHOD_NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_high_risk_is_rejected() {
|
||||
let input =
|
||||
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"delete_project","arguments":{"id":"x"}}}"#;
|
||||
let out = run_io_lines(&[input], false).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let content = v["result"]["content"].as_array().unwrap();
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
let text = content[0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("拒绝") || text.contains("High"));
|
||||
assert_eq!(v["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_read_only_rejects_medium() {
|
||||
let input =
|
||||
r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"t","description":"d"}}}"#;
|
||||
let out = run_io_lines(&[input], true).await;
|
||||
let v: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let text = v["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("只读模式拒绝"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_create_then_list_project_roundtrip() {
|
||||
let create =
|
||||
r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"McpProj","description":"via mcp"}}}"#;
|
||||
let list =
|
||||
r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
||||
let out = run_io_lines(&[create, list], false).await;
|
||||
let created: Value = serde_json::from_str(&out[0]).unwrap();
|
||||
let id = created["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let created_v: Value = serde_json::from_str(id).unwrap();
|
||||
assert!(created_v["id"].is_string());
|
||||
|
||||
let listed: Value = serde_json::from_str(&out[1]).unwrap();
|
||||
let list_text = listed["result"]["content"][0]["text"].as_str().unwrap();
|
||||
let list_v: Value = serde_json::from_str(list_text).unwrap();
|
||||
assert_eq!(list_v["count"], 1);
|
||||
assert_eq!(list_v["projects"][0]["name"], "McpProj");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_predicate() {
|
||||
assert!(visible(false, RiskLevel::Low));
|
||||
assert!(visible(false, RiskLevel::Medium));
|
||||
assert!(!visible(false, RiskLevel::High));
|
||||
assert!(visible(true, RiskLevel::Low));
|
||||
assert!(!visible(true, RiskLevel::Medium));
|
||||
assert!(!visible(true, RiskLevel::High));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
||||
//!
|
||||
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
||||
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
||||
//!
|
||||
//! 安全降级:
|
||||
//! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」
|
||||
//! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志
|
||||
//! - [`RiskLevel::Low`]:默认允许,只读无副作用
|
||||
//! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见
|
||||
//!
|
||||
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
|
||||
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
|
||||
|
||||
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 futures::future::BoxFuture;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::protocol::{CallToolResult, Tool};
|
||||
|
||||
// ============================================================
|
||||
// 风险等级
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskLevel {
|
||||
/// 只读无副作用:list/get
|
||||
Low,
|
||||
/// 有副作用但可逆:create/update/bind/restore/advance
|
||||
Medium,
|
||||
/// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝
|
||||
High,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具元数据 + 上下文
|
||||
// ============================================================
|
||||
|
||||
/// 工具元数据 + 风险等级 + handler 函数指针。
|
||||
pub struct ToolSpec {
|
||||
pub tool: Tool,
|
||||
pub risk: RiskLevel,
|
||||
pub handler: HandlerFn,
|
||||
}
|
||||
|
||||
/// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。
|
||||
pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>;
|
||||
|
||||
/// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。
|
||||
///
|
||||
/// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc<Database> clone 廉价。
|
||||
pub struct Ctx {
|
||||
pub db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// inputSchema 构造助手
|
||||
// ============================================================
|
||||
|
||||
fn object_schema(properties: Value, required: &[&str]) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn str_field(desc: &str) -> Value {
|
||||
json!({ "type": "string", "description": desc })
|
||||
}
|
||||
|
||||
fn opt_str_field(desc: &str) -> Value {
|
||||
json!({ "type": "string", "description": desc })
|
||||
}
|
||||
|
||||
fn int_field(desc: &str) -> Value {
|
||||
json!({ "type": "integer", "description": desc })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具清单
|
||||
// ============================================================
|
||||
|
||||
/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。
|
||||
pub fn all_tools() -> Vec<&'static ToolSpec> {
|
||||
use RiskLevel::*;
|
||||
vec![
|
||||
// ─── 项目 ───
|
||||
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
|
||||
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
|
||||
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
|
||||
spec("update_project", "更新项目(整体替换 description/status/path/stack)", object_schema(json!({"id": str_field("项目 ID"), "name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态")}), &["id", "name", "description"]), Medium, update_project),
|
||||
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
|
||||
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
|
||||
// ─── 任务 ───
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
|
||||
spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task),
|
||||
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
||||
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
||||
// ─── 灵感 ───
|
||||
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
|
||||
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
|
||||
spec("update_idea", "更新想法(整体替换)", object_schema(json!({"id": str_field("想法 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "title", "description"]), Medium, update_idea),
|
||||
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
|
||||
spec("evaluate_idea", "对想法做启发式评估(只读,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
||||
// ─── 工作流(High) ───
|
||||
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
|
||||
// ─── 回收站 ───
|
||||
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
|
||||
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
|
||||
]
|
||||
}
|
||||
|
||||
/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spec(
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
input_schema: Value,
|
||||
risk: RiskLevel,
|
||||
handler: HandlerFn,
|
||||
) -> &'static ToolSpec {
|
||||
Box::leak(Box::new(ToolSpec {
|
||||
tool: Tool {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
},
|
||||
risk,
|
||||
handler,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具查找
|
||||
// ============================================================
|
||||
|
||||
/// 按 name 查找工具(线性扫描,工具数 19,O(n) 足够)。
|
||||
pub fn find(name: &str) -> Option<&'static ToolSpec> {
|
||||
all_tools().into_iter().find(|t| t.tool.name == name)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 公共助手
|
||||
// ============================================================
|
||||
|
||||
fn now_millis() -> String {
|
||||
df_types::now_millis().to_string()
|
||||
}
|
||||
|
||||
fn json_ok(v: Value) -> CallToolResult {
|
||||
CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))
|
||||
}
|
||||
|
||||
fn err_str(e: impl std::fmt::Display) -> CallToolResult {
|
||||
CallToolResult::error(format!("内部错误: {e}"))
|
||||
}
|
||||
|
||||
fn medium_audit(name: &str, args_summary: &str) {
|
||||
tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用");
|
||||
}
|
||||
|
||||
/// 取必填字符串参数
|
||||
fn arg_str(args: &Value, key: &str) -> Result<String, CallToolResult> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_owned())
|
||||
.ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}")))
|
||||
}
|
||||
|
||||
/// 取可选字符串参数(默认值)
|
||||
fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(default)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// 取可选整数参数(默认值)
|
||||
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|i| i as i32)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 项目
|
||||
// ============================================================
|
||||
|
||||
fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.list_active().await {
|
||||
Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.get_by_id(&id).await {
|
||||
Ok(Some(p)) => json_ok(json!(p)),
|
||||
Ok(None) => CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let name = match arg_str(&args, "name") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
medium_audit("create_project", &name);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = ProjectRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "project": created }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let name = arg_str_or(&args, "name", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "active");
|
||||
medium_audit("update_project", &id);
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 先读现有保留 path/stack/idea_id
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let rec = ProjectRecord {
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
idea_id: existing.idea_id,
|
||||
path: existing.path,
|
||||
stack: existing.stack,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
match repo.update_full(&rec).await {
|
||||
Ok(true) => {
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "project": updated }))
|
||||
}
|
||||
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
// High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判)
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let path = match arg_str(&args, "path") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
let norm = normalize_path(&path);
|
||||
// 路径冲突检测
|
||||
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
|
||||
return CallToolResult::error(format!(
|
||||
"路径已被项目「{}」({})绑定,请先解绑",
|
||||
conflict.name, conflict.id
|
||||
));
|
||||
}
|
||||
// 仅更新 path 字段(用 update_field,保留其它)
|
||||
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
|
||||
return CallToolResult::error(format!("项目不存在: {id}"));
|
||||
}
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "project": updated }))
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 任务
|
||||
// ============================================================
|
||||
|
||||
fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.list_active().await {
|
||||
Ok(mut list) => {
|
||||
if let Some(pid) = &project_id_filter {
|
||||
list.retain(|t| t.project_id == *pid);
|
||||
}
|
||||
if let Some(st) = &status_filter {
|
||||
list.retain(|t| t.status == *st);
|
||||
}
|
||||
json_ok(json!({ "tasks": list, "count": list.len() }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let project_id = match arg_str(&args, "project_id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 0);
|
||||
medium_audit("create_task", &format!("{project_id}/{title}"));
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: new_id(),
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status: "todo".to_owned(),
|
||||
priority,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
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,
|
||||
};
|
||||
let repo = TaskRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "task": created }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let project_id = match arg_str(&args, "project_id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
medium_audit("update_task", &id);
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: id.clone(),
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
// 不允许经 MCP 改状态(状态机收口,须走 advance_task)
|
||||
status: existing.status,
|
||||
priority: existing.priority,
|
||||
branch_name: existing.branch_name,
|
||||
assignee: existing.assignee,
|
||||
workflow_def_id: existing.workflow_def_id,
|
||||
base_branch: existing.base_branch,
|
||||
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,
|
||||
};
|
||||
match repo.update_full(&rec).await {
|
||||
Ok(true) => {
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "task": updated }))
|
||||
}
|
||||
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
// 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition /
|
||||
// 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。
|
||||
let to = match arg_str(&args, "to") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("advance_task", &format!("{id} -> {to}"));
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
|
||||
// - is_valid_state + can_transition + 同态拒绝三层校验
|
||||
// - CAS 防 TOCTOU
|
||||
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
|
||||
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
|
||||
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
|
||||
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 灵感
|
||||
// ============================================================
|
||||
|
||||
fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
match repo.list_all().await {
|
||||
Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 0);
|
||||
medium_audit("create_idea", &title);
|
||||
Box::pin(async move {
|
||||
let now = now_millis();
|
||||
let rec = IdeaRecord {
|
||||
id: new_id(),
|
||||
title,
|
||||
description,
|
||||
status: "draft".to_owned(),
|
||||
priority,
|
||||
score: None,
|
||||
tags: None,
|
||||
source: Some("mcp".to_owned()),
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = IdeaRepo::new(&db);
|
||||
match repo.insert(rec).await {
|
||||
Ok(id) => {
|
||||
let created = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "idea": created }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = arg_str_or(&args, "title", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
medium_audit("update_idea", &id);
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let now = now_millis();
|
||||
let rec = IdeaRecord {
|
||||
id: id.clone(),
|
||||
title,
|
||||
description,
|
||||
status: existing.status,
|
||||
priority: existing.priority,
|
||||
score: existing.score,
|
||||
tags: existing.tags,
|
||||
source: existing.source,
|
||||
promoted_to: existing.promoted_to,
|
||||
ai_analysis: existing.ai_analysis,
|
||||
scores: existing.scores,
|
||||
related_ids: existing.related_ids.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
match repo.update_full(&rec).await {
|
||||
Ok(true) => {
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
json_ok(json!({ "id": id, "idea": updated }))
|
||||
}
|
||||
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
let idea = match repo.get_by_id(&id).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 启发式评分(本地确定性,不调 LLM)
|
||||
let scores = heuristic_scores(&idea.title, &idea.description);
|
||||
let now = now_millis();
|
||||
// 写回 scores 字段(整体更新)
|
||||
let mut rec = idea.clone();
|
||||
rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
|
||||
rec.updated_at = now;
|
||||
if let Err(e) = repo.update_full(&rec).await {
|
||||
return err_str(e);
|
||||
}
|
||||
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
|
||||
})
|
||||
}
|
||||
|
||||
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
|
||||
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
|
||||
fn heuristic_scores(title: &str, description: &str) -> Value {
|
||||
let desc_len = description.chars().count();
|
||||
let title_len = title.chars().count();
|
||||
// feasibility:描述越详细越可行(评估前已有思考)
|
||||
let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0);
|
||||
// impact:含「核心/关键/重要」等关键词加权
|
||||
let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
|
||||
let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
|
||||
let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0);
|
||||
// urgency:priority 字段不在此,用关键词近似
|
||||
let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"];
|
||||
let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
|
||||
let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0);
|
||||
// overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2)
|
||||
let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2;
|
||||
let _ = title_len; // 标题长度暂不入分(避免短标题被低估)
|
||||
json!({
|
||||
"feasibility": (feasibility * 10.0).round() / 10.0,
|
||||
"impact": (impact * 10.0).round() / 10.0,
|
||||
"urgency": (urgency * 10.0).round() / 10.0,
|
||||
"overall": (overall * 10.0).round() / 10.0
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 工作流(High,拒绝)
|
||||
// ============================================================
|
||||
|
||||
fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Box::pin(std::future::ready(CallToolResult::error(
|
||||
"High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。",
|
||||
)))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 回收站
|
||||
// ============================================================
|
||||
|
||||
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
Box::pin(async move {
|
||||
let projects = match ProjectRepo::new(&db).list_deleted().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
let tasks = match TaskRepo::new(&db).list_deleted().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
json_ok(json!({
|
||||
"projects": projects,
|
||||
"tasks": tasks,
|
||||
"project_count": projects.len(),
|
||||
"task_count": tasks.len()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("restore_project", &id);
|
||||
Box::pin(async move {
|
||||
match ProjectRepo::new(&db).restore(&id).await {
|
||||
Ok(true) => json_ok(json!({ "id": id, "restored": true })),
|
||||
Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
|
||||
// ============================================================
|
||||
|
||||
fn normalize_path(p: &str) -> String {
|
||||
match std::path::Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-types = { path = "../df-types" }
|
||||
df-execute = { path = "../df-execute" }
|
||||
df-workflow = { path = "../df-workflow" }
|
||||
df-ai = { path = "../df-ai" }
|
||||
df-storage = { path = "../df-storage" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
+345
-89
@@ -5,114 +5,72 @@
|
||||
//!
|
||||
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
||||
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
||||
//!
|
||||
//! 拆分边界(纯结构,逻辑等价):
|
||||
//! - provider 解析/参数构造/自审 JSON 解析等纯逻辑 → `ai_node_helpers`
|
||||
//! - AiSelfReviewNode(自审闭环独立节点) → `ai_self_review_node`
|
||||
//! - 本文件:仅 AiNode 的 struct + impl + tests。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_ai::anthropic_compat::AnthropicCompatProvider;
|
||||
use df_ai::openai_compat::OpenAICompatProvider;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
// AiNode::execute 用的纯函数(resolve_and_parse / provider_from_params);
|
||||
// 其余 helpers(parse_review_json/gate_should_block/REVIEW_SYSTEM_PROMPT/ResolvedProvider/
|
||||
// truncate_for_summary)由 ai_self_review_node 与 tests 引入,不在此处 glob。
|
||||
use crate::ai_node_helpers::{provider_from_params, resolve_and_parse};
|
||||
|
||||
/// AI 节点
|
||||
pub struct AiNode;
|
||||
///
|
||||
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry 工厂闭包 move 捕获
|
||||
/// db.clone(),对齐 TaskAdvanceNode 先例)。execute 从 NodeContext.config 读 provider 配置 +
|
||||
/// 可选 task_id:有 task_id 时把产出落 task.output_json(AiNode 自审闭环,决策 a)。
|
||||
pub struct AiNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl AiNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄)
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for AiNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// ── provider 配置(必填)──
|
||||
let base_url = ctx
|
||||
.config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
|
||||
.to_string();
|
||||
|
||||
let api_key = ctx
|
||||
.config
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
|
||||
.to_string();
|
||||
|
||||
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
|
||||
let prompt = ctx
|
||||
.inputs
|
||||
.get("prompt")
|
||||
.and_then(|o| o.data.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
ctx.config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
||||
|
||||
// ── 可选参数 ──
|
||||
let model = ctx
|
||||
.config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let temperature = ctx
|
||||
.config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32);
|
||||
let max_tokens = ctx
|
||||
.config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32);
|
||||
let system_prompt = ctx
|
||||
.config
|
||||
.get("system_prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 协议类型:默认 openai_compat,可设 anthropic(GLM 订阅端点 / Claude 官方)
|
||||
let protocol = ctx
|
||||
.config
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("openai_compat")
|
||||
.to_string();
|
||||
|
||||
// default_model:留空时给一个占位,避免 provider 构造 panic
|
||||
let default_model = if model.is_empty() {
|
||||
"gpt-4o-mini".to_string()
|
||||
} else {
|
||||
model.clone()
|
||||
};
|
||||
|
||||
let provider: Box<dyn LlmProvider> = match protocol.as_str() {
|
||||
"anthropic" => Box::new(AnthropicCompatProvider::new(&base_url, &api_key, &default_model)),
|
||||
_ => Box::new(OpenAICompatProvider::new(&base_url, &api_key, &default_model)),
|
||||
};
|
||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
||||
|
||||
// ── 构建消息 ──
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let Some(sys) = system_prompt {
|
||||
if let Some(sys) = p.system_prompt {
|
||||
messages.push(ChatMessage::system(sys));
|
||||
}
|
||||
messages.push(ChatMessage::user(prompt));
|
||||
messages.push(ChatMessage::user(p.prompt));
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: model.clone(),
|
||||
model: p.model.clone(),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens,
|
||||
temperature: p.temperature,
|
||||
max_tokens: p.max_tokens,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"AiNode 调用 LLM: model={}, base_url={}",
|
||||
default_model,
|
||||
base_url
|
||||
p.provider.default_model,
|
||||
p.provider.base_url
|
||||
);
|
||||
let response = provider.complete(request).await?;
|
||||
|
||||
@@ -123,7 +81,7 @@ impl Node for AiNode {
|
||||
response.usage.completion_tokens
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
let output = serde_json::json!({
|
||||
"text": response.text,
|
||||
"model": response.model,
|
||||
"usage": {
|
||||
@@ -131,7 +89,25 @@ impl Node for AiNode {
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
},
|
||||
})))
|
||||
});
|
||||
|
||||
// 决策 a(AiNode 自审闭环):有 task_id 时落产出到 task.output_json。
|
||||
// 走通用 update_field(output_json 已在 tasks 白名单,crud/mod.rs update_field 宏(impl_repo!)),
|
||||
// 非 status 状态机收口字段,合法可写。落库失败不阻断节点返回(产出已在内存,工作流可继续)。
|
||||
if let Some(task_id) = ctx.config.get("task_id").and_then(|v| v.as_str()) {
|
||||
if let Ok(json_str) = serde_json::to_string(&output) {
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
|
||||
tracing::warn!(
|
||||
task_id = task_id,
|
||||
error = %e,
|
||||
"AiNode 落 output_json 失败(产出已在内存,节点继续)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NodeOutput::from_value(output))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
@@ -141,14 +117,15 @@ impl Node for AiNode {
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "用户提示词(若无则取上游 prompt 输出)" },
|
||||
"system_prompt": { "type": "string", "description": "系统提示词(可选)" },
|
||||
"protocol": { "type": "string", "description": "协议类型:openai_compat(默认)或 anthropic(GLM订阅/Claude官方)" },
|
||||
"base_url": { "type": "string", "description": "API 地址,OpenAI 兼容如 https://api.deepseek.com;Anthropic 如 https://open.bigmodel.cn/api/anthropic" },
|
||||
"api_key": { "type": "string", "description": "API 密钥" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用默认)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
|
||||
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" }
|
||||
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" },
|
||||
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
|
||||
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;FR-S1 下经 secret 解析" }
|
||||
},
|
||||
"required": ["prompt", "base_url", "api_key"]
|
||||
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
|
||||
"required": []
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -165,3 +142,282 @@ impl Node for AiNode {
|
||||
"ai"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_node_helpers::{parse_params, resolve_provider, ResolvedProvider};
|
||||
use df_storage::crud::AiProviderRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 测试用 ResolvedProvider 桩:固定 protocol/base_url/api_key/default_model。
|
||||
fn provider_stub() -> ResolvedProvider {
|
||||
ResolvedProvider {
|
||||
protocol: "openai_compat".to_string(),
|
||||
base_url: "https://api.example.com".to_string(),
|
||||
api_key: "sk-test".to_string(),
|
||||
default_model: "gpt-4o-mini".to_string(),
|
||||
model_pool: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 造带基础 prompt 的 config(provider 经 resolve_provider 解析,parse_params 只看 prompt),
|
||||
/// overrides 覆盖或追加。
|
||||
fn config_with(overrides: serde_json::Value) -> serde_json::Value {
|
||||
let mut base = json!({
|
||||
"prompt": "config-prompt"
|
||||
});
|
||||
if let (serde_json::Value::Object(b), serde_json::Value::Object(o)) = (&mut base, overrides) {
|
||||
for (k, v) in o {
|
||||
b.insert(k, v);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn empty_inputs() -> HashMap<String, NodeOutput> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_errors() {
|
||||
let config = json!({});
|
||||
let err = parse_params(&config, &empty_inputs(), provider_stub())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("prompt"), "缺 prompt 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_from_config_when_no_upstream() {
|
||||
let p =
|
||||
parse_params(&config_with(json!({})), &empty_inputs(), provider_stub()).unwrap();
|
||||
assert_eq!(p.prompt, "config-prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_prefers_upstream_input_over_config() {
|
||||
let mut inputs = empty_inputs();
|
||||
inputs.insert(
|
||||
"prompt".to_string(),
|
||||
NodeOutput::from_value(json!("upstream-prompt")),
|
||||
);
|
||||
let p =
|
||||
parse_params(&config_with(json!({})), &inputs, provider_stub()).unwrap();
|
||||
assert_eq!(p.prompt, "upstream-prompt", "上游输入应优先于 config.prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_parsed_when_present() {
|
||||
let p = parse_params(
|
||||
&config_with(json!({
|
||||
"model": "glm-4",
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1024,
|
||||
"system_prompt": "你是助手"
|
||||
})),
|
||||
&empty_inputs(),
|
||||
provider_stub(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.model, "glm-4");
|
||||
assert_eq!(p.provider.protocol, "openai_compat");
|
||||
assert_eq!(p.temperature, Some(0.3));
|
||||
assert_eq!(p.max_tokens, Some(1024));
|
||||
assert_eq!(p.system_prompt.as_deref(), Some("你是助手"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// resolve_provider 双路径测试(FR-S1 注入链核心)
|
||||
// ============================================================
|
||||
|
||||
/// 内存 DB 插 provider(可选 is_default),返回 (db, provider_id)。
|
||||
async fn setup_provider_db(
|
||||
provider_id: &str,
|
||||
is_default: bool,
|
||||
api_key: &str,
|
||||
) -> (Database, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiProviderRepo::new(&db);
|
||||
repo.insert(AiProviderRecord {
|
||||
id: provider_id.to_string(),
|
||||
name: "测试 Provider".to_string(),
|
||||
provider_type: "openai_compat".to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
base_url: "https://api.example.com".to_string(),
|
||||
default_model: "glm-4-flash".to_string(),
|
||||
models: None,
|
||||
model_configs: Vec::new(),
|
||||
is_default,
|
||||
config: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
enabled: true,
|
||||
weight: 50,
|
||||
})
|
||||
.await
|
||||
.expect("insert provider");
|
||||
(db, provider_id.to_string())
|
||||
}
|
||||
|
||||
/// 路径 1:provider_id 优先 → resolve_provider 走下沉解析(DB api_key 非空→直接返回)。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_by_id_prefers_db_key() {
|
||||
let (db, pid) = setup_provider_db("prov-1", true, "sk-from-db").await;
|
||||
let config = json!({ "provider_id": pid });
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-from-db", "DB api_key 非空应直接返回");
|
||||
assert_eq!(resolved.base_url, "https://api.example.com");
|
||||
assert_eq!(resolved.protocol, "openai_compat");
|
||||
assert_eq!(
|
||||
resolved.default_model, "glm-4-flash",
|
||||
"无 config.model 时用 record.default_model"
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 1:config.model 覆盖 record.default_model。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_config_model_overrides_record() {
|
||||
let (db, pid) = setup_provider_db("prov-1m", true, "sk-db").await;
|
||||
let config = json!({ "provider_id": pid, "model": "glm-4" });
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.default_model, "glm-4");
|
||||
}
|
||||
|
||||
/// 路径 1:provider_id 不存在 → 友好错误。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_unknown_id_errors() {
|
||||
let (db, _pid) = setup_provider_db("prov-2", true, "sk-db").await;
|
||||
let config = json!({ "provider_id": "non-existent" });
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("不存在"), "未知 provider_id 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 路径 1:provider_id 指向但密钥空(DB 空 + keyring 无)→ ensure_resolved_key 报错。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_empty_key_errors() {
|
||||
// api_key 空:DB 无 → keyring 读不到 → 空 → ensure 报错
|
||||
let (db, pid) = setup_provider_db("prov-empty", true, "").await;
|
||||
let config = json!({ "provider_id": pid });
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("密钥") || err.contains("未读取到"),
|
||||
"空密钥应报错, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 2:老明文路径兼容(base_url+api_key 显式注入)→ 返回明文 + 不报错。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_legacy_plain_path_compat() {
|
||||
// 无 provider 且无 provider_id → 老明文路径兜底
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let config = json!({
|
||||
"base_url": "https://legacy.example.com",
|
||||
"api_key": "sk-legacy"
|
||||
});
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-legacy");
|
||||
assert_eq!(resolved.base_url, "https://legacy.example.com");
|
||||
assert_eq!(resolved.default_model, "gpt-4o-mini", "无 model 时占位兜底");
|
||||
}
|
||||
|
||||
/// 路径 2:老明文路径空 key → ensure_resolved_key 报错。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_legacy_empty_key_errors() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let config = json!({ "base_url": "https://x", "api_key": "" });
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("密钥"), "老路径空 key 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// 路径 3:空兜底 → 无 provider_id 无明文 → 取 is_default=true 首条。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_fallback_default_provider() {
|
||||
let (db, _pid) = setup_provider_db("prov-default", true, "sk-default").await;
|
||||
let config = json!({}); // 无 provider_id 无明文
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-default", "兜底应取 is_default 首条");
|
||||
}
|
||||
|
||||
/// 路径 3:无任何 provider → 友好错误「未配置 AI Provider」。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_no_providers_errors() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let config = json!({});
|
||||
let err = resolve_provider(&Arc::new(db), &config)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("未配置") || err.contains("Provider"),
|
||||
"无 provider 应友好报错, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 3:兜底无 is_default 时取首条(对齐 idea.rs build_default_provider)。
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_fallback_first_when_no_default() {
|
||||
let (db, _pid) = setup_provider_db("prov-first", false, "sk-first").await;
|
||||
let config = json!({});
|
||||
let resolved = resolve_provider(&Arc::new(db), &config).await.unwrap();
|
||||
assert_eq!(resolved.api_key, "sk-first", "无 is_default 时取首条");
|
||||
}
|
||||
|
||||
/// 真调 GLM 验证 provider 调用层(parse_params 已由上方单测覆盖,此处补 complete 端到端)
|
||||
///
|
||||
/// `#[ignore]`:需真实 GLM 配置(env var),默认不跑。
|
||||
/// 跑法:`GLM_BASE_URL=... GLM_API_KEY=... GLM_MODEL=glm-4-flash \
|
||||
/// cargo test -p df-nodes --lib glm_live_complete -- --ignored --nocapture`
|
||||
/// env var 缺失 → 跳过(非失败)。
|
||||
#[ignore = "需真实 GLM 配置(env var)"]
|
||||
#[tokio::test]
|
||||
async fn glm_live_complete() {
|
||||
let base_url = std::env::var("GLM_BASE_URL").ok().filter(|s| !s.is_empty());
|
||||
let api_key = std::env::var("GLM_API_KEY").ok().filter(|s| !s.is_empty());
|
||||
let (base_url, api_key) = match (base_url, api_key) {
|
||||
(Some(b), Some(k)) => (b, k),
|
||||
_ => {
|
||||
eprintln!("跳过: 未设 GLM_BASE_URL / GLM_API_KEY env var");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let model = std::env::var("GLM_MODEL").unwrap_or_else(|_| "glm-4-flash".to_string());
|
||||
|
||||
let provider: Box<dyn LlmProvider> =
|
||||
df_ai::build_provider("openai_compat", &base_url, &api_key, &model);
|
||||
let request = CompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: vec![ChatMessage::user("只回复两个字:通过")],
|
||||
temperature: Some(0.0),
|
||||
max_tokens: Some(16),
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let response = provider.complete(request).await.expect("GLM 调用失败");
|
||||
assert!(!response.text.is_empty(), "GLM 返回空文本");
|
||||
println!(
|
||||
"GLM 响应: model={}, text={}, usage={:?}",
|
||||
response.model, response.text, response.usage
|
||||
);
|
||||
}
|
||||
|
||||
// 自审闭环相关单测(parse_review_json / truncate_for_summary / gate_should_block /
|
||||
// build_review_prompt / update_field_writes_output_json)已随 AiSelfReviewNode 迁移至
|
||||
// ai_self_review_node.rs(与被测代码同位,纯搬运)。
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! AI 节点辅助函数/类型 — provider 解析、参数构造、自审 JSON 解析等纯逻辑
|
||||
//!
|
||||
//! 从 ai_node.rs 抽离的纯函数/类型(execute 与解析解耦,便于单测覆盖)。
|
||||
//! AiNode / AiSelfReviewNode 的 struct + impl 仍保留在 ai_node.rs(impl 块约束)。
|
||||
//!
|
||||
//! SW-09 已抽 resolve_and_parse / provider_from_params helper(本文件随之搬迁)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::df_ai_core::model::{Modality, ModelConfig};
|
||||
use df_ai::provider::LlmProvider;
|
||||
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
|
||||
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
|
||||
use df_ai::router::{select_model_id, TaskRequirements};
|
||||
use df_storage::crud::AiProviderRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use df_storage::secret::{ensure_resolved_key, resolve_provider_secret};
|
||||
use df_workflow::node::{NodeOutput};
|
||||
|
||||
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
|
||||
///
|
||||
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
|
||||
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。
|
||||
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AiNodeParams {
|
||||
/// 解析后的 provider 构造要素(含明文 api_key,仅 AiNode 内存可见)
|
||||
pub provider: ResolvedProvider,
|
||||
pub prompt: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub model: String,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。
|
||||
///
|
||||
/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ResolvedProvider {
|
||||
/// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射
|
||||
pub protocol: String,
|
||||
pub base_url: String,
|
||||
/// 明文 api_key,经 `resolve_provider_secret`(DB 优先→keyring) 解析;仅 AiNode 内存可见
|
||||
pub api_key: String,
|
||||
/// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic
|
||||
pub default_model: String,
|
||||
/// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用:
|
||||
/// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。
|
||||
pub model_pool: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。
|
||||
///
|
||||
/// 三路径(优先级从高到低):
|
||||
/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record →
|
||||
/// `resolve_provider_secret`(DB 优先→keyring) → `ensure_resolved_key` 空键早失败。
|
||||
/// 2. **老路径兼容(过渡)**:config 显式含 `base_url` + `api_key` 明文(老调用方)→ 走原路径
|
||||
/// + `tracing::warn!`(明文 api_key 经 config 注入已废弃)。兼容期保留,后续移除。
|
||||
/// 3. **空兜底**:无 provider_id 也无明文 → 取 `is_default=true` 首条 provider(对齐
|
||||
/// src-tauri commands/idea.rs build_default_provider 模式),走同 resolve+ensure 链。
|
||||
/// 无任何 provider → 友好错误「未配置 AI Provider」。
|
||||
///
|
||||
/// model:config["model"] 非空用之,否则 record.default_model,再否则 "gpt-4o-mini" 占位。
|
||||
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并
|
||||
/// (AiNode/AiSelfReviewNode execute 逐字重复)。
|
||||
pub(crate) async fn resolve_and_parse(
|
||||
db: &Arc<Database>,
|
||||
config: &serde_json::Value,
|
||||
inputs: &HashMap<String, NodeOutput>,
|
||||
) -> anyhow::Result<AiNodeParams> {
|
||||
let provider_cfg = resolve_provider(db, config).await?;
|
||||
parse_params(config, inputs, provider_cfg)
|
||||
}
|
||||
|
||||
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
|
||||
pub(crate) fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
|
||||
df_ai::build_provider(
|
||||
&p.provider.protocol,
|
||||
&p.provider.base_url,
|
||||
&p.provider.api_key,
|
||||
&p.provider.default_model,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_provider(
|
||||
db: &Arc<Database>,
|
||||
config: &serde_json::Value,
|
||||
) -> anyhow::Result<ResolvedProvider> {
|
||||
let repo = AiProviderRepo::new(db);
|
||||
let config_model = config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// ── 路径 1:provider_id 优先 ──
|
||||
if let Some(pid) = config.get("provider_id").and_then(|v| v.as_str()) {
|
||||
if !pid.is_empty() {
|
||||
let record = repo
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("AiNode 查 provider 失败: {}", e))?
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode provider_id={} 不存在", pid))?;
|
||||
return resolve_from_record(&record, &config_model);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 路径 2:老明文路径兼容(过渡,warn) ──
|
||||
// base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。
|
||||
let plain_base = config.get("base_url").and_then(|v| v.as_str());
|
||||
let plain_key = config.get("api_key").and_then(|v| v.as_str());
|
||||
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
|
||||
tracing::warn!(
|
||||
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
|
||||
老路径将在后续版本移除"
|
||||
);
|
||||
let base_url = base_url_str.to_string();
|
||||
let api_key = api_key_str.to_string();
|
||||
ensure_resolved_key("(明文注入)", &api_key)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let protocol = config
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("openai_compat")
|
||||
.to_string();
|
||||
let default_model = if config_model.is_empty() {
|
||||
"gpt-4o-mini".to_string()
|
||||
} else {
|
||||
config_model.clone()
|
||||
};
|
||||
return Ok(ResolvedProvider {
|
||||
protocol,
|
||||
base_url,
|
||||
api_key,
|
||||
default_model,
|
||||
// 老明文路径无 record,候选池空(无路由能力,兜底 default_model)。
|
||||
model_pool: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 路径 3:兜底 is_default=true 首条 provider ──
|
||||
let providers = repo
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("AiNode 列 provider 失败: {}", e))?;
|
||||
let picked = providers
|
||||
.iter()
|
||||
.find(|p| p.is_default)
|
||||
.cloned()
|
||||
.or_else(|| providers.into_iter().next())
|
||||
.ok_or_else(|| anyhow::anyhow!("未配置 AI Provider,请在设置中添加并保存密钥"))?;
|
||||
resolve_from_record(&picked, &config_model)
|
||||
}
|
||||
|
||||
/// 从 record 解析 provider 构造要素:resolve_provider_secret(DB 优先→keyring) + ensure 空键早失败。
|
||||
/// protocol 从 record.provider_type 映射;default_model 取 config_model > record.default_model > 占位。
|
||||
pub(crate) fn resolve_from_record(
|
||||
record: &AiProviderRecord,
|
||||
config_model: &str,
|
||||
) -> anyhow::Result<ResolvedProvider> {
|
||||
let api_key = resolve_provider_secret(record);
|
||||
ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
|
||||
let default_model = if !config_model.is_empty() {
|
||||
config_model.to_string()
|
||||
} else if !record.default_model.is_empty() {
|
||||
record.default_model.clone()
|
||||
} else {
|
||||
"gpt-4o-mini".to_string()
|
||||
};
|
||||
Ok(ResolvedProvider {
|
||||
protocol: record.provider_type.clone(),
|
||||
base_url: record.base_url.clone(),
|
||||
api_key,
|
||||
default_model,
|
||||
model_pool: record.model_configs.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从节点 config + 上游输入解析 AI 节点 prompt 与可选参数(provider 经 `resolve_provider` 异步解析)。
|
||||
///
|
||||
/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。
|
||||
pub(crate) fn parse_params(
|
||||
config: &serde_json::Value,
|
||||
inputs: &HashMap<String, NodeOutput>,
|
||||
provider: ResolvedProvider,
|
||||
) -> anyhow::Result<AiNodeParams> {
|
||||
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
|
||||
let prompt = inputs
|
||||
.get("prompt")
|
||||
.and_then(|o| o.data.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
||||
|
||||
// ── 可选参数 ──
|
||||
// model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时)
|
||||
// > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。
|
||||
// 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id
|
||||
// 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。
|
||||
let config_model = config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let model = if !config_model.is_empty() {
|
||||
config_model
|
||||
} else {
|
||||
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
|
||||
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
|
||||
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
|
||||
let node_req = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
};
|
||||
select_model_id(&node_req, &provider.model_pool).unwrap_or_default()
|
||||
};
|
||||
let temperature = config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32);
|
||||
let max_tokens = config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32);
|
||||
let system_prompt = config
|
||||
.get("system_prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(AiNodeParams {
|
||||
provider,
|
||||
prompt,
|
||||
system_prompt,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// 自审四维度 system prompt:严格审查员角色 + 只输出 JSON 强约束。
|
||||
pub(crate) const REVIEW_SYSTEM_PROMPT: &str = "\
|
||||
你是严格的代码/产出审查员。审查任务产出是否符合需求,按四维度给出结构化结论。\
|
||||
只输出 JSON,不要任何额外文字、不要 markdown 代码块包裹。";
|
||||
|
||||
/// 解析 LLM 自审输出为结构化 review JSON。
|
||||
///
|
||||
/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。
|
||||
/// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文,
|
||||
/// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。
|
||||
pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
|
||||
// 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。
|
||||
let trimmed = raw.trim();
|
||||
let cleaned = trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.map(|s| s.trim_end_matches("```").trim())
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(cleaned) {
|
||||
if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。
|
||||
serde_json::json!({
|
||||
"verdict": "unknown",
|
||||
"dimensions": {},
|
||||
"summary": format!("(自审输出解析失败,原文: {})", truncate_for_summary(cleaned)),
|
||||
"suggestions": [],
|
||||
})
|
||||
}
|
||||
|
||||
/// summary 截断(防原文过长撑爆 output_json / 审批卡)。
|
||||
pub(crate) fn truncate_for_summary(s: &str) -> String {
|
||||
const MAX: usize = 300;
|
||||
if s.chars().count() <= MAX {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(MAX).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
|
||||
///
|
||||
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
|
||||
/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。
|
||||
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
|
||||
gate && verdict == "fail"
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||
//!
|
||||
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
||||
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
||||
//! verdict 闸门。provider 解析与自审 JSON 解析等纯逻辑见 `ai_node_helpers`。
|
||||
//!
|
||||
//! 节点类型注册 key:`ai_self_review`(state.rs build_registry)。
|
||||
//! 模板对齐:task_workflow_templates.rs testing 模板 `ai_self_review` 节点 + gate:true。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
// 抽离的纯函数/类型(与 AiNode 共用)。
|
||||
use crate::ai_node_helpers::{
|
||||
gate_should_block, parse_review_json, provider_from_params, resolve_and_parse,
|
||||
REVIEW_SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
// AiSelfReviewNode 节点 — AI 自审闭环(决策 a 步骤③)
|
||||
//
|
||||
// 复用 AiNode 的 provider 调用逻辑(parse_params + build_provider + complete),差异:
|
||||
// 1. prompt 固定四维度审查模板(需求符合度/完整性/正确性/边界),不从 config/inputs 取 prompt
|
||||
// 2. system_prompt 强约束「只输出 JSON」+ temperature=0
|
||||
// 3. LLM 输出按结构化 JSON 解析,失败兜底 verdict=unknown(防 LLM 不按要求输出)
|
||||
// 4. 自审输入:task.description(需求,经 ctx.config["task_id"] 读 task) + task.output_json.text(产出)
|
||||
// 5. 自审结果写回 task.output_json 加 review 子字段(不覆盖 text,保留对照)
|
||||
// 6. review 摘要塞 NodeOutput.data,供下游 human_review 经 DAG inputs["ai_self_review"] 读
|
||||
//
|
||||
// verdict 处理(首版保守):自审是辅助,最终决策权在人。verdict=fail 不主动 Err,
|
||||
// 节点返 Ok 让 human_review 继续,人在审批卡看到 fail 结论再定(human reject 走 Err→工作流
|
||||
// failed→②-4 回调退回 in_review)。
|
||||
|
||||
/// AI 自审节点(决策 a 步骤③)
|
||||
pub struct AiSelfReviewNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl AiSelfReviewNode {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 拼装自审 user prompt:任务需求 + 待审产出 + 四维度审查要求 + 输出格式。
|
||||
/// description / output_text 缺失时给占位(不报错,信任调用方注入合法 task_id)。
|
||||
fn build_review_prompt(description: &str, output_text: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
## 任务需求
|
||||
{description}
|
||||
|
||||
## 待审产出
|
||||
{output_text}
|
||||
|
||||
## 审查维度
|
||||
1. 需求符合度:产出是否覆盖需求描述的所有要点
|
||||
2. 产出完整性:是否有遗漏、未完成的部分
|
||||
3. 正确性:逻辑/事实/语法是否正确
|
||||
4. 边界处理:异常输入、空值、错误路径是否考虑
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{{\"verdict\":\"pass\"|\"fail\",\"dimensions\":{{\"requirement_fit\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"completeness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"correctness\":{{\"score\":0-10,\"issues\":[\"...\"]}},\"boundary\":{{\"score\":0-10,\"issues\":[\"...\"]}}}},\"summary\":\"一句话总结\",\"suggestions\":[\"改进建议\"]}}
|
||||
verdict=fail 当且仅当任一维度 score<6 或有阻断性 issue。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for AiSelfReviewNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||
|
||||
// ── 读任务(需求 + 产出) ──
|
||||
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
|
||||
let task_id = ctx
|
||||
.config
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiSelfReviewNode 缺少必填参数: task_id"))?;
|
||||
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
let task = repo
|
||||
.get_by_id(task_id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("自审读任务失败: {}", e))?
|
||||
.ok_or_else(|| anyhow::anyhow!("自审任务 {} 不存在", task_id))?;
|
||||
|
||||
// output_json 可能无(ai_execute 未跑或失败) → 待审产出占位提示,不报错。
|
||||
let output_text = task
|
||||
.output_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "(无产出,仅依据需求审查)".to_string());
|
||||
|
||||
let prompt = Self::build_review_prompt(&task.description, &output_text);
|
||||
|
||||
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: p.model.clone(),
|
||||
messages: vec![
|
||||
ChatMessage::system(REVIEW_SYSTEM_PROMPT),
|
||||
ChatMessage::user(prompt),
|
||||
],
|
||||
temperature: Some(0.0), // 强约束确定性输出
|
||||
max_tokens: p.max_tokens,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"AiSelfReviewNode 调用 LLM: model={}, base_url={}",
|
||||
p.provider.default_model,
|
||||
p.provider.base_url
|
||||
);
|
||||
let response = provider.complete(request).await?;
|
||||
|
||||
// ── 解析自审 JSON(失败兜底 verdict=unknown) ──
|
||||
let review = parse_review_json(&response.text);
|
||||
let verdict = review
|
||||
.get("verdict")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
// summary / suggestions 在 move review 前先取走(供 NodeOutput.data 平铺)
|
||||
let summary = review
|
||||
.get("summary")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let suggestions = review
|
||||
.get("suggestions")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([]));
|
||||
|
||||
tracing::info!(
|
||||
"AiSelfReviewNode 完成: verdict={}, model={}",
|
||||
verdict,
|
||||
response.model
|
||||
);
|
||||
|
||||
// ── 写回 task.output_json 加 review 子字段(不覆盖 text) ──
|
||||
// 读现有 output_json(若有),合并 review 子字段后整体写回。
|
||||
let mut output_obj: serde_json::Map<String, serde_json::Value> = task
|
||||
.output_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
// review 子字段:自审结论 + reviewed_at + reviewer_model(便于前端展示与人对照)
|
||||
let mut review_with_meta = match review {
|
||||
serde_json::Value::Object(m) => m,
|
||||
other => {
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("verdict".into(), other);
|
||||
m
|
||||
}
|
||||
};
|
||||
review_with_meta.insert(
|
||||
"reviewed_at".into(),
|
||||
serde_json::Value::Number(df_types::now_millis().into()),
|
||||
);
|
||||
review_with_meta.insert("reviewer_model".into(), serde_json::json!(response.model));
|
||||
output_obj.insert("review".into(), serde_json::Value::Object(review_with_meta));
|
||||
|
||||
let merged = serde_json::Value::Object(output_obj);
|
||||
if let Ok(json_str) = serde_json::to_string(&merged) {
|
||||
if let Err(e) = repo.update_field(task_id, "output_json", &json_str).await {
|
||||
tracing::warn!(
|
||||
task_id = task_id,
|
||||
error = %e,
|
||||
"AiSelfReviewNode 落 review 子字段失败(摘要已在 NodeOutput,节点继续)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── NodeOutput.data 塞 review 摘要,供下游 human_review 经 inputs["ai_self_review"] 读 ──
|
||||
// human_review(HumanNode)从 config/inputs 拼 description;此处把 verdict/summary/suggestions
|
||||
// 平铺进 data,HumanNode 零改动即可透传(前端/模板层可据此拼审批卡描述)。
|
||||
let output = NodeOutput::from_value(serde_json::json!({
|
||||
"verdict": verdict,
|
||||
"summary": summary,
|
||||
"suggestions": suggestions,
|
||||
"review": merged.get("review").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"model": response.model,
|
||||
}));
|
||||
|
||||
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
|
||||
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
|
||||
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
|
||||
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
|
||||
// verdict="unknown" → 保持 Ok(LLM 输出不可靠时不阻断,人定权)
|
||||
// verdict="pass" → Ok 放行下游
|
||||
//
|
||||
// 落库在前(已 update_field output_json),故闸门 fail 时 task.output_json.review
|
||||
// 已含结论,前端/二次工作流可读自审详情(不丢失审查痕迹)。
|
||||
//
|
||||
// 设计选型(三方案对比,见 commit/设计文档):
|
||||
// A) AiNode 内部门控(本方案):1 处 return Err,复用 executor first_err + ②-4 回调,
|
||||
// 零 executor 核心改动,零依赖暂缓的条件引擎(T-260614-11)。最简。
|
||||
// B) DAG edges 条件 + ConditionEngine 求值:依赖 T-260614-11(暂缓),executor 当前
|
||||
// 完全不评估 edge.condition(topological_layers 无条件收录所有边),须先做条件
|
||||
// 引擎 Phase1+2 才能用 → 拆波。
|
||||
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
|
||||
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
|
||||
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
|
||||
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
|
||||
let gate_enabled = ctx
|
||||
.config
|
||||
.get("gate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if gate_should_block(gate_enabled, &verdict) {
|
||||
tracing::info!(
|
||||
node_id = %ctx.node_id,
|
||||
task_id,
|
||||
"AiSelfReviewNode 闸门触发: verdict=fail, 阻断下游(工作流将 failed)"
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"AI 自审闸门未通过(verdict=fail): {}",
|
||||
if summary.is_empty() { "自审结论 fail" } else { &summary }
|
||||
));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||
"max_tokens": { "type": "integer" },
|
||||
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
|
||||
},
|
||||
"required": ["task_id", "provider_id"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verdict": { "type": "string", "enum": ["pass", "fail", "unknown"] },
|
||||
"summary": { "type": "string" },
|
||||
"suggestions": { "type": "array", "items": { "type": "string" } },
|
||||
"review": { "type": "object" },
|
||||
"model": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"ai_self_review"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_node_helpers::{gate_should_block, parse_review_json, truncate_for_summary};
|
||||
use df_storage::crud::{ProjectRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
use serde_json::json;
|
||||
|
||||
// ============================================================
|
||||
// 决策 a 自审闭环单测 — output_json 写回 + review JSON 解析兜底
|
||||
// ============================================================
|
||||
|
||||
/// 内存 DB 构造 helper:插父项目 + 指定 task,返回 (db, task_id)。
|
||||
async fn setup_task_db(task_output_json: Option<&str>) -> (Database, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
ProjectRepo::new(&db)
|
||||
.insert(ProjectRecord {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert project");
|
||||
TaskRepo::new(&db)
|
||||
.insert(TaskRecord {
|
||||
id: "t1".to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: "t1".to_string(),
|
||||
description: "实现登录接口".to_string(),
|
||||
status: "testing".to_string(),
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
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(),
|
||||
})
|
||||
.await
|
||||
.expect("insert task");
|
||||
(db, "t1".to_string())
|
||||
}
|
||||
|
||||
/// 决策 a 步骤②:AiNode.execute 落产出验证 — 有 task_id 时 output_json 写入。
|
||||
/// 用 mock provider 避免真调 LLM:自定义 Node 替换不便,改为直接验证 update_field 路径
|
||||
/// (execute 内部调 provider.complete 需真 LLM,此处降级验证 parse_review_json 兜底 +
|
||||
/// 直接 repo.update_field 路径,与 execute 落库逻辑同一条 crud.update_field)。
|
||||
#[tokio::test]
|
||||
async fn update_field_writes_output_json() {
|
||||
// 验证 output_json 在 tasks 白名单(update_field 不报「不允许的字段名」)。
|
||||
// 这正是 AiNode.execute / AiSelfReviewNode.execute 落产出的底层路径。
|
||||
let (db, task_id) = setup_task_db(None).await;
|
||||
let repo = TaskRepo::new(&db);
|
||||
let payload = r#"{"text":"产出内容","model":"glm-4","usage":{}}"#;
|
||||
let ok = repo
|
||||
.update_field(&task_id, "output_json", payload)
|
||||
.await
|
||||
.expect("update_field output_json");
|
||||
assert!(ok, "update_field 应返回 affected>0=true");
|
||||
let after = repo.get_by_id(&task_id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
after.output_json.as_deref(),
|
||||
Some(payload),
|
||||
"output_json 应被写入"
|
||||
);
|
||||
// 原始 text 字段(产出对照)保留
|
||||
let parsed: serde_json::Value = serde_json::from_str(after.output_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(parsed["text"], json!("产出内容"));
|
||||
}
|
||||
|
||||
/// 步骤③:parse_review_json 合规 JSON 直通(保留 verdict/dimensions/summary/suggestions)。
|
||||
#[test]
|
||||
fn parse_review_json_valid_passes_through() {
|
||||
let raw = r#"{"verdict":"pass","dimensions":{"requirement_fit":{"score":9,"issues":[]}},"summary":"符合需求","suggestions":["可加单测"]}"#;
|
||||
let v = parse_review_json(raw);
|
||||
assert_eq!(v["verdict"], json!("pass"));
|
||||
assert_eq!(v["summary"], json!("符合需求"));
|
||||
assert_eq!(v["suggestions"], json!(["可加单测"]));
|
||||
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(9));
|
||||
}
|
||||
|
||||
/// 步骤③:parse_review_json 非 JSON / 缺 verdict → 兜底 verdict=unknown + summary 含原文。
|
||||
#[test]
|
||||
fn parse_review_json_invalid_falls_back_unknown() {
|
||||
// 非 JSON
|
||||
let v = parse_review_json("这不是 JSON,LLM 没按要求输出");
|
||||
assert_eq!(v["verdict"], json!("unknown"));
|
||||
assert!(v["summary"].as_str().unwrap().contains("不是 JSON"));
|
||||
assert!(v["summary"].as_str().unwrap().contains("解析失败"));
|
||||
|
||||
// JSON 但缺 verdict
|
||||
let v = parse_review_json(r#"{"foo":"bar"}"#);
|
||||
assert_eq!(v["verdict"], json!("unknown"));
|
||||
|
||||
// JSON 非 object(数组)
|
||||
let v = parse_review_json("[1,2,3]");
|
||||
assert_eq!(v["verdict"], json!("unknown"));
|
||||
}
|
||||
|
||||
/// 步骤③:LLM 偶尔包 ```json 代码块,parse_review_json 应剥离后解析成功。
|
||||
#[test]
|
||||
fn parse_review_json_strips_markdown_code_fence() {
|
||||
let raw = "```json\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n```";
|
||||
let v = parse_review_json(raw);
|
||||
assert_eq!(v["verdict"], json!("fail"));
|
||||
assert_eq!(v["summary"], json!("缺边界处理"));
|
||||
}
|
||||
|
||||
/// 步骤③:truncate_for_summary 长文截断。
|
||||
#[test]
|
||||
fn truncate_for_summary_long_text() {
|
||||
let short = "短文";
|
||||
assert_eq!(truncate_for_summary(short), "短文");
|
||||
let long: String = "字".repeat(500);
|
||||
let t = truncate_for_summary(&long);
|
||||
assert!(t.ends_with("…"));
|
||||
assert!(t.chars().count() <= 302, "截断后含省略号应 ≈300 字");
|
||||
}
|
||||
|
||||
/// 步骤③:AiSelfReviewNode.build_review_prompt 含需求 + 产出 + 四维度。
|
||||
#[test]
|
||||
fn build_review_prompt_contains_inputs() {
|
||||
let p = AiSelfReviewNode::build_review_prompt("登录接口", "已实现 /login");
|
||||
assert!(p.contains("登录接口"), "prompt 应含需求");
|
||||
assert!(p.contains("已实现 /login"), "prompt 应含产出");
|
||||
assert!(p.contains("需求符合度"), "prompt 应含四维度");
|
||||
assert!(p.contains("边界处理"));
|
||||
assert!(p.contains("verdict"), "prompt 应含输出格式约束");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 阶段3 自审闸门(gate_should_block)单测
|
||||
// ============================================================
|
||||
//
|
||||
// gate 决策矩阵:
|
||||
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
|
||||
// gate=true + verdict=pass → 放行
|
||||
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
|
||||
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
|
||||
|
||||
#[test]
|
||||
fn gate_disabled_never_blocks_any_verdict() {
|
||||
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
|
||||
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
|
||||
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
|
||||
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_enabled_blocks_only_on_fail() {
|
||||
// gate 开启:仅 fail 阻断
|
||||
assert!(gate_should_block(true, "fail"), "gate 开 + fail 应阻断");
|
||||
assert!(!gate_should_block(true, "pass"), "gate 开 + pass 放行");
|
||||
assert!(
|
||||
!gate_should_block(true, "unknown"),
|
||||
"gate 开 + unknown 放行(LLM 不可靠时保人定权)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_unknown_verdict_does_not_block() {
|
||||
// 防御:verdict 非标准值(如解析异常)不应触发阻断(对齐 unknown 保守不阻断语义)
|
||||
assert!(
|
||||
!gate_should_block(true, "weird"),
|
||||
"非标准 verdict 不应阻断(对齐 unknown 保守)"
|
||||
);
|
||||
assert!(!gate_should_block(true, ""), "空 verdict 不应阻断");
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Docker 节点 — 在容器中执行任务
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Docker 节点
|
||||
pub struct DockerNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for DockerNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 接入 df-execute 的 Docker 执行器
|
||||
tracing::info!("DockerNode 执行: 在容器中运行");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"command": { "type": "string" },
|
||||
"env": { "type": "object" }
|
||||
},
|
||||
"required": ["image"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stdout": { "type": "string" },
|
||||
"exit_code": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"docker"
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Git 节点 — 执行 Git 操作(克隆、提交、推送、合并等)
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Git 节点
|
||||
pub struct GitNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for GitNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 接入 df-execute 的 Git 操作
|
||||
tracing::info!("GitNode 执行: Git 操作");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "enum": ["clone", "commit", "push", "merge", "checkout"] },
|
||||
"repo": { "type": "string" },
|
||||
"branch": { "type": "string" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"message": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"git"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! HTTP 节点 — 发起 HTTP 请求
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// HTTP 节点
|
||||
pub struct HttpNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for HttpNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 实现HTTP请求逻辑
|
||||
tracing::info!("HttpNode 执行: 发送 HTTP 请求");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] },
|
||||
"url": { "type": "string" },
|
||||
"headers": { "type": "object" },
|
||||
"body": {}
|
||||
},
|
||||
"required": ["method", "url"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "integer" },
|
||||
"body": {},
|
||||
"headers": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"http"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
//! 人工审批节点 — 阻塞工作流,等待人工确认或输入
|
||||
//!
|
||||
//! 拒绝语义化关键字判定等纯逻辑见 `human_node_helpers`(本文件只保留
|
||||
//! HumanNode 的 struct + impl,impl 块约束)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tokio::sync::broadcast;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_core::events::WorkflowEvent;
|
||||
use df_types::events::{SelectType, WorkflowEvent};
|
||||
|
||||
// 抽离的纯函数/常量(glob 引入,tests `use super::*` 间接可见)
|
||||
#[allow(unused_imports)]
|
||||
use crate::human_node_helpers::{contains_reject, is_reject_decision};
|
||||
|
||||
/// 人工审批节点(阻塞节点)
|
||||
pub struct HumanNode;
|
||||
@@ -21,44 +28,131 @@ impl Node for HumanNode {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let options = config.get("options")
|
||||
let options: Vec<String> = config.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec!["同意", "拒绝"]);
|
||||
.map(String::from)
|
||||
.collect())
|
||||
.unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]);
|
||||
|
||||
// 发送人工审批请求到事件总线
|
||||
let _ = ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.iter().map(|s| s.to_string()).collect(),
|
||||
});
|
||||
let timeout_secs = config.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
// 等待用户响应(最多等待 1 小时)
|
||||
let timeout = Duration::from_secs(3600);
|
||||
let start_time = std::time::Instant::now();
|
||||
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
|
||||
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
|
||||
Some("multiple") => SelectType::Multiple,
|
||||
_ => SelectType::Single,
|
||||
};
|
||||
|
||||
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
|
||||
// 若 subscribe 晚于 send,Response 会在 receiver 建位前发出而丢失,节点死等到超时。
|
||||
let mut rx = ctx.event_bus.subscribe();
|
||||
|
||||
// 2. 发送人工审批请求到事件总线
|
||||
// send 是 async fn(broadcast 同步发但 async 包装),必须 await 否则 Future 不 poll、Request 不进 channel(B-03b-R6)
|
||||
let _ = ctx.event_bus
|
||||
.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.clone(),
|
||||
select_type,
|
||||
})
|
||||
.await;
|
||||
|
||||
// 3. select! 循环:Response / 超时 / 取消
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
|
||||
cancel_tick.tick().await; // 丢弃首个立即触发的 tick
|
||||
|
||||
loop {
|
||||
// 检查是否超时
|
||||
if start_time.elapsed() > timeout {
|
||||
return Err(anyhow::anyhow!("人工审批超时"));
|
||||
tokio::select! {
|
||||
recv = rx.recv() => match recv {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, decisions, comment,
|
||||
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
|
||||
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
|
||||
// select_type=Single → 决策数必须 =1
|
||||
// select_type=Multiple → 决策数必须 ≥1
|
||||
// options 空 → 允许自由文本(仅受数量约束);
|
||||
// options 非空 → 每项必须 ∈ options
|
||||
// 兼容旧调用方:decisions 空但 decision 非空时,按 [decision] 单值处理。
|
||||
let mut picked: Vec<String> = decisions;
|
||||
if picked.is_empty() && !decision.is_empty() {
|
||||
picked.push(decision.clone());
|
||||
}
|
||||
let count_ok = match select_type {
|
||||
SelectType::Single => picked.len() == 1,
|
||||
SelectType::Multiple => !picked.is_empty(),
|
||||
};
|
||||
let each_valid = picked.iter().all(|d| !d.is_empty())
|
||||
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
|
||||
if count_ok && each_valid {
|
||||
// F-260616-06 阶段2: 拒绝语义化。
|
||||
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
|
||||
// 下游无法据 failed 触发退回/重做。
|
||||
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
|
||||
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
|
||||
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
|
||||
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
|
||||
if contains_reject(&picked) {
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
let comment_str = comment.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"人工审批被拒绝(用户选择: {}){}",
|
||||
primary,
|
||||
if comment_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";意见: {}", comment_str)
|
||||
}
|
||||
));
|
||||
}
|
||||
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
return Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"decision": primary,
|
||||
"decisions": picked,
|
||||
"comment": comment.unwrap_or_default(),
|
||||
})));
|
||||
}
|
||||
tracing::warn!(
|
||||
node_id = %ctx.node_id,
|
||||
execution_id = %ctx.execution_id,
|
||||
select_type = ?select_type,
|
||||
decision = %decision,
|
||||
decisions = ?picked,
|
||||
options = ?options,
|
||||
"审批决策非法,忽略并继续等待下一条合法 Response(由超时兜底)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => continue, // 其他节点/类型的事件,忽略
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
"HumanNode {} 漏收 {} 条事件(可能错过自身响应,继续等待)",
|
||||
ctx.node_id, n
|
||||
);
|
||||
// 容量 256 + 审批低频,漏自身 Response 概率极低;
|
||||
// 丢弃则可能误判超时,故 continue。
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
return Err(anyhow::anyhow!("事件总线关闭,审批无法完成"));
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
|
||||
}
|
||||
_ = cancel_tick.tick() => {
|
||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||
return Err(anyhow::anyhow!("人工审批被取消"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查节点是否被取消
|
||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||
return Err(anyhow::anyhow!("人工审批被取消"));
|
||||
}
|
||||
|
||||
// TODO: 检查审批响应(需要前端实现)
|
||||
// 目前直接返回同意
|
||||
let output = serde_json::json!({
|
||||
"decision": "同意",
|
||||
"comment": "",
|
||||
});
|
||||
return Ok(NodeOutput::from_value(output));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +166,11 @@ impl Node for HumanNode {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"select_type": {
|
||||
"type": "string",
|
||||
"enum": ["single", "multiple"],
|
||||
"default": "single"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
@@ -80,6 +179,7 @@ impl Node for HumanNode {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": { "type": "string" },
|
||||
"decisions": { "type": "array", "items": { "type": "string" } },
|
||||
"comment": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
@@ -94,3 +194,638 @@ impl Node for HumanNode {
|
||||
"human"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_types::types::NodeStatus;
|
||||
use df_workflow::dag::Dag;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::executor::DagExecutor;
|
||||
use df_workflow::state::StateMachine;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 构造 NodeContext:自定义 execution_id / node_id / config,共享同一 EventBus。
|
||||
fn make_ctx(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
config: serde_json::Value,
|
||||
) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
|
||||
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
|
||||
async fn send_response(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decision: &str,
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
|
||||
async fn send_response_multi(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decisions: &[&str],
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_approval_returns_decision() {
|
||||
// 正常审批:发匹配(exec_id+node_id) Response → 返回该 decision
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({}));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
// 给 execute 一点时间 subscribe + send Request
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "同意", Some("好的")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["comment"], json!("好的"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_is_emitted_to_bus() {
|
||||
// B-03b-R8: 验证 HumanNode 真发出 HumanApprovalRequest 到事件总线
|
||||
// (R6 修复前 execute 内 event_bus.send().await 缺 await → Request 未进 channel,此测会超时失败)
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 execute send(broadcast 不回放)
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
// execute send Request 后 rx 应收到
|
||||
let received = tokio::time::timeout(Duration::from_millis(500), rx.recv()).await;
|
||||
assert!(
|
||||
received.is_ok(),
|
||||
"应收到 HumanApprovalRequest(R6 Request 真发出),实际超时"
|
||||
);
|
||||
match received.unwrap().unwrap() {
|
||||
WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id,
|
||||
node_id,
|
||||
title,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(execution_id, "exec-1");
|
||||
assert_eq!(node_id, "node-h");
|
||||
assert_eq!(title, "请确认");
|
||||
}
|
||||
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
|
||||
}
|
||||
// 发 Response 让 execute 正常返回(防 task 泄漏)
|
||||
send_response(&bus_clone, "exec-1", "node-h", "同意", None).await;
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_execution_id_filtered_then_timeout() {
|
||||
// execution_id 不匹配:Response 被过滤,短超时验证 → Err 超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 错配的 execution_id
|
||||
send_response(&bus_clone, "exec-OTHER", "node-h", "同意", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "不匹配 exec_id 应被过滤后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_node_id_filtered_then_timeout() {
|
||||
// node_id 不匹配:Response 被过滤,短超时验证 → Err 超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 错配的 node_id
|
||||
send_response(&bus_clone, "exec-1", "node-OTHER", "同意", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "不匹配 node_id 应被过滤后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_when_no_response() {
|
||||
// 无 Response → sleep_until(deadline) 触发超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
|
||||
let err = HumanNode.execute(ctx).await.unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "应报超时, 实际: {}", err);
|
||||
assert!(err.contains("1s"), "超时消息应带秒数, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_decision_ignored_then_timeout() {
|
||||
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
|
||||
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-1",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"], "timeout_secs": 1 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "随便", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "非法 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_options_allows_free_text() {
|
||||
// options 空 → 允许任意自由文本 decision(不校验)
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "改成先做B方案", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
out.data["decision"],
|
||||
json!("改成先做B方案"),
|
||||
"options 空应允许自由文本 decision"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
|
||||
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "空 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
// Closed 分支覆盖:execute 持有的 ctx 含 EventBus clone(即一个 sender),
|
||||
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
|
||||
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
|
||||
|
||||
// ===== B-03b-R8: executor 级端到端集成测试 =====
|
||||
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
|
||||
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
|
||||
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
|
||||
|
||||
/// 简单测试节点:sleep 指定毫秒后返回空输出(仿 executor.rs SleepNode,作 human 的前驱)
|
||||
struct SleepNode {
|
||||
sleep_ms: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SleepNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
|
||||
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
|
||||
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_completes_workflow() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send(broadcast 不回放)
|
||||
let exec_id = "exec-e2e-1";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
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 run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
.run(
|
||||
&dag,
|
||||
json!({ "title": "确认发布", "options": ["同意", "拒绝"], "timeout_secs": 5 }),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// 等 a 层完成、b 层 human subscribe + send Request。
|
||||
// executor 先发 NodeStarted(a)/NodeStarted(b),再发 human 的 Request —— 过滤跳过非 Request 事件
|
||||
let request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
loop {
|
||||
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
|
||||
return ev;
|
||||
}
|
||||
// NodeStarted/NodeCompleted 等其他事件,继续等
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
request.is_ok(),
|
||||
"应收到 b 的 HumanApprovalRequest(R6 端到端),实际超时"
|
||||
);
|
||||
match request.unwrap() {
|
||||
WorkflowEvent::HumanApprovalRequest { node_id, title, options, .. } => {
|
||||
assert_eq!(node_id, "b");
|
||||
assert_eq!(title, "确认发布");
|
||||
assert_eq!(options, vec!["同意".to_string(), "拒绝".to_string()]);
|
||||
}
|
||||
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
|
||||
}
|
||||
|
||||
// 外部 IPC 模拟:审批通过
|
||||
send_response(&bus, exec_id, "b", "同意", Some("可以发布")).await;
|
||||
|
||||
let outputs = run_handle.await.unwrap().unwrap();
|
||||
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
|
||||
assert_eq!(outputs["b"].data["decision"], json!("同意"));
|
||||
assert_eq!(outputs["b"].data["decisions"], json!(["同意"]));
|
||||
assert_eq!(outputs["b"].data["comment"], json!("可以发布"));
|
||||
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
|
||||
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
|
||||
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_cancelled() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send
|
||||
let exec_id = "exec-e2e-cancel";
|
||||
|
||||
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 sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor.run(&dag, json!({ "timeout_secs": 5 })).await
|
||||
});
|
||||
|
||||
// 等 human 发出 Request(确认已 subscribe + send + 进入 select! 轮询)
|
||||
let saw_request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
while !matches!(rx.recv().await, Ok(WorkflowEvent::HumanApprovalRequest { .. })) {}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
assert!(saw_request, "human 应先发出 HumanApprovalRequest");
|
||||
|
||||
// 外部 IPC 模拟:取消节点(写共享 state_machine,human cancel_tick 会读到)
|
||||
sm.set_cancelled("h".to_string());
|
||||
|
||||
let result = run_handle.await.unwrap();
|
||||
assert!(result.is_err(), "取消应致 run 返回 Err");
|
||||
let err = result.unwrap_err().to_string();
|
||||
// executor 把节点 Err 包成 "节点 h 执行失败"(context),原 "人工审批被取消" 在 source chain
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {}",
|
||||
err
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法端到端验证
|
||||
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
|
||||
}
|
||||
|
||||
// ===== F-260615-01: 多选审批覆盖 =====
|
||||
|
||||
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
|
||||
#[tokio::test]
|
||||
async fn multiple_select_returns_decisions_array() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B", "C"],
|
||||
"select_type": "multiple"
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response_multi(&bus_clone, "exec-multi", "node-h", &["A", "C"], Some("多选")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("A"), "decision 取首项");
|
||||
assert_eq!(out.data["decisions"], json!(["A", "C"]));
|
||||
assert_eq!(out.data["comment"], json!("多选"));
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
|
||||
#[tokio::test]
|
||||
async fn single_select_rejects_multiple_decisions_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-single-multi",
|
||||
"node-h",
|
||||
json!({ "options": ["A", "B"], "timeout_secs": 1 }), // 缺省 single
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// single 模式却发多个 → 非法
|
||||
send_response_multi(&bus_clone, "exec-single-multi", "node-h", &["A", "B"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
|
||||
#[tokio::test]
|
||||
async fn multiple_select_invalid_option_ignored_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-bad",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B"],
|
||||
"select_type": "multiple",
|
||||
"timeout_secs": 1
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 一项合法一项不合法 → 整批非法
|
||||
send_response_multi(&bus_clone, "exec-multi-bad", "node-h", &["A", "X"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
|
||||
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
|
||||
#[tokio::test]
|
||||
async fn default_single_with_legacy_decision_single_value() {
|
||||
let bus = EventBus::new();
|
||||
// 注意:不传 select_type,验证缺省=Single
|
||||
let ctx = make_ctx(&bus, "exec-leg", "node-h", json!({ "options": ["同意", "拒绝"] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 旧链路只填 decision,decisions 为空 → HumanNode 兼容回退
|
||||
send_response(&bus_clone, "exec-leg", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
|
||||
}
|
||||
|
||||
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
|
||||
|
||||
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
|
||||
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
|
||||
#[tokio::test]
|
||||
async fn reject_decision_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-reject",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-reject", "node-h", "拒绝", Some("方案不行")).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("拒绝") && err.contains("人工审批"),
|
||||
"拒绝 decision 应返 Err「人工审批被拒绝...」, 实际: {}",
|
||||
err
|
||||
);
|
||||
assert!(
|
||||
err.contains("方案不行"),
|
||||
"拒绝 Err 应携带 comment, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
|
||||
#[tokio::test]
|
||||
async fn approve_decision_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-approve",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-approve", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
}
|
||||
|
||||
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
|
||||
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
|
||||
#[tokio::test]
|
||||
async fn english_reject_keyword_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-en",
|
||||
"node-h",
|
||||
json!({ "options": ["approve", "reject"], "timeout_secs": 5 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-en", "node-h", "reject", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("拒绝"),
|
||||
"英文 reject 关键字应返 Err, 实际: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
|
||||
/// (选了「驳回」即驳回,即便同时选了「同意」)。
|
||||
#[tokio::test]
|
||||
async fn multiple_select_with_one_reject_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-reject",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["同意", "驳回", "备注"],
|
||||
"select_type": "multiple"
|
||||
}),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response_multi(
|
||||
&bus_clone,
|
||||
"exec-multi-reject",
|
||||
"node-h",
|
||||
&["同意", "驳回"],
|
||||
Some("驳回其中一项"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
|
||||
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
|
||||
#[tokio::test]
|
||||
async fn empty_options_free_text_reject_keyword_still_errors() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-free-reject", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-free-reject", "node-h", "拒绝", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
|
||||
/// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
|
||||
#[tokio::test]
|
||||
async fn empty_options_non_reject_free_text_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-free-ok", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-free-ok", "node-h", "再讨论一下", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("再讨论一下"));
|
||||
}
|
||||
|
||||
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
|
||||
#[test]
|
||||
fn reject_keyword_detection_normalized() {
|
||||
assert!(is_reject_decision("拒绝"));
|
||||
assert!(is_reject_decision(" 拒绝 "));
|
||||
assert!(is_reject_decision("Reject"));
|
||||
assert!(is_reject_decision(" decline "));
|
||||
assert!(is_reject_decision("驳回"));
|
||||
assert!(is_reject_decision("退回"));
|
||||
assert!(!is_reject_decision("同意"));
|
||||
assert!(!is_reject_decision("approve"));
|
||||
assert!(!is_reject_decision("再讨论"));
|
||||
assert!(!is_reject_decision(""));
|
||||
// 不是关键字开头,是整词匹配 —— "拒绝啦" 不应误判
|
||||
assert!(!is_reject_decision("拒绝啦"));
|
||||
assert!(contains_reject(&["同意".into()]) == false);
|
||||
assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! 人工审批节点辅助函数/类型 — 拒绝语义化关键字判定等纯逻辑
|
||||
//!
|
||||
//! 从 human_node.rs 抽离的纯函数/常量(execute 与关键字判定解耦,便于单测覆盖)。
|
||||
//! HumanNode 的 struct + impl 仍保留在 human_node.rs(impl 块约束)。
|
||||
//!
|
||||
//! B-260615-05 / CR-260618-15: 拒绝语义化保留(executor set_failed → 推进链退回)。
|
||||
|
||||
/// F-260616-06 阶段2: 拒绝语义化关键字。
|
||||
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
|
||||
///
|
||||
/// 识别范围(避免误伤):
|
||||
/// - 仅当节点 options 非空且该 decision ∈ options 时拒绝 —— 前端从受控选项中选择,语义可信;
|
||||
/// - options 空(自由文本)时同样按关键字判定 —— 仅当文本明确为拒绝词才拒绝,
|
||||
/// 其余自由文本一律视为通过(向后兼容,不阻断自由反馈)。
|
||||
///
|
||||
/// 关键字集合与前端/IPC 的 options 命名约定对齐:「拒绝/驳回/退回」中文 +
|
||||
/// 「reject/decline/deny/no/block」英文,覆盖默认 options `["同意","拒绝"]`。
|
||||
pub(super) const REJECT_KEYWORDS: &[&str] = &[
|
||||
"拒绝", "驳回", "退回", "否决",
|
||||
"reject", "decline", "declined", "deny", "denied", "no", "block",
|
||||
];
|
||||
|
||||
/// 判定单个 decision 是否为拒绝选项(归一化后命中 REJECT_KEYWORDS)。
|
||||
pub(super) fn is_reject_decision(d: &str) -> bool {
|
||||
let normalized = d.trim().to_lowercase();
|
||||
REJECT_KEYWORDS.iter().any(|k| normalized == *k)
|
||||
}
|
||||
|
||||
/// 判定 picked 集合是否含拒绝选项:任一项命中即视为整单拒绝(多选场景「选了驳回」即驳回)。
|
||||
pub(super) fn contains_reject(picked: &[String]) -> bool {
|
||||
picked.iter().any(|d| is_reject_decision(d))
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、Docker、Git、人工审批、HTTP、子流程、通知
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod docker_node;
|
||||
pub mod git_node;
|
||||
pub mod http_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
pub mod human_node;
|
||||
pub mod notify_node;
|
||||
mod human_node_helpers;
|
||||
pub mod script_node;
|
||||
pub mod subflow_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
pub mod task_workflow_templates;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//! 通知节点 — 发送通知(邮件、飞书、钉钉等)
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// 通知节点
|
||||
pub struct NotifyNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for NotifyNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 实现通知发送逻辑
|
||||
tracing::info!("NotifyNode 执行: 发送通知");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "type": "string", "enum": ["email", "feishu", "dingtalk", "webhook"] },
|
||||
"to": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"body": { "type": "string" }
|
||||
},
|
||||
"required": ["channel", "to", "body"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"notify"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user