Compare commits
34
Commits
40a97a7655
...
9c3f27f4ca
| 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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
Generated
+33
@@ -860,7 +860,10 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tree-sitter",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-java",
|
||||
"tree-sitter-javascript",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"uuid",
|
||||
@@ -5122,6 +5125,26 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-go"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-java"
|
||||
version = "0.23.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-javascript"
|
||||
version = "0.25.0"
|
||||
@@ -5138,6 +5161,16 @@ version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-python"
|
||||
version = "0.23.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.24.2"
|
||||
|
||||
@@ -615,7 +615,10 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
// 上下文分段/压缩中,miniapp 无需特殊处理(状态在桌面端)
|
||||
break
|
||||
|
||||
case 'AiCompressed':
|
||||
// 治 Task#1:手动/自动两变体都插摘要气泡(miniapp 无独立压缩按钮,
|
||||
// 两路径都代表"对端发生压缩",插摘要气泡告知用户合理)。
|
||||
case 'AiManualCompressed':
|
||||
case 'AiAutoCompressed':
|
||||
// 压缩完成:在消息首位插入摘要提示
|
||||
messages.unshift({
|
||||
id: genId('compressed'),
|
||||
|
||||
@@ -68,7 +68,10 @@ export type AiChatEvent =
|
||||
}
|
||||
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressing'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressed'; conversation_id?: string | null; summary: string }
|
||||
// 治 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
|
||||
|
||||
@@ -222,6 +222,17 @@ impl AnthropicCompatProvider {
|
||||
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
|
||||
Self::merge_consecutive_users(&mut messages);
|
||||
|
||||
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
|
||||
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
|
||||
// "首条 role=assistant 非法"。
|
||||
// 根本解用"补"而非"砍":开头补一条 user 占位,保留全部上下文(assistant 的 tool_use
|
||||
// 与其后 user 的 tool_result 配对完整),precheck 必过。砍会丢工具调用历史,且多轮
|
||||
// [asst(tu),user(tr),asst(tu),user(tr),...] 逐对砍到空。占位是异常路径轻量噪声
|
||||
// (正常会话首条本就是 user),远优于丢弃上下文。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| AnthropicToolDef {
|
||||
@@ -295,6 +306,42 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
///
|
||||
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
|
||||
/// 或从历史片段截取时,真正的首条 user 已被裁剪/压缩掉。直接发触发 precheck "首条
|
||||
/// role=assistant 非法"。
|
||||
///
|
||||
/// **用"补"而非"砍"**:在开头插一条 user 占位消息。
|
||||
/// - 砍掉首条 assistant 会丢失有效上下文(其 tool_use 与后续 user 的 tool_result 是完整
|
||||
/// 配对),且多轮 [asst(tu),user(tr),asst(tu),user(tr),...] 会被逐对砍到空;
|
||||
/// - 补占位则全部上下文保留(占位 user 紧贴原首条 assistant,不破坏 user/assistant 交替),
|
||||
/// tool_use/tool_result 配对完整不动,**无 orphan 产生**(故砍策略那套 orphan 清理在此不需要),
|
||||
/// 占位 content 非空过 precheck 的"user content 空"校验。
|
||||
///
|
||||
/// 仅异常路径触发(正常会话首条本就是 user),占位文案是轻量噪声,远优于丢弃工具调用历史。
|
||||
fn ensure_leading_user(messages: &mut Vec<serde_json::Value>) {
|
||||
let first_role = messages
|
||||
.first()
|
||||
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
|
||||
.unwrap_or("");
|
||||
if first_role == "user" {
|
||||
return;
|
||||
}
|
||||
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
|
||||
// Anthropic 协议要求 messages 至少一条且首条 user,补占位让降级会话能继续(不丢这条兜底)。
|
||||
warn!(
|
||||
first_role,
|
||||
msg_count = messages.len(),
|
||||
"ensure_leading_user: 首条非 user(含空),补 user 占位(防 Anthropic 首条 assistant/空 messages 非法)"
|
||||
);
|
||||
messages.insert(0, serde_json::json!({
|
||||
"role": "user",
|
||||
"content": "(continued from previous context)",
|
||||
}));
|
||||
}
|
||||
|
||||
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||
@@ -878,4 +925,170 @@ mod tests {
|
||||
// 纯文本 → 字符串简写(非数组)
|
||||
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
|
||||
/// 辅助:构造 assistant(tool_use) 消息
|
||||
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
|
||||
ChatMessage::assistant_with_tools(
|
||||
text,
|
||||
vec![ToolCall::new(tool_id, tool_name, "{}")],
|
||||
)
|
||||
}
|
||||
|
||||
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
|
||||
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
msg_assistant_with_tool_use("我来帮你", "call_f892", "read_file"),
|
||||
ChatMessage::tool_result("call_f892", "file content"),
|
||||
msg_assistant_with_tool_use("继续", "call_003a", "write_file"),
|
||||
ChatMessage::tool_result("call_003a", "done"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
|
||||
// 首条必须是 user(补的占位)
|
||||
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
assert_eq!(first_role, "user", "首条应为 user(补占位)");
|
||||
|
||||
// 上下文零丢失:占位 user + asst(tu) + user(tr) + asst(tu) + user(tr) = 5 条
|
||||
assert_eq!(
|
||||
body.messages.len(), 5,
|
||||
"应保留全部上下文(占位 + 原 4 条),实际 {} 条", body.messages.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"precheck 应通过,实际 messages: {}",
|
||||
AnthropicCompatProvider::summarize_messages(&body.messages)
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_plain_assistant() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
ChatMessage::assistant("你好"),
|
||||
ChatMessage::user("请帮我"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 3, "占位 + 原 2 条");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi there"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 2, "正常序列不应补占位");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_three_round_chain() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![
|
||||
msg_assistant_with_tool_use("a1", "call_1", "read_file"),
|
||||
ChatMessage::tool_result("call_1", "r1"),
|
||||
msg_assistant_with_tool_use("a2", "call_2", "write_file"),
|
||||
ChatMessage::tool_result("call_2", "r2"),
|
||||
msg_assistant_with_tool_use("a3", "call_3", "list_directory"),
|
||||
ChatMessage::tool_result("call_3", "r3"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
let first_role = body.messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
assert_eq!(first_role, "user", "首条应为 user(补占位)");
|
||||
// 占位 + 3×[asst(tu),user(tr)] = 7 条,全部保留
|
||||
assert_eq!(
|
||||
body.messages.len(), 7,
|
||||
"3 轮链应全保留(占位 + 原 6 条),实际 {} 条", body.messages.len()
|
||||
);
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"3 轮链补占位后 precheck 应通过,实际: {}",
|
||||
AnthropicCompatProvider::summarize_messages(&body.messages)
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
let req = CompletionRequest {
|
||||
model: "claude-3-5-sonnet".into(),
|
||||
messages: vec![],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let body = provider.convert_request(req);
|
||||
assert_eq!(body.messages.len(), 1, "空 messages 应补 1 条 user 占位");
|
||||
assert_eq!(
|
||||
body.messages[0].get("role").and_then(|r| r.as_str()),
|
||||
Some("user"),
|
||||
);
|
||||
assert!(
|
||||
AnthropicCompatProvider::precheck_messages(&body.messages).is_ok(),
|
||||
"补占位后 precheck 应通过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,17 @@ impl ContextManager {
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
return (self.all_messages_clone(), false);
|
||||
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
@@ -802,9 +812,13 @@ impl ContextManager {
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
self.messages[..end]
|
||||
.iter()
|
||||
.any(|t| t.message.is_active())
|
||||
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
@@ -1463,10 +1477,10 @@ mod tests {
|
||||
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
|
||||
let mut mgr = ContextManager::new(cfg(10));
|
||||
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
|
||||
mgr.push(ChatMessage::user("第二条撑爆预算的长消息"));
|
||||
mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
|
||||
assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息");
|
||||
assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -105,7 +105,7 @@ impl OpenAICompatProvider {
|
||||
req.model
|
||||
};
|
||||
|
||||
let messages: Vec<OpenAiMessage> = req
|
||||
let mut messages: Vec<OpenAiMessage> = req
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
@@ -177,6 +177,12 @@ impl OpenAICompatProvider {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
|
||||
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
@@ -201,6 +207,36 @@ impl OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
///
|
||||
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
|
||||
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
/// assistant 的序列——会话恢复、续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉。
|
||||
///
|
||||
/// **用"补"而非"砍"**:开头插一条 user 占位,保留全部上下文(砍会丢工具调用历史,多轮砍到空)。
|
||||
/// 占位 user 紧贴原首条,不破坏 user/assistant 交替;仅异常路径触发(正常首条本就是 user)。
|
||||
fn ensure_leading_user(messages: &mut Vec<OpenAiMessage>) {
|
||||
let first_role = messages.first().map(|m| m.role.as_str()).unwrap_or("");
|
||||
if first_role == "user" || first_role == "system" {
|
||||
return;
|
||||
}
|
||||
warn!(
|
||||
first_role,
|
||||
msg_count = messages.len(),
|
||||
"ensure_leading_user: 首条非 user/system,补 user 占位(保留上下文,防 OpenAI 首条 assistant/tool 非法)"
|
||||
);
|
||||
messages.insert(
|
||||
0,
|
||||
OpenAiMessage {
|
||||
role: "user".into(),
|
||||
content: serde_json::Value::String("(continued from previous context)".into()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析同步响应中的工具调用
|
||||
fn parse_tool_calls(calls: Vec<OpenAiToolCallResp>) -> Vec<ToolCall> {
|
||||
calls
|
||||
@@ -594,4 +630,53 @@ mod tests {
|
||||
let msg = &out.messages[0];
|
||||
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||
|
||||
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![
|
||||
ChatMessage::assistant("我来帮你"),
|
||||
ChatMessage::user("继续"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
assert_eq!(out.messages.len(), 3, "占位 + 原 2 条");
|
||||
assert_eq!(out.messages[0].role.as_str(), "user", "首条应为 user(补占位)");
|
||||
assert_eq!(out.messages[1].role.as_str(), "assistant");
|
||||
assert_eq!(out.messages[2].role.as_str(), "user");
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_normal_unchanged() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
let req = CompletionRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi"),
|
||||
],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let out = provider.convert_request(req);
|
||||
assert_eq!(out.messages.len(), 2, "正常序列不补占位");
|
||||
assert_eq!(out.messages[0].role.as_str(), "user");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ mod adversarial_helpers;
|
||||
pub mod capture;
|
||||
pub mod promotion;
|
||||
pub mod scoring;
|
||||
pub mod scoring_keywords;
|
||||
|
||||
@@ -11,6 +11,15 @@ pub use crate::capture::IdeaScores;
|
||||
|
||||
use df_types::types::Priority;
|
||||
|
||||
// ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
|
||||
//
|
||||
// 关键词常量已拆至独立文件 `scoring_keywords.rs`。
|
||||
// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
|
||||
pub use crate::scoring_keywords::{
|
||||
FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
|
||||
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
|
||||
};
|
||||
|
||||
/// 评分权重配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringWeights {
|
||||
@@ -71,14 +80,10 @@ impl ScoringEngine {
|
||||
score -= 0.5;
|
||||
}
|
||||
// 可行性正向信号
|
||||
let pos = count_any(desc, &[
|
||||
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
|
||||
]);
|
||||
let pos = count_any(desc, FEASIBILITY_POSITIVE_KEYWORDS);
|
||||
score += (pos as f64) * 0.5;
|
||||
// 复杂度负向信号
|
||||
let neg = count_any(desc, &[
|
||||
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
|
||||
]);
|
||||
let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
|
||||
score -= (neg as f64) * 0.6;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
@@ -97,9 +102,7 @@ impl ScoringEngine {
|
||||
score += (idea.tags.len().min(4) as f64) * 0.25;
|
||||
}
|
||||
let desc = idea.description.trim();
|
||||
let value_hits = count_any(desc, &[
|
||||
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
|
||||
]);
|
||||
let value_hits = count_any(desc, IMPACT_KEYWORDS);
|
||||
score += (value_hits as f64) * 0.5;
|
||||
if desc.chars().count() > 100 {
|
||||
score += 0.5;
|
||||
@@ -116,9 +119,7 @@ impl ScoringEngine {
|
||||
Priority::Low => 3.0,
|
||||
};
|
||||
let desc = idea.description.trim();
|
||||
let time_hits = count_any(desc, &[
|
||||
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
|
||||
]);
|
||||
let time_hits = count_any(desc, URGENCY_KEYWORDS);
|
||||
score += (time_hits as f64) * 0.5;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
@@ -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", "截止", "先行", "阻塞",
|
||||
];
|
||||
@@ -393,6 +393,9 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -446,6 +449,9 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
review_rounds: existing.review_rounds,
|
||||
output_json: existing.output_json,
|
||||
idea_id: existing.idea_id,
|
||||
queue: existing.queue,
|
||||
parent_id: existing.parent_id,
|
||||
content_json: existing.content_json,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -311,6 +311,9 @@ mod tests {
|
||||
review_rounds: 0,
|
||||
output_json: task_output_json.map(String::from),
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
|
||||
@@ -190,6 +190,9 @@ mod tests {
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# df-relay 跨端 WS 中继反向代理(miniapp ↔ 桌面端)
|
||||
# wss 在 nginx 终止(复用 *.1216.top 通配符证书),明文 ws 转 df-relay :9180
|
||||
|
||||
# HTTP → HTTPS 重定向
|
||||
server {
|
||||
listen 80;
|
||||
server_name u-work.1216.top;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS wss 反代
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name u-work.1216.top;
|
||||
|
||||
include /etc/nginx/snippets/ssl-1216.conf;
|
||||
|
||||
# df-relay 两个 WS 端点原样转发(/ws/device + /ws/miniapp)
|
||||
# proxy_pass 不带路径后缀 → 完整 URI 透传给 df-relay
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:9180;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
dot / (norm_a * norm_b + 1e-8)
|
||||
}
|
||||
|
||||
/// 解析 JSON 数组字符串为 Vec<String>。NULL / 空 / 非法 → 空 Vec。
|
||||
fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
|
||||
match raw {
|
||||
Some(s) if !s.is_empty() => match serde_json::from_str::<Vec<String>>(s) {
|
||||
Ok(v) => v,
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
@@ -271,28 +282,31 @@ impl IdeaRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符)
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
// 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
|
||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
|
||||
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
|
||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
|
||||
// 占位符编号用 params_vec.len()+1(参数实际位置),非 where_clauses.len()+1
|
||||
// (where_clauses 含 deleted_at IS NULL 常量无占位符子句,len() 会偏移致 ?N 与参数错位
|
||||
// — 父⑤⑤.1 加 deleted_at 恒带引入的潜伏 bug,非空 status/keyword 查询 rusqlite 报
|
||||
// "needed N, got M"。对标 task_repo list_by_query ②.2 同款修复)
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let p1 = where_clauses.len() + 1;
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
// where_clauses 恒含 deleted_at IS NULL(初始项),永非空,直接 join 拼 WHERE。
|
||||
let where_sql = format!(" WHERE {}", where_clauses.join(" AND "));
|
||||
|
||||
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
|
||||
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
|
||||
@@ -341,6 +355,184 @@ impl IdeaRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删灵感,返回是否命中。
|
||||
/// 对标 TaskRepo::soft_delete / ProjectRepo::soft_delete。
|
||||
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ideas SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删灵感,返回是否命中。
|
||||
/// 对标 TaskRepo::restore / ProjectRepo::restore。
|
||||
pub async fn restore(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ideas SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
|
||||
///
|
||||
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
|
||||
/// 新增的关联目标追加 `subject_id` 到其 `related_ids`;
|
||||
/// 移除的关联目标从中删除 `subject_id`。
|
||||
/// 全部操作在同一 SQLite 事务中完成,保证原子性。
|
||||
pub async fn sync_related_ids(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
new_target_ids: &[String],
|
||||
) -> Result<()> {
|
||||
use rusqlite::Transaction;
|
||||
let conn = self.conn.clone();
|
||||
let subject_id = subject_id.to_owned();
|
||||
let new_target_ids = new_target_ids.to_vec();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx: Transaction = guard
|
||||
.transaction()
|
||||
.map_err(storage_err)?;
|
||||
|
||||
// 1. 读主体当前 related_ids
|
||||
let old_raw: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
||||
params![subject_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?
|
||||
.flatten();
|
||||
|
||||
// 2. 解析新旧集合
|
||||
let old_set: std::collections::HashSet<String> =
|
||||
parse_json_id_array(&old_raw).into_iter().collect();
|
||||
let new_set: std::collections::HashSet<String> =
|
||||
new_target_ids.iter().cloned().collect();
|
||||
|
||||
let added: Vec<&str> = new_set
|
||||
.difference(&old_set)
|
||||
.map(|s| s.as_str())
|
||||
.filter(|id| *id != subject_id) // 不自关联
|
||||
.collect();
|
||||
let removed: Vec<&str> = old_set
|
||||
.difference(&new_set)
|
||||
.map(|s| s.as_str())
|
||||
.filter(|id| *id != subject_id)
|
||||
.collect();
|
||||
|
||||
// 3. 更新 added 目标:追加 subject_id
|
||||
for target_id in &added {
|
||||
let cur: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
||||
params![target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?
|
||||
.flatten();
|
||||
let mut ids: Vec<String> = parse_json_id_array(&cur);
|
||||
if !ids.iter().any(|i| i == &subject_id) {
|
||||
ids.push(subject_id.clone());
|
||||
}
|
||||
let json = serde_json::to_string(&ids).map_err(|e| {
|
||||
storage_err::<df_types::error::Error>(e.into())
|
||||
})?;
|
||||
tx.execute(
|
||||
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![json, &now, target_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
|
||||
// 4. 更新 removed 目标:移除 subject_id
|
||||
for target_id in &removed {
|
||||
let cur: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
||||
params![target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?
|
||||
.flatten();
|
||||
let mut ids: Vec<String> = parse_json_id_array(&cur);
|
||||
ids.retain(|i| i != &subject_id);
|
||||
let json = serde_json::to_string(&ids).map_err(|e| {
|
||||
storage_err::<df_types::error::Error>(e.into())
|
||||
})?;
|
||||
tx.execute(
|
||||
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![json, &now, target_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
|
||||
// 5. 更新主体
|
||||
let new_json = serde_json::to_string(&new_target_ids)
|
||||
.map_err(|e| storage_err::<df_types::error::Error>(e.into()))?;
|
||||
tx.execute(
|
||||
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![new_json, &now, &subject_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
|
||||
tx.commit().map_err(storage_err)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。
|
||||
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
|
||||
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&format!(
|
||||
"SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC"
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| idea_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
impl KnowledgeRepo {
|
||||
@@ -1202,4 +1394,101 @@ mod tests {
|
||||
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
|
||||
}
|
||||
|
||||
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
|
||||
|
||||
/// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
|
||||
fn irec(id: &str, title: &str) -> IdeaRecord {
|
||||
IdeaRecord {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: String::new(),
|
||||
status: "draft".to_string(),
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
source: None,
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_idea_repo() -> IdeaRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
IdeaRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_marks_and_filters_from_list_by_query() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "保留")).await.unwrap();
|
||||
repo.insert(irec("i2", "软删")).await.unwrap();
|
||||
|
||||
// 软删 i2:返回 true(命中)
|
||||
assert!(repo.soft_delete("i2").await.unwrap());
|
||||
|
||||
// list_by_query(空 query = 等价全量未删)应只返回 i1,i2 进回收站被过滤
|
||||
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
|
||||
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["i1"], "soft_delete 后 list_by_query 应过滤回收站");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_idempotent_on_deleted() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
assert!(repo.soft_delete("i1").await.unwrap());
|
||||
// 已删再删:WHERE deleted_at IS NULL 不命中 → false
|
||||
assert!(!repo.soft_delete("i1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_missing_returns_false() {
|
||||
let repo = setup_idea_repo().await;
|
||||
// 不存在的 id → affected=0
|
||||
assert!(!repo.soft_delete("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_restore_brings_back_to_active() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
repo.soft_delete("i1").await.unwrap();
|
||||
// 恢复:返回 true,回到 list_by_query
|
||||
assert!(repo.restore("i1").await.unwrap());
|
||||
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
|
||||
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["i1"], "restore 后灵感应回到活跃列表");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_restore_only_affects_deleted() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
// 未删灵感 restore:WHERE deleted_at IS NOT NULL 不命中 → false
|
||||
assert!(!repo.restore("i1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "活跃")).await.unwrap();
|
||||
repo.insert(irec("i2", "回收1")).await.unwrap();
|
||||
repo.insert(irec("i3", "回收2")).await.unwrap();
|
||||
|
||||
// 软删 i2 / i3(updated_at 在 soft_delete 时被刷新为 now)
|
||||
repo.soft_delete("i2").await.unwrap();
|
||||
// 让 i3 的删除时间晚于 i2,保证 updated_at DESC 顺序确定(i3 在前)
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
repo.soft_delete("i3").await.unwrap();
|
||||
|
||||
let deleted = repo.list_deleted().await.unwrap();
|
||||
let ids: Vec<_> = deleted.iter().map(|r| r.id.as_str()).collect();
|
||||
// i1 活跃不出现;i3 删除最晚在前
|
||||
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
|
||||
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
|
||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
||||
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
||||
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
|
||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
@@ -18,16 +21,22 @@ mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_repo;
|
||||
mod project_service_repo;
|
||||
mod settings;
|
||||
mod task_link_repo;
|
||||
mod task_repo;
|
||||
|
||||
pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
pub use task_repo::*;
|
||||
|
||||
// ============================================================
|
||||
@@ -266,7 +275,7 @@ mod baseline_tests {
|
||||
let tables = [
|
||||
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
|
||||
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
|
||||
"knowledges", "knowledge_events",
|
||||
"knowledges", "knowledge_events", "project_services",
|
||||
];
|
||||
for t in tables {
|
||||
assert!(
|
||||
@@ -293,6 +302,9 @@ mod baseline_tests {
|
||||
let _ = IdeaRepo::new(&db);
|
||||
let _ = ProjectRepo::new(&db);
|
||||
let _ = TaskRepo::new(&db);
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = ProjectServiceRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! 项目事件流 Repo:ProjectEventRepo(project_events 表,知识图谱 Phase 2 V30)
|
||||
//!
|
||||
//! 统一事件流——一张表承载跨实体(idea/task/workflow/knowledge/module/service/project)的
|
||||
//! 全部事件,AI 精准检索的基础(回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出")。
|
||||
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4。
|
||||
//!
|
||||
//! 关键设计(对标 TaskLinkRepo 全专用路径):
|
||||
//! - **追加型审计表**:只 INSERT,无 UPDATE/DELETE(历史不改可追溯,对标 knowledge_events V10 /
|
||||
//! idea_evaluations V22)。故不走 `impl_repo!` 宏(宏生成的 update/delete/update_field 对审计
|
||||
//! 语义是错的),手写专用 insert + 三个查询方法。
|
||||
//! - 不登记通用列白名单(`allowed_columns_for("project_events")` 返回 None):同 task_links,
|
||||
//! 无通用 update_field 路径故无需白名单。`baseline_tests::all_known_tables_have_column_whitelist`
|
||||
//! 的已知表数组不含本表(与 task_links 同款豁免)。
|
||||
//! - `event_type` / `entity_type` 白名单**应用层校验**(类比 task_links.link_type),不进 DB 约束
|
||||
//! (保留扩展性,新增事件/实体类型无需迁移)。本 Repo insert **不做白名单校验**——埋点层
|
||||
//! (commands/IPC hook)是事件唯一生产者,字符串字面量自带约束;开放校验留给未来若开放外部写入。
|
||||
//! - 埋点策略(设计 §2.4 hook/after)与 knowledge_events 双写均在调用方(commands 层)实现,
|
||||
//! 非 Repo 职责。事件写入失败 best-effort 不阻断主操作(设计 §10.1)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectEventRecord;
|
||||
|
||||
use super::{now_millis_str, storage_err};
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_event_from_row(row: &Row<'_>) -> std::result::Result<ProjectEventRecord, rusqlite::Error> {
|
||||
Ok(ProjectEventRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
event_type: row.get("event_type")?,
|
||||
entity_type: row.get("entity_type")?,
|
||||
entity_id: row.get("entity_id")?,
|
||||
from_state: row.get("from_state")?,
|
||||
to_state: row.get("to_state")?,
|
||||
context_json: row.get("context_json")?,
|
||||
source: row.get("source")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
created_at: row.get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// project_events SELECT 列清单(11 字段,防 COLS 漂移,对标 KNOWLEDGE_COLS)。
|
||||
const PROJECT_EVENT_COLS: &str = "id,project_id,event_type,entity_type,entity_id,from_state,\
|
||||
to_state,context_json,source,conversation_id,created_at";
|
||||
|
||||
// ============================================================
|
||||
// ProjectEventRepo
|
||||
// ============================================================
|
||||
|
||||
/// 项目统一事件流表 Repo(project_events,V30,追加型审计表)。
|
||||
///
|
||||
/// 不走 `impl_repo!` 宏:① 追加型审计表无 UPDATE/DELETE(宏生成的 update/update_field/delete
|
||||
/// 对审计语义是错的);② 表无 `updated_at`(宏的 update_field 硬编码 `SET ... updated_at = ?`
|
||||
/// 会触发 "no such column: updated_at")。故手写专用方法,对标 TaskLinkRepo 全专用路径。
|
||||
pub struct ProjectEventRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ProjectEventRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 追加一条事件(只 INSERT,无 UPDATE,审计语义不可篡改)。
|
||||
///
|
||||
/// 返回插入的事件 id。`created_at` 由本方法内部取当前毫秒时间戳填充(调用方无需传),
|
||||
/// 与 IdeaRepo::insert(now_millis_str)同款收口——事件生产时机即落库时机,时间由存储层定。
|
||||
///
|
||||
/// 不做 event_type / entity_type 白名单校验(埋点层是唯一生产者,字符串字面量自带约束)。
|
||||
pub async fn insert(&self, record: ProjectEventRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
// created_at 由存储层覆盖(调用方传入的不可信),保证「事件生产时机即落库时机」。
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO project_events \
|
||||
(id, project_id, event_type, entity_type, entity_id, from_state, to_state, \
|
||||
context_json, source, conversation_id, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
record.id,
|
||||
record.project_id,
|
||||
record.event_type,
|
||||
record.entity_type,
|
||||
record.entity_id,
|
||||
record.from_state,
|
||||
record.to_state,
|
||||
record.context_json,
|
||||
record.source,
|
||||
record.conversation_id,
|
||||
now,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询事件流(time DESC,id DESC 兜底同毫秒并列,limit 钳制上限 200,
|
||||
/// 命中 idx_project_events_project)。
|
||||
///
|
||||
/// AI / Dashboard 项目时间线视图用:取某项目最近 N 条事件(设计 §2.4 / §10.3.4)。
|
||||
/// 同毫秒插入的事件(高频埋点常见)以 id DESC 兜底——生产 id 是 ULID(字典序单调递增),
|
||||
/// 字典序 DESC 等价插入逆序,保证「最后发生的事件在最前」语义稳定(纯 created_at DESC
|
||||
/// 同毫秒并列时顺序不确定,审计表必须可复现)。
|
||||
pub async fn get_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ProjectEventRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
// 钳制 limit 防滥用(最大 200,对标 KnowledgeEventsRepo::list_recent)
|
||||
let safe_limit = limit.min(200) as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
|
||||
WHERE project_id = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id, safe_limit], project_event_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按实体反查事件流(命中 idx_project_events_entity)。
|
||||
///
|
||||
/// AI「这个任务/灵感发生过什么」反查:取某实体的全部事件(时间倒序)。跨项目——
|
||||
/// 同一实体(如被移动过项目的任务)的全部历史事件都返回。同毫秒并列以 id DESC 兜底
|
||||
/// (语义稳定,见 get_by_project 说明)。
|
||||
pub async fn get_by_entity(
|
||||
&self,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<Vec<ProjectEventRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let entity_type = entity_type.to_owned();
|
||||
let entity_id = entity_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
|
||||
WHERE entity_type = ?1 AND entity_id = ?2 ORDER BY created_at DESC, id DESC"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![entity_type, entity_id], project_event_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 跨项目列最近 N 条事件(全表 created_at DESC,id DESC 兜底,top-N,命中
|
||||
/// idx_project_events_project 的 created_at 维度)。
|
||||
///
|
||||
/// 对标 KnowledgeEventsRepo::list_recent:供「全局事件流 / 跨项目时间线」视图使用
|
||||
/// (设计 §10.3.4 模式 B 事件流)。limit 上限钳制 200,防恶意/失误传超大值。
|
||||
/// 同毫秒并列以 id DESC 兜底(语义稳定,见 get_by_project 说明)。
|
||||
pub async fn list_recent(&self, limit: u32) -> Result<Vec<ProjectEventRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let safe_limit = limit.min(200) as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?1"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![safe_limit], project_event_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectEventRepo insert + get_by_project + get_by_entity(内存 DB,
|
||||
// 对标 task_link_repo 测试:先建占位 project 满足 FK,再插事件)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectEventRepo) {
|
||||
let db = crate::db::Database::open_in_memory()
|
||||
.await
|
||||
.expect("open_in_memory");
|
||||
let repo = ProjectEventRepo::new(&db);
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
(db, repo)
|
||||
}
|
||||
|
||||
/// 构造一条 ProjectEventRecord fixture(可指定 created_at 便于排序断言)。
|
||||
fn erec(id: &str, project_id: &str, created_at: &str) -> ProjectEventRecord {
|
||||
ProjectEventRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
event_type: "task_advanced".to_string(),
|
||||
entity_type: Some("task".to_string()),
|
||||
entity_id: Some("task-A".to_string()),
|
||||
from_state: Some("todo".to_string()),
|
||||
to_state: Some("in_progress".to_string()),
|
||||
context_json: None,
|
||||
source: Some("ai".to_string()),
|
||||
conversation_id: Some("conv-1".to_string()),
|
||||
created_at: created_at.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_and_read_back_full_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let id = repo.insert(erec("e1", "proj-1", "1700000000001")).await.unwrap();
|
||||
assert_eq!(id, "e1");
|
||||
|
||||
let got = repo
|
||||
.get_by_project("proj-1", 10)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("应有 1 条事件");
|
||||
// 全字段回读校验
|
||||
assert_eq!(got.id, "e1");
|
||||
assert_eq!(got.project_id, "proj-1");
|
||||
assert_eq!(got.event_type, "task_advanced");
|
||||
assert_eq!(got.entity_type.as_deref(), Some("task"));
|
||||
assert_eq!(got.entity_id.as_deref(), Some("task-A"));
|
||||
assert_eq!(got.from_state.as_deref(), Some("todo"));
|
||||
assert_eq!(got.to_state.as_deref(), Some("in_progress"));
|
||||
assert!(got.context_json.is_none());
|
||||
assert_eq!(got.source.as_deref(), Some("ai"));
|
||||
assert_eq!(got.conversation_id.as_deref(), Some("conv-1"));
|
||||
// created_at 由存储层覆盖为当前毫秒(非 fixture 传入的旧值)
|
||||
assert_ne!(got.created_at, "1700000000001", "created_at 应由存储层覆盖");
|
||||
assert!(!got.created_at.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_project_orders_desc_and_filters() {
|
||||
let (_db, repo) = setup().await;
|
||||
// 三条事件不同时间(create_at 由存储层覆盖,但插入顺序决定落库 created_at 单调递增,
|
||||
// 故 DESC 应按插入逆序返回——最后插的最前)
|
||||
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e3", "proj-1", "1")).await.unwrap();
|
||||
|
||||
let got = repo.get_by_project("proj-1", 10).await.unwrap();
|
||||
assert_eq!(got.len(), 3, "应返回全部 3 条事件");
|
||||
// DESC:最后插入的 e3 在前
|
||||
assert_eq!(got[0].id, "e3");
|
||||
assert_eq!(got[1].id, "e2");
|
||||
assert_eq!(got[2].id, "e1");
|
||||
|
||||
// 过滤:不存在的项目返回空
|
||||
let empty = repo.get_by_project("no-such-proj", 10).await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_project_limit_clamped() {
|
||||
let (_db, repo) = setup().await;
|
||||
for i in 0..5 {
|
||||
repo.insert(erec(&format!("e{i}"), "proj-1", "1"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// limit=2 只返回 2 条(最新两条)
|
||||
let got = repo.get_by_project("proj-1", 2).await.unwrap();
|
||||
assert_eq!(got.len(), 2);
|
||||
// limit=99999 钳制为 200,但只有 5 条,返回 5 条
|
||||
let got_all = repo.get_by_project("proj-1", 99999).await.unwrap();
|
||||
assert_eq!(got_all.len(), 5, "钳制后仍返回全部存在的事件");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_entity_cross_project_and_filters() {
|
||||
let (_db, repo) = setup().await;
|
||||
// 同一实体 task-A 在 proj-1 下产生 2 条事件
|
||||
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
|
||||
// 另一实体 task-B 1 条(改 entity_id)
|
||||
let mut other = erec("e3", "proj-1", "1");
|
||||
other.entity_id = Some("task-B".to_string());
|
||||
repo.insert(other).await.unwrap();
|
||||
|
||||
// task-A 反查:返回 2 条(DESC)
|
||||
let got_a = repo.get_by_entity("task", "task-A").await.unwrap();
|
||||
assert_eq!(got_a.len(), 2);
|
||||
assert_eq!(got_a[0].id, "e2");
|
||||
assert_eq!(got_a[1].id, "e1");
|
||||
|
||||
// task-B 反查:返回 1 条
|
||||
let got_b = repo.get_by_entity("task", "task-B").await.unwrap();
|
||||
assert_eq!(got_b.len(), 1);
|
||||
assert_eq!(got_b[0].id, "e3");
|
||||
|
||||
// 不存在的实体返回空
|
||||
let empty = repo.get_by_entity("task", "no-such").await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_recent_cross_project() {
|
||||
let (_db, repo) = setup().await;
|
||||
for i in 0..3 {
|
||||
repo.insert(erec(&format!("e{i}"), "proj-1", "1"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// list_recent 跨项目取最近 N 条(此处仅 proj-1)
|
||||
let got = repo.list_recent(10).await.unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
// DESC:最后插入的最前
|
||||
assert_eq!(got[0].id, "e2");
|
||||
}
|
||||
|
||||
/// entity_type/entity_id 为 None(纯决策日志无明确实体)也能正常插入与查询。
|
||||
#[tokio::test]
|
||||
async fn insert_with_null_entity_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = erec("e1", "proj-1", "1");
|
||||
rec.event_type = "decision_made".to_string();
|
||||
rec.entity_type = None;
|
||||
rec.entity_id = None;
|
||||
rec.from_state = None;
|
||||
rec.to_state = None;
|
||||
repo.insert(rec).await.unwrap();
|
||||
|
||||
let got = repo.get_by_project("proj-1", 10).await.unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].entity_type.is_none());
|
||||
assert!(got[0].entity_id.is_none());
|
||||
assert!(got[0].from_state.is_none());
|
||||
assert!(got[0].to_state.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! 项目基础设施配置 Repo:ProjectServiceRepo(project_services 表,知识图谱 Phase 3 V31)
|
||||
//!
|
||||
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
|
||||
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计
|
||||
//! docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3。
|
||||
//!
|
||||
//! 关键设计(对标 IdeaEvalRepo 走宏 + 专用方法模式):
|
||||
//! - **走 `impl_repo!` 宏**:表有 `updated_at`(基础设施配置可改,非审计不可篡改),
|
||||
//! 故宏生成的 insert/get_by_id/list_all/query/update_field/update_full/delete 全可用,
|
||||
//! 白名单已在 settings.rs 登记(allowed_columns_for("project_services") 返回 Some)。
|
||||
//! - `service_type` 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other)应用层校验,
|
||||
//! 非进 DB 约束(对标 task_links.link_type / project_events.event_type,保留扩展性)。
|
||||
//! - **敏感凭证审查(D10)**:insert/update_full 时检测 config_json / endpoint / remark 中是否
|
||||
//! 含 password/secret/token/api_key 子串,命中即拒(防密码/密钥误存进表)。审查在应用层
|
||||
//! 非 DB 约束(SQLite CHECK 对 JSON 内容无法表达),best-effort 兜底,非加密级安全。
|
||||
//! - 专用 `list_by_project(project_id)` / `list_by_project_env(project_id, environment)`
|
||||
//! 覆盖最高频查询(命中 idx_project_services_project)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectServiceRecord;
|
||||
|
||||
use super::impl_repo;
|
||||
use super::{now_millis_str, storage_err, validate_column_name};
|
||||
|
||||
// ============================================================
|
||||
// service_type 白名单 + 校验
|
||||
// ============================================================
|
||||
|
||||
/// `service_type` 白名单:基础设施类型枚举(对标设计 §2.3)。
|
||||
///
|
||||
/// 防拼写漂移 / 非法值进库。新增类型(如 elasticsearch / kafka)在此数组追加 + 设计文档
|
||||
/// §2.3 同步更新,无需 DB 迁移(应用层校验,保留扩展性)。
|
||||
pub const PROJECT_SERVICE_TYPES: &[&str] = &[
|
||||
"mysql",
|
||||
"postgresql",
|
||||
"sqlite",
|
||||
"redis",
|
||||
"mongodb",
|
||||
"mq",
|
||||
"api",
|
||||
"other",
|
||||
];
|
||||
|
||||
/// 校验 `service_type` 在白名单内,否则返回 Err(对标 task_links.validate_link_type)。
|
||||
pub fn validate_service_type(service_type: &str) -> Result<()> {
|
||||
if PROJECT_SERVICE_TYPES.contains(&service_type) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(df_types::error::Error::Storage(format!(
|
||||
"非法 service_type: {service_type},合法值: {:?}",
|
||||
PROJECT_SERVICE_TYPES
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 敏感凭证审查(D10 安全边界)
|
||||
// ============================================================
|
||||
|
||||
/// 敏感字段名子串黑名单(小写匹配)。config_json / endpoint / remark 含任一即视为误存凭证。
|
||||
///
|
||||
/// D10 边界:本表不存密码/密钥/Token,凭证走环境变量。此审查是 best-effort 兜底——检测常见
|
||||
/// 凭证字段名子串拒绝,防用户/AI 误把 `{"password": "xxx"}` 灌进 config_json。非加密级安全,
|
||||
/// 非穷尽(如把密码放在非标准字段名仍可能漏检),核心防御仍依赖调用方纪律 + 环境变量约定。
|
||||
const SENSITIVE_TOKENS: &[&str] = &["password", "passwd", "secret", "api_key", "apikey", "token"];
|
||||
|
||||
/// 审查给定文本(可空)是否含敏感字段名子串(大小写不敏感),命中即返回 Err(D10 边界)。
|
||||
///
|
||||
/// 对 config_json / endpoint / remark 三字段调用。endpoint 含 "token=" query 参数(如
|
||||
/// `?token=abc`)或 config_json 含 `"password":` 都会被拦截。空/None 文本直接放行。
|
||||
fn reject_sensitive(text: Option<&str>, field_name: &str) -> Result<()> {
|
||||
let Some(t) = text else { return Ok(()) };
|
||||
if t.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let lower = t.to_ascii_lowercase();
|
||||
for tok in SENSITIVE_TOKENS {
|
||||
if lower.contains(tok) {
|
||||
return Err(df_types::error::Error::Storage(format!(
|
||||
"project_services.{field_name} 含敏感凭证字段「{tok}」,违反 D10 安全边界:\
|
||||
不存敏感凭证,密码/密钥走环境变量。请改用环境变量引用"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对一条 ProjectServiceRecord 做应用层前置校验(service_type 白名单 + 凭证审查)。
|
||||
/// insert / update_full 共用,保证两条写入路径校验一致。
|
||||
fn validate_record(rec: &ProjectServiceRecord) -> Result<()> {
|
||||
validate_service_type(&rec.service_type)?;
|
||||
reject_sensitive(rec.config_json.as_deref(), "config_json")?;
|
||||
reject_sensitive(rec.endpoint.as_deref(), "endpoint")?;
|
||||
reject_sensitive(rec.remark.as_deref(), "remark")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_service_from_row(row: &Row<'_>) -> std::result::Result<ProjectServiceRecord, rusqlite::Error> {
|
||||
Ok(ProjectServiceRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
name: row.get("name")?,
|
||||
service_type: row.get("service_type")?,
|
||||
endpoint: row.get("endpoint")?,
|
||||
config_json: row.get("config_json")?,
|
||||
environment: row.get("environment")?,
|
||||
remark: row.get("remark")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现
|
||||
// ============================================================
|
||||
|
||||
impl_repo!(
|
||||
/// 项目基础设施配置表 CRUD(project_services,V31,可改非审计)。
|
||||
///
|
||||
/// 走 `impl_repo!` 宏:表有 `updated_at`,标准 CRUD 全可用,白名单在 settings.rs 登记。
|
||||
/// 注:宏生成的 `update_field` 经 settings 白名单(不含 service_type)收口——service_type
|
||||
/// 变更须走 `update_full`(过应用层校验 + 凭证审查),不可旁路 update_field 改类型。
|
||||
ProjectServiceRepo,
|
||||
ProjectServiceRecord,
|
||||
"project_services",
|
||||
from_row => |row| project_service_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.name, rec.service_type,
|
||||
rec.endpoint, rec.config_json, rec.environment, rec.remark,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||
params![
|
||||
rec.project_id, rec.name, rec.service_type,
|
||||
rec.endpoint, rec.config_json, rec.environment, rec.remark,
|
||||
rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl ProjectServiceRepo {
|
||||
/// 插入一条基础设施配置(应用层校验 service_type 白名单 + 凭证审查 D10 后再落库)。
|
||||
///
|
||||
/// 校验 fail-fast 在 spawn_blocking 前(非法值不进 DB 层),与 TaskLinkRepo::create_link
|
||||
/// 同款。`created_at` / `updated_at` 由本方法内部取当前毫秒时间戳覆盖(调用方传入的不可信),
|
||||
/// 保证「写入时机即落库时机」(对标 ProjectEventRepo::insert)。
|
||||
pub async fn insert_validated(&self, record: ProjectServiceRecord) -> Result<String> {
|
||||
// 应用层校验先行(fail-fast,非法值/凭证不进 DB 层)
|
||||
validate_record(&record)?;
|
||||
let conn = self.conn.clone();
|
||||
let now = now_millis_str();
|
||||
let mut rec = record;
|
||||
rec.created_at = now.clone();
|
||||
rec.updated_at = now;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let id = rec.id.clone();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
conn.execute(
|
||||
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
r.id, r.project_id, r.name, r.service_type,
|
||||
r.endpoint, r.config_json, r.environment, r.remark,
|
||||
r.created_at, r.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 整体更新(应用层校验 service_type + 凭证审查 D10 后再落库,保留 id 与 created_at)。
|
||||
///
|
||||
/// 对标宏生成的 `update_full`,但在落库前过 `validate_record`(宏无业务前置逻辑)。
|
||||
/// `updated_at` 由本方法内部取当前毫秒时间戳覆盖。
|
||||
pub async fn update_full_validated(&self, record: &ProjectServiceRecord) -> Result<bool> {
|
||||
validate_record(record)?;
|
||||
let conn = self.conn.clone();
|
||||
let mut rec = record.clone();
|
||||
rec.updated_at = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn.execute(
|
||||
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||
params![
|
||||
r.project_id, r.name, r.service_type,
|
||||
r.endpoint, r.config_json, r.environment, r.remark,
|
||||
r.updated_at, r.id
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询全部基础设施配置(命中 idx_project_services_project,created_at DESC)。
|
||||
///
|
||||
/// AI 项目上下文注入 / Dashboard 项目基础设施视图用:取某项目的全部服务配置。
|
||||
pub async fn list_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<ProjectServiceRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
|
||||
FROM project_services WHERE project_id = ?1 ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id], project_service_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目 + 环境查询基础设施配置(命中 idx_project_services_project,environment 等值过滤)。
|
||||
///
|
||||
/// AI 执行任务时按当前环境取基础设施:production 部署任务只取 production 服务,
|
||||
/// 开发调试取 development 服务。环境隔离避免误连生产库。
|
||||
pub async fn list_by_project_env(
|
||||
&self,
|
||||
project_id: &str,
|
||||
environment: &str,
|
||||
) -> Result<Vec<ProjectServiceRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
let environment = environment.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
|
||||
FROM project_services WHERE project_id = ?1 AND environment = ?2 ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id, environment], project_service_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectServiceRepo CRUD + 校验 + 环境过滤(内存 DB,
|
||||
// 对标 project_event_repo 测试:先建占位 project 满足 FK,再插服务)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
|
||||
let db = crate::db::Database::open_in_memory()
|
||||
.await
|
||||
.expect("open_in_memory");
|
||||
let repo = ProjectServiceRepo::new(&db);
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
(db, repo)
|
||||
}
|
||||
|
||||
/// 构造一条 ProjectServiceRecord fixture(全字段,环境可指定)。
|
||||
fn srec(id: &str, service_type: &str, environment: &str) -> ProjectServiceRecord {
|
||||
ProjectServiceRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
name: format!("svc-{id}"),
|
||||
service_type: service_type.to_string(),
|
||||
endpoint: Some("localhost:3306".to_string()),
|
||||
config_json: Some(r#"{"pool_size":10}"#.to_string()),
|
||||
environment: environment.to_string(),
|
||||
remark: Some("主库".to_string()),
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_service_type_rejects_unknown() {
|
||||
// 白名单内全部通过
|
||||
assert!(validate_service_type("mysql").is_ok());
|
||||
assert!(validate_service_type("postgresql").is_ok());
|
||||
assert!(validate_service_type("sqlite").is_ok());
|
||||
assert!(validate_service_type("redis").is_ok());
|
||||
assert!(validate_service_type("mongodb").is_ok());
|
||||
assert!(validate_service_type("mq").is_ok());
|
||||
assert!(validate_service_type("api").is_ok());
|
||||
assert!(validate_service_type("other").is_ok());
|
||||
// 非法值拒绝
|
||||
assert!(validate_service_type("elasticsearch").is_err());
|
||||
assert!(validate_service_type("").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_validated_and_read_back_full_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let id = repo
|
||||
.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(id, "s1");
|
||||
|
||||
let got = repo.get_by_id("s1").await.unwrap().expect("应存在");
|
||||
// 全字段回读校验
|
||||
assert_eq!(got.id, "s1");
|
||||
assert_eq!(got.project_id, "proj-1");
|
||||
assert_eq!(got.name, "svc-s1");
|
||||
assert_eq!(got.service_type, "mysql");
|
||||
assert_eq!(got.endpoint.as_deref(), Some("localhost:3306"));
|
||||
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||
assert_eq!(got.environment, "development");
|
||||
assert_eq!(got.remark.as_deref(), Some("主库"));
|
||||
// created_at / updated_at 由存储层覆盖为当前毫秒
|
||||
assert!(!got.created_at.is_empty());
|
||||
assert!(!got.updated_at.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_invalid_service_type() {
|
||||
let (_db, repo) = setup().await;
|
||||
let err = repo
|
||||
.insert_validated(srec("s1", "elasticsearch", "development"))
|
||||
.await;
|
||||
assert!(err.is_err(), "非法 service_type 应被拒绝");
|
||||
// 未入库
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_sensitive_in_config_json() {
|
||||
// D10 边界:config_json 含 password 应被拒绝
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = srec("s1", "mysql", "development");
|
||||
rec.config_json = Some(r#"{"password":"secret123"}"#.to_string());
|
||||
let err = repo.insert_validated(rec).await;
|
||||
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_sensitive_in_endpoint() {
|
||||
// endpoint query 参数带 token= 应被拒绝
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = srec("s1", "api", "development");
|
||||
rec.endpoint = Some("https://api.example.com?token=abc123".to_string());
|
||||
let err = repo.insert_validated(rec).await;
|
||||
assert!(err.is_err(), "endpoint 含 token 应被 D10 拒绝");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_rejects_sensitive_in_remark() {
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = srec("s1", "redis", "development");
|
||||
rec.remark = Some("API_KEY=sk-xxxx".to_string());
|
||||
let err = repo.insert_validated(rec).await;
|
||||
assert!(err.is_err(), "remark 含 api_key 应被 D10 拒绝");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_validated_rejects_sensitive() {
|
||||
// 先插一条合法的,update 时灌入凭证应被拒
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut bad = srec("s1", "mysql", "development");
|
||||
bad.config_json = Some(r#"{"password":"new"}"#.to_string());
|
||||
let err = repo.update_full_validated(&bad).await;
|
||||
assert!(err.is_err(), "update 灌凭证应被 D10 拒绝");
|
||||
|
||||
// 原记录未变(config_json 仍是 pool_size)
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_validated_changes_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut updated = srec("s1", "redis", "production");
|
||||
updated.name = "缓存".to_string();
|
||||
let hit = repo.update_full_validated(&updated).await.unwrap();
|
||||
assert!(hit, "应命中更新");
|
||||
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert_eq!(got.name, "缓存");
|
||||
assert_eq!(got.service_type, "redis");
|
||||
assert_eq!(got.environment, "production");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_returns_all() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s2", "redis", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s3", "mq", "production"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 按项目查返回全部 3 条(含所有环境)
|
||||
let got = repo.list_by_project("proj-1").await.unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
|
||||
// 不存在的项目返回空
|
||||
let empty = repo.list_by_project("no-such").await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_env_filters_environment() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s2", "redis", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_validated(srec("s3", "mysql", "production"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 只取 development:2 条(s1/s2)
|
||||
let dev = repo
|
||||
.list_by_project_env("proj-1", "development")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dev.len(), 2);
|
||||
for s in &dev {
|
||||
assert_eq!(s.environment, "development");
|
||||
}
|
||||
|
||||
// 只取 production:1 条(s3)
|
||||
let prod = repo
|
||||
.list_by_project_env("proj-1", "production")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(prod.len(), 1);
|
||||
assert_eq!(prod[0].id, "s3");
|
||||
|
||||
// 不存在的环境返回空
|
||||
let staging = repo
|
||||
.list_by_project_env("proj-1", "staging")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(staging.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_record() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.delete("s1").await.unwrap());
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||
// 再删返回 false
|
||||
assert!(!repo.delete("s1").await.unwrap());
|
||||
}
|
||||
|
||||
/// None 字段(config_json/endpoint/remark)也能正常插入与查询。
|
||||
#[tokio::test]
|
||||
async fn insert_with_null_optional_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let rec = ProjectServiceRecord {
|
||||
id: "s1".to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
name: "bare-svc".to_string(),
|
||||
service_type: "api".to_string(),
|
||||
endpoint: None,
|
||||
config_json: None,
|
||||
environment: "development".to_string(),
|
||||
remark: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
};
|
||||
repo.insert_validated(rec).await.unwrap();
|
||||
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert!(got.endpoint.is_none());
|
||||
assert!(got.config_json.is_none());
|
||||
assert!(got.remark.is_none());
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,10 @@ impl SettingsRepo {
|
||||
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match table {
|
||||
"ideas" => &[
|
||||
"id", "title", "description", "status", "priority", "score", "tags", "source",
|
||||
"promoted_to", "ai_analysis", "scores", "related_ids", "created_at", "updated_at",
|
||||
// IDEA-FIX-02: id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
|
||||
// (对标 tasks 白名单 B-260616-16 同款防护,防篡改主键/伪造创建时间)
|
||||
"title", "description", "status", "priority", "score", "tags", "source",
|
||||
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
|
||||
],
|
||||
"projects" => &[
|
||||
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at",
|
||||
@@ -150,6 +152,13 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
|
||||
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
|
||||
"idea_id",
|
||||
// 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。
|
||||
// - queue:管理维度池,走 move_task_queue IPC(设计 §五),IPC 层校验白名单 + 一致性约束。
|
||||
// - parent_id:父子纵向关联,1 级嵌套铁律由 IPC 层(create_task)校验不进白名单。
|
||||
// - content_json:结构化需求规格,走未来 update_content IPC(设计 §五)。
|
||||
// 注:status 仍不列入(D-260616-04 收口不变),父聚合/move_task_queue 联动 status
|
||||
// 走专用方法 set_status_for_aggregation,不经通用 update_field。
|
||||
"queue", "parent_id", "content_json",
|
||||
"updated_at",
|
||||
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
|
||||
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
|
||||
@@ -190,6 +199,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
"knowledge_events" => &[
|
||||
"id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp",
|
||||
],
|
||||
// 知识图谱 Phase 3 V31(对标设计 §2.3):project_services 表有 updated_at,走 impl_repo! 宏
|
||||
// 标准 CRUD(insert/get_by_id/list_all/query/update_field/update_full/delete),故登记白名单。
|
||||
// id/created_at 不列入:主键与创建时间不可经通用 update_field 改写(对标 tasks/projects 防护)。
|
||||
// service_type 不列入白名单(类比 status 收口思路):service_type 是类型枚举,变更应走整行
|
||||
// update_full(过 service_type 应用层校验 + 凭证审查),而非 update_field 旁路绕过校验。
|
||||
"project_services" => &[
|
||||
"project_id", "name", "endpoint", "config_json", "environment",
|
||||
"remark", "updated_at",
|
||||
],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
||||
//!
|
||||
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
|
||||
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
|
||||
//!
|
||||
//! 关键设计:
|
||||
//! - `link_type` 白名单(depends_on/blocks/relates_to)应用层校验,非 DB 约束。
|
||||
//! - 循环依赖(`depends_on` 链 A→B→A)在 `create_link` BFS 检测拒绝(非 DB 约束,对标 D8)。
|
||||
//! - 跨项目依赖允许(现实中有跨项目依赖)。
|
||||
//! - 软删除语义:Task 软删不级联删 link(恢复后关系还在)。
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::TaskLinkRecord;
|
||||
|
||||
use super::{now_millis_str, storage_err};
|
||||
|
||||
// ============================================================
|
||||
// link_type 白名单 + 校验
|
||||
// ============================================================
|
||||
|
||||
/// `link_type` 白名单:只允许这三种关联类型(防拼写漂移 / 非法值进库)。
|
||||
///
|
||||
/// 对标设计 §2.2 link_type 语义:
|
||||
/// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度
|
||||
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
|
||||
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
|
||||
pub const TASK_LINK_TYPES: &[&str] = &["depends_on", "blocks", "relates_to"];
|
||||
|
||||
/// 校验 `link_type` 在白名单内,否则返回 Err。
|
||||
fn validate_link_type(link_type: &str) -> Result<()> {
|
||||
if TASK_LINK_TYPES.contains(&link_type) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(df_types::error::Error::Storage(format!(
|
||||
"非法 link_type: {link_type},合法值: {:?}",
|
||||
TASK_LINK_TYPES
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn task_link_from_row(row: &Row<'_>) -> std::result::Result<TaskLinkRecord, rusqlite::Error> {
|
||||
Ok(TaskLinkRecord {
|
||||
id: row.get("id")?,
|
||||
source_id: row.get("source_id")?,
|
||||
target_id: row.get("target_id")?,
|
||||
link_type: row.get("link_type")?,
|
||||
remark: row.get("remark")?,
|
||||
created_at: row.get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TaskLinkRepo
|
||||
// ============================================================
|
||||
|
||||
/// 任务横向关联表 Repo(task_links,V29)。
|
||||
///
|
||||
/// 不走 `impl_repo!` 宏:① 表无 `updated_at`/`deleted_at`(link 不可改只增删,审计简单);
|
||||
/// ② `create_link` 需在 INSERT 前做 BFS 循环依赖检测(宏生成的 insert 无业务前置逻辑)。
|
||||
/// 故手写专用方法,对标 SettingsRepo 全专用路径。
|
||||
pub struct TaskLinkRepo {
|
||||
conn: std::sync::Arc<tokio::sync::Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl TaskLinkRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 创建任务关联(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。
|
||||
///
|
||||
/// 对标设计 §2.2 边界约束:
|
||||
/// - `link_type` 必须在白名单(depends_on/blocks/relates_to),否则 Err。
|
||||
/// - 自环(source_id == target_id)直接拒绝(无意义的自依赖)。
|
||||
/// - 循环依赖检测:**仅对 depends_on 链**做 BFS。blocks 是 depends_on 的反向声明,
|
||||
/// relates_to 无执行约束,二者不参与环检测(否则弱关联也会触发拒绝,过度约束)。
|
||||
/// BFS 思路:新增 source→target 后,从 target 出发沿 depends_on 链向下遍历,
|
||||
/// 若能回到 source 则成环,拒绝。数据量小(单项目 ~50 任务),全表遍历无压力(对标 D8)。
|
||||
/// - 跨项目允许(不做 project_id 一致性校验,现实有跨项目依赖)。
|
||||
///
|
||||
/// 返回插入的 link id。
|
||||
pub async fn create_link(
|
||||
&self,
|
||||
id: &str,
|
||||
source_id: &str,
|
||||
target_id: &str,
|
||||
link_type: &str,
|
||||
remark: Option<&str>,
|
||||
) -> Result<String> {
|
||||
// 应用层校验先行(fail-fast,非法值不进 DB 层)
|
||||
validate_link_type(link_type)?;
|
||||
if source_id == target_id {
|
||||
return Err(df_types::error::Error::Storage(format!(
|
||||
"非法 task_link: source_id 与 target_id 相同({source_id}),自环无意义"
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let source_id_o = source_id.to_owned();
|
||||
let target_id_o = target_id.to_owned();
|
||||
let link_type_o = link_type.to_owned();
|
||||
let remark_o = remark.map(|s| s.to_owned());
|
||||
let now = now_millis_str();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// depends_on 链 BFS 环检测:新增 source→target 边后,从 target 沿 depends_on
|
||||
// 向下走,若能回到 source 即成环。在 INSERT 前检测,避免脏数据(检测与插入非原子,
|
||||
// 但单用户桌面应用无并发,够用)。
|
||||
if link_type_o == "depends_on" {
|
||||
if bfs_reaches(&guard, &target_id_o, &source_id_o)? {
|
||||
return Err(df_types::error::Error::Storage(format!(
|
||||
"非法 task_link: {} depends_on {} 会形成循环依赖",
|
||||
source_id_o, target_id_o
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO task_links (id, source_id, target_id, link_type, remark, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![id, source_id_o, target_id_o, link_type_o, remark_o, now],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 删除关联(按 id),返回是否命中。
|
||||
pub async fn delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute("DELETE FROM task_links WHERE id = ?1", params![id])
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 取单条(对标 IdeaRepo::get_by_id 模式)。
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<TaskLinkRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let row = guard
|
||||
.query_row(
|
||||
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
||||
FROM task_links WHERE id = ?1",
|
||||
params![id],
|
||||
task_link_from_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 source_id 查询(source 主动声明的全部关联),命中 idx_task_links_source。
|
||||
/// AI 编排调度用:取某任务的全部依赖 / 阻塞 / 关联。
|
||||
pub async fn get_by_source(&self, source_id: &str) -> Result<Vec<TaskLinkRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let source_id = source_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
||||
FROM task_links WHERE source_id = ?1 ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![source_id], task_link_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 target_id 查询(谁指向了 target),命中 idx_task_links_target。
|
||||
/// AI 编排调度用:「谁依赖了我」「谁被我阻塞」反向查询(JSON 列方案无法高效反查的痛点)。
|
||||
pub async fn get_by_target(&self, target_id: &str) -> Result<Vec<TaskLinkRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let target_id = target_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
||||
FROM task_links WHERE target_id = ?1 ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![target_id], task_link_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出全部关联(调试/管理用,按创建时间升序)。
|
||||
pub async fn list_all(&self) -> Result<Vec<TaskLinkRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
||||
FROM task_links ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map([], task_link_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BFS 循环依赖检测(仅 depends_on 链)
|
||||
// ============================================================
|
||||
|
||||
/// BFS:从 `start` 出发沿 depends_on 链(source→target 方向)向下遍历,判断能否到达 `target_node`。
|
||||
///
|
||||
/// 用于 `create_link(source, depends_on, target)` 前的环检测:新增 source→target 边后,
|
||||
/// 若 target 能经 depends_on 链回到 source,则形成 source→target→...→source 闭环。
|
||||
/// 故调用 `bfs_reaches(conn, start=target, target_node=source)`:从 target 出发看能否到 source。
|
||||
///
|
||||
/// 实现细节:
|
||||
/// - 遍历 `WHERE source_id = ? AND link_type = 'depends_on'` 取下一跳(对标设计 D8:数据量小,
|
||||
/// 全表遍历无压力,不走 SQL 递归 CTE,纯 Rust BFS)。
|
||||
/// - `visited` HashSet 防重复访问(菱形依赖 A→B,A→C,B→D,C→D 不应死循环)。
|
||||
/// - 防御性深度上限(10000 跳):万一数据异常成环未被拦截,BFS 不致无限循环。
|
||||
fn bfs_reaches(
|
||||
conn: &rusqlite::Connection,
|
||||
start: &str,
|
||||
target_node: &str,
|
||||
) -> Result<bool> {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut queue: VecDeque<String> = VecDeque::new();
|
||||
queue.push_back(start.to_owned());
|
||||
visited.insert(start.to_owned());
|
||||
|
||||
let mut depth = 0u32;
|
||||
const MAX_DEPTH: u32 = 10_000;
|
||||
|
||||
while let Some(node) = queue.pop_front() {
|
||||
depth += 1;
|
||||
if depth > MAX_DEPTH {
|
||||
// 防御性兜底:正常依赖链不可能这么深,到这要么数据异常要么已有环未被拦截。
|
||||
// 保守视为成环(拒绝创建),避免无限循环 + 暴露异常数据。
|
||||
tracing::warn!(
|
||||
"task_links BFS 超过 {MAX_DEPTH} 跳,疑似数据异常,保守拒绝创建"
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if node == target_node {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// 取该节点作为 source 的全部 depends_on 边的 target(下一跳)
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT target_id FROM task_links \
|
||||
WHERE source_id = ?1 AND link_type = 'depends_on'",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let nexts: Vec<String> = stmt
|
||||
.query_map(params![node], |row| row.get::<_, String>(0))
|
||||
.map_err(storage_err)?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for next in nexts {
|
||||
if visited.insert(next.clone()) {
|
||||
queue.push_back(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — TaskLinkRepo CRUD + 循环依赖拒绝(内存 DB,对标 idea_repo 测试)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::TaskRepo;
|
||||
use crate::models::TaskRecord;
|
||||
|
||||
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
|
||||
fn trec(id: &str, project_id: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: "todo".to_string(),
|
||||
priority: 1,
|
||||
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: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造内存 DB + 注入若干任务(task_links FK 要求 tasks 存在,
|
||||
/// tasks.project_id FK 要求 projects 存在 → 先建占位 project 满足 FK 约束,
|
||||
/// 对标 tests/project_soft_delete.rs 集成测试的 setup 模式)。
|
||||
async fn setup_with_tasks(ids: &[&str]) -> (crate::db::Database, TaskLinkRepo, TaskRepo) {
|
||||
let db = crate::db::Database::open_in_memory()
|
||||
.await
|
||||
.expect("open_in_memory");
|
||||
let link_repo = TaskLinkRepo::new(&db);
|
||||
let task_repo = TaskRepo::new(&db);
|
||||
// 先建占位 project 满足 tasks.project_id FK(PRAGMA foreign_keys=ON,db.rs:37/37)
|
||||
let project_repo = crate::crud::ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(crate::models::ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
task_repo.insert(trec(id, "proj-1")).await.unwrap();
|
||||
}
|
||||
(db, link_repo, task_repo)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_link_type_rejects_unknown() {
|
||||
assert!(validate_link_type("depends_on").is_ok());
|
||||
assert!(validate_link_type("blocks").is_ok());
|
||||
assert!(validate_link_type("relates_to").is_ok());
|
||||
assert!(validate_link_type("unknown").is_err());
|
||||
assert!(validate_link_type("").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_link_basic_depends_on() {
|
||||
let (_db, repo, _task_repo) = setup_with_tasks(&["a", "b"]).await;
|
||||
let id = repo
|
||||
.create_link("l1", "a", "b", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(id, "l1");
|
||||
|
||||
let got = repo.get_by_id("l1").await.unwrap().expect("link 存在");
|
||||
assert_eq!(got.source_id, "a");
|
||||
assert_eq!(got.target_id, "b");
|
||||
assert_eq!(got.link_type, "depends_on");
|
||||
assert!(got.remark.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_link_with_remark() {
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
repo.create_link("l1", "a", "b", "blocks", Some("阻塞说明"))
|
||||
.await
|
||||
.unwrap();
|
||||
let got = repo.get_by_id("l1").await.unwrap().unwrap();
|
||||
assert_eq!(got.link_type, "blocks");
|
||||
assert_eq!(got.remark.as_deref(), Some("阻塞说明"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_link_rejects_invalid_type() {
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
let err = repo
|
||||
.create_link("l1", "a", "b", "invalid_type", None)
|
||||
.await;
|
||||
assert!(err.is_err(), "非法 link_type 应被拒绝");
|
||||
// 未入库
|
||||
assert!(repo.get_by_id("l1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_link_rejects_self_loop() {
|
||||
let (_db, repo, _) = setup_with_tasks(&["a"]).await;
|
||||
let err = repo.create_link("l1", "a", "a", "depends_on", None).await;
|
||||
assert!(err.is_err(), "自环 source==target 应被拒绝");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_source_and_target() {
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await;
|
||||
repo.create_link("l1", "a", "b", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l2", "a", "c", "relates_to", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l3", "c", "b", "blocks", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// a 作为 source 声明了 2 条(l1/l2)
|
||||
let from_a = repo.get_by_source("a").await.unwrap();
|
||||
let ids: Vec<_> = from_a.iter().map(|l| l.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["l1", "l2"]);
|
||||
|
||||
// b 作为 target 被 2 条指向(l1/l3)
|
||||
let to_b = repo.get_by_target("b").await.unwrap();
|
||||
let ids: Vec<_> = to_b.iter().map(|l| l.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["l1", "l3"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_link() {
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
repo.create_link("l1", "a", "b", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.delete("l1").await.unwrap());
|
||||
assert!(repo.get_by_id("l1").await.unwrap().is_none());
|
||||
// 再删返回 false
|
||||
assert!(!repo.delete("l1").await.unwrap());
|
||||
}
|
||||
|
||||
// ---------- 循环依赖 BFS 检测(仅 depends_on 链)----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cycle_direct_a_depends_b_then_b_depends_a_rejected() {
|
||||
// A→B 合法;再 B→A 应形成 A→B→A 闭环,拒绝
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
repo.create_link("l1", "a", "b", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = repo
|
||||
.create_link("l2", "b", "a", "depends_on", None)
|
||||
.await;
|
||||
assert!(err.is_err(), "A→B→A 循环依赖应被拒绝");
|
||||
// l2 未入库
|
||||
assert!(repo.get_by_id("l2").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cycle_three_nodes_rejected() {
|
||||
// A→B→C 合法;再 C→A 形成 A→B→C→A 闭环,拒绝
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await;
|
||||
repo.create_link("l1", "a", "b", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l2", "b", "c", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = repo
|
||||
.create_link("l3", "c", "a", "depends_on", None)
|
||||
.await;
|
||||
assert!(err.is_err(), "A→B→C→A 三节点循环依赖应被拒绝");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diamond_dependency_not_cycle() {
|
||||
// 菱形依赖 A→B, A→C, B→D, C→D 是 DAG 非环,D 不应再指向 A/B/C
|
||||
// 关键:BFS 遇菱形不误判(D 被两条路径到达,visited 去重不死循环)
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c", "d"]).await;
|
||||
repo.create_link("l1", "a", "b", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l2", "a", "c", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l3", "b", "d", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l4", "c", "d", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
// 全部成功(菱形合法)
|
||||
assert_eq!(repo.list_all().await.unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocks_link_does_not_trigger_cycle_check() {
|
||||
// blocks 是 depends_on 的反向声明,不参与环检测:A blocks B + B blocks A 应都合法
|
||||
// (虽然语义重复,但环检测只管 depends_on 链,blocks/relates_to 弱约束不强拦)
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
repo.create_link("l1", "a", "b", "blocks", None)
|
||||
.await
|
||||
.unwrap();
|
||||
// B blocks A 不触发 depends_on 环检测,合法
|
||||
repo.create_link("l2", "b", "a", "blocks", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.list_all().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relates_to_link_does_not_trigger_cycle_check() {
|
||||
// relates_to 弱关联无执行约束,不参与环检测
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
repo.create_link("l1", "a", "b", "relates_to", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.create_link("l2", "b", "a", "relates_to", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.list_all().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_link_allowed() {
|
||||
// 跨项目依赖允许(设计 §2.2 边界):不做 project_id 一致性校验
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; // fixture 同 proj-1,但 repo 不校验
|
||||
let res = repo.create_link("l1", "a", "b", "depends_on", None).await;
|
||||
assert!(res.is_ok(), "跨项目依赖应允许(repo 层不校验 project 一致性)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cycle_check_mixed_chain_only_depends_on_matters() {
|
||||
// 混合链:A relates_to B(弱关联),B depends_on A 应合法(relates_to 不构成环路径)
|
||||
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
||||
repo.create_link("l1", "a", "b", "relates_to", None)
|
||||
.await
|
||||
.unwrap();
|
||||
// B depends_on A:BFS 从 A 出发沿 depends_on 找 B,A 无 depends_on 出边 → 不到 B → 合法
|
||||
repo.create_link("l2", "b", "a", "depends_on", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repo.list_all().await.unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
review_rounds: row.get("review_rounds")?,
|
||||
output_json: row.get("output_json")?,
|
||||
idea_id: row.get("idea_id")?,
|
||||
// 知识图谱 Phase 1 V29 三列(queue/parent_id/content_json),15→18 列同步之一。
|
||||
queue: row.get("queue")?,
|
||||
parent_id: row.get("parent_id")?,
|
||||
content_json: row.get("content_json")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -54,6 +58,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
///
|
||||
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
|
||||
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
|
||||
///
|
||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1)新增 queue / parent_id 两个筛选维度:
|
||||
/// - `queue`:管理池筛选(backlog/todo/decision/active/done),看板视图按池分列的数据源;
|
||||
/// - `parent_id`:父任务筛选(Some(id)=某父的子任务;特殊语义 None 仅叶子 vs 全部 由调用方拼)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TaskQuery {
|
||||
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
|
||||
@@ -66,6 +74,14 @@ pub struct TaskQuery {
|
||||
pub assignee: Option<String>,
|
||||
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
|
||||
pub keyword: Option<String>,
|
||||
/// 管理池过滤(知识图谱 Phase 1 V29):backlog/todo/decision/active/done。
|
||||
/// 看板视图按池分列的数据源。值合法性由上层 queue 语义校验兜底(非法值 DB 无匹配返回空)。
|
||||
#[serde(default)]
|
||||
pub queue: Option<String>,
|
||||
/// 父任务 ID 过滤(知识图谱 Phase 1 V29):Some(id) = 查某父任务的子任务;
|
||||
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
|
||||
pub order_by: Option<String>,
|
||||
/// 分页上限(钳制 ≤500)。P3 基建就绪。
|
||||
@@ -103,22 +119,26 @@ impl_repo!(
|
||||
from_row => |row| task_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.review_rounds, rec.output_json, rec.idea_id, rec.created_at, rec.updated_at
|
||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, updated_at = ?13 WHERE id = ?14",
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
|
||||
params![
|
||||
rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.review_rounds, rec.output_json, rec.idea_id, rec.updated_at, rec.id
|
||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -127,14 +147,14 @@ impl_repo!(
|
||||
impl TaskRepo {
|
||||
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
|
||||
///
|
||||
/// 显式列出全部 15 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
|
||||
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| task_from_row(row))
|
||||
@@ -235,7 +255,7 @@ impl TaskRepo {
|
||||
}
|
||||
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE id = ?1")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE id = ?1")
|
||||
.map_err(storage_err)?;
|
||||
let row = stmt
|
||||
.query_row(params![id], |row| task_from_row(row))
|
||||
@@ -257,7 +277,7 @@ impl TaskRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| task_from_row(row))
|
||||
@@ -284,7 +304,7 @@ impl TaskRepo {
|
||||
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
|
||||
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
|
||||
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
|
||||
/// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
|
||||
/// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
|
||||
///
|
||||
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
|
||||
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
|
||||
@@ -311,35 +331,51 @@ impl TaskRepo {
|
||||
let priority = query.priority;
|
||||
let assignee = query.assignee.clone();
|
||||
let keyword = query.keyword.clone();
|
||||
let queue = query.queue.clone();
|
||||
let parent_id = query.parent_id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)──
|
||||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = params_vec.len()+1)──
|
||||
// 占位符序号必须按「实际参数位置」(params_vec.len()+1)而非「子句数」(where_clauses.len()+1)
|
||||
// 编号:deleted_at IS NULL 是常量条件无占位符却占 where_clauses[0],用子句数编号会让首个
|
||||
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
|
||||
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
|
||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
|
||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(pid) = &project_id {
|
||||
where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(p));
|
||||
}
|
||||
if let Some(a) = &assignee {
|
||||
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(a.clone()));
|
||||
}
|
||||
// queue(知识图谱 Phase 1 V29):管理池等值过滤,看板视图按池分列数据源
|
||||
if let Some(q) = &queue {
|
||||
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(q.clone()));
|
||||
}
|
||||
// parent_id(知识图谱 Phase 1 V29):父任务等值过滤(查某父的子任务)
|
||||
if let Some(pid) = &parent_id {
|
||||
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let p1 = where_clauses.len() + 1;
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
@@ -358,10 +394,13 @@ impl TaskRepo {
|
||||
};
|
||||
|
||||
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
|
||||
// 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at:
|
||||
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
||||
let sql = format!(
|
||||
"SELECT id, project_id, title, description, status, priority, branch_name, \
|
||||
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
|
||||
created_at, updated_at FROM tasks WHERE {} ORDER BY {} DESC{}",
|
||||
queue, parent_id, content_json, created_at, updated_at \
|
||||
FROM tasks WHERE {} ORDER BY {} DESC{}",
|
||||
where_clauses.join(" AND "),
|
||||
order_col,
|
||||
limit_sql_bound
|
||||
@@ -393,6 +432,106 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
|
||||
///
|
||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
|
||||
/// 不走状态机,由子任务聚合计算。本方法取子任务列表供聚合规则消费。
|
||||
///
|
||||
/// 注:嵌套深度限制 1 级(无孙任务)由 IPC 层校验不进 DB 约束,本方法只做等值查询。
|
||||
pub async fn get_children(&self, parent_id: &str) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let pid = parent_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| task_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 父任务聚合:按 status 分组统计子任务计数(对标设计 §2.1 聚合规则)。
|
||||
///
|
||||
/// 父任务 status 由子任务聚合计算(不走状态机),聚合规则:
|
||||
/// - 全 todo → 父 todo
|
||||
/// - 任一 in_progress → 父 in_progress
|
||||
/// - 任一 blocked → 父 blocked
|
||||
/// - 全 done/cancelled → 父 done
|
||||
///
|
||||
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
|
||||
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
|
||||
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
|
||||
pub async fn count_children_by_status(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
) -> Result<Vec<(String, i64)>> {
|
||||
let conn = self.conn.clone();
|
||||
let pid = parent_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT status, COUNT(*) AS cnt FROM tasks \
|
||||
WHERE deleted_at IS NULL AND parent_id = ?1 \
|
||||
GROUP BY status",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 父任务聚合专用 status 写入(知识图谱 Phase 1 V29,对标设计 §2.1 D3 父任务=容器模型)。
|
||||
///
|
||||
/// **这是父任务 status 的唯一写入路径**,绕过 D-260616-04 status 收口(通用 update_field
|
||||
/// 白名单不含 status,所有叶子任务 status 走 advance_status_atomic 状态机)。父任务 status
|
||||
/// **不走状态机**(容器模型,由子任务聚合计算),故需专用写入路径。
|
||||
///
|
||||
/// 防护:
|
||||
/// - 方法名 `set_status_for_aggregation` 显式表明语义,非通用 setter,防误用。
|
||||
/// - 调用方(commands::task::recompute_parent_status)负责聚合规则计算,本方法只落库。
|
||||
/// - 不动 review_rounds(父任务不执行工作流,无 review 退回语义)。
|
||||
///
|
||||
/// 返回是否命中(父任务不存在/已删 → false)。
|
||||
pub async fn set_status_for_aggregation(&self, id: &str, new_status: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let new_status = new_status.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
||||
WHERE id = ?3 AND deleted_at IS NULL",
|
||||
params![new_status, now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||||
///
|
||||
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
|
||||
@@ -402,7 +541,7 @@ impl TaskRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| task_from_row(row))
|
||||
@@ -417,3 +556,254 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
|
||||
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
|
||||
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
|
||||
trec_full(id, queue, parent_id, "todo")
|
||||
}
|
||||
|
||||
/// 全参 fixture(聚合测试需自定义 status 时用)。
|
||||
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: status.to_string(),
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: queue.to_string(),
|
||||
parent_id: parent_id.map(|s| s.to_string()),
|
||||
content_json: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造内存 DB + 占位 project(满足 tasks.project_id FK,PRAGMA foreign_keys=ON)。
|
||||
async fn setup() -> TaskRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
TaskRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_queue_filter() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("t1", "backlog", None)).await.unwrap();
|
||||
repo.insert(trec("t2", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("t3", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("t4", "done", None)).await.unwrap();
|
||||
|
||||
// queue=todo → 只返回 t2/t3
|
||||
let q = TaskQuery {
|
||||
queue: Some("todo".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"t2"));
|
||||
assert!(ids.contains(&"t3"));
|
||||
|
||||
// queue=backlog → 只返回 t1
|
||||
let q = TaskQuery {
|
||||
queue: Some("backlog".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["t1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_parent_id_filter() {
|
||||
let repo = setup().await;
|
||||
// parent 父任务(叶子,parent_id=None)+ 3 子任务(parent_id=parent)
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("orphan", "todo", None)).await.unwrap();
|
||||
|
||||
// parent_id=parent → 只返回 c1/c2(父任务自身不匹配)
|
||||
let q = TaskQuery {
|
||||
parent_id: Some("parent".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"c1"));
|
||||
assert!(ids.contains(&"c2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_queue_and_parent_id_combined() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "backlog", Some("parent"))).await.unwrap();
|
||||
|
||||
// queue=todo AND parent_id=parent → 只 c1(c2 是 backlog)
|
||||
let q = TaskQuery {
|
||||
queue: Some("todo".to_string()),
|
||||
parent_id: Some("parent".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["c1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_empty_returns_all_active() {
|
||||
// 空 query(全 None)→ 等价 list_active 全量未删,向后兼容
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("t1", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("t2", "backlog", None)).await.unwrap();
|
||||
|
||||
let res = repo.list_by_query(&TaskQuery::default()).await.unwrap();
|
||||
assert_eq!(res.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_children_returns_only_direct_children() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "in_progress", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("orphan", "todo", None)).await.unwrap();
|
||||
|
||||
let children = repo.get_children("parent").await.unwrap();
|
||||
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"c1"));
|
||||
assert!(ids.contains(&"c2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_children_empty_when_no_children() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
let children = repo.get_children("parent").await.unwrap();
|
||||
assert!(children.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_children_by_status_groups() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
||||
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let counts = repo.count_children_by_status("parent").await.unwrap();
|
||||
// 转 map 便于断言(顺序由 GROUP BY 决定,不依赖)。父任务自身不计入(parent_id 非自身)。
|
||||
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
|
||||
assert_eq!(map.get("todo"), Some(&2));
|
||||
assert_eq!(map.get("in_progress"), Some(&1));
|
||||
assert_eq!(map.get("done"), Some(&1));
|
||||
assert!(!map.contains_key("cancelled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_children_by_status_empty_when_no_children() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
let counts = repo.count_children_by_status("parent").await.unwrap();
|
||||
assert!(counts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_children_excludes_soft_deleted() {
|
||||
// 软删子任务不进 get_children 结果(deleted_at IS NULL 过滤)
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
|
||||
repo.soft_delete("c2").await.unwrap();
|
||||
|
||||
let children = repo.get_children("parent").await.unwrap();
|
||||
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["c1"], "软删子任务应被过滤");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 父聚合专用 status 写入:set_status_for_aggregation(知识图谱 Phase 1 V29)
|
||||
// 父任务=容器模型,status 不走状态机,由子任务聚合计算后经此方法落库。
|
||||
// 锁定:① 写入命中 + status 变更;② 不动 review_rounds;③ 软删任务返回 false。
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_status_for_aggregation_writes_status() {
|
||||
let repo = setup().await;
|
||||
// 父任务初始 todo(queue=todo, parent_id=None 容器模型)
|
||||
repo.insert(trec_full("parent", "todo", None, "todo"))
|
||||
.await
|
||||
.unwrap();
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
|
||||
assert!(ok, "应命中写入");
|
||||
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
||||
assert_eq!(after.status, "in_progress", "status 应被聚合写入更新");
|
||||
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_status_for_aggregation_skips_soft_deleted() {
|
||||
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
|
||||
let repo = setup().await;
|
||||
repo.insert(trec_full("parent", "todo", None, "todo"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.soft_delete("parent").await.unwrap();
|
||||
let ok = repo.set_status_for_aggregation("parent", "done").await.unwrap();
|
||||
assert!(!ok, "软删任务不应被聚合写入命中");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_status_for_aggregation_nonexistent_returns_false() {
|
||||
let repo = setup().await;
|
||||
let ok = repo.set_status_for_aggregation("ghost", "done").await.unwrap();
|
||||
assert!(!ok, "不存在的任务应返回 false");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,21 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
|
||||
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
|
||||
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 26] = [
|
||||
// V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed,
|
||||
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
|
||||
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
|
||||
// V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。
|
||||
// V29 = 知识图谱 Phase 1 任务网络基础(对标 docs/02-架构设计/专项设计/
|
||||
// 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/
|
||||
// parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表
|
||||
// (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。
|
||||
// V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表,
|
||||
// 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索
|
||||
// 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。
|
||||
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
|
||||
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -56,6 +70,11 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(24, migrate_v24),
|
||||
(25, migrate_v25),
|
||||
(26, migrate_v26),
|
||||
(27, migrate_v27),
|
||||
(28, migrate_v28),
|
||||
(29, migrate_v29),
|
||||
(30, migrate_v30),
|
||||
(31, migrate_v31),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -657,6 +676,232 @@ fn migrate_v26(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V27: 审批状态统一 executed→completed(TD-260621-05)
|
||||
///
|
||||
/// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行
|
||||
/// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配;
|
||||
/// ② 前端 AuditLog statusClass/i18n auditLog.status 无 executed 键 → executed 记录显示蓝pending
|
||||
/// 标签 + raw"executed"文案矛盾(用户见"待审批 executed");③ 未来 WHERE status='completed'
|
||||
/// 统计会漏 executed 路径。统一为 completed(find_cached SW-16 透传已兼容双值,无破坏)。
|
||||
/// 数据迁移:存量 executed→completed,新库/老库均跑(UPDATE 0 行也安全)。
|
||||
fn migrate_v27(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE ai_tool_executions SET status = 'completed' WHERE status = 'executed'",
|
||||
[],
|
||||
)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [27])?;
|
||||
tracing::info!("迁移 v27 完成(审批状态 executed→completed 统一)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V28: 幂等补 ideas.deleted_at 列(灵感软删回收站,对标 tasks.deleted_at V14)
|
||||
///
|
||||
/// 删除灵感改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
|
||||
/// 与 tasks/projects.soft_delete 同模板:IdeaRecord 不带该字段,纯靠 SQL WHERE
|
||||
/// deleted_at IS NULL 过滤;关联数据(idea_evaluations 评估历史)不动,FK 仍满足,
|
||||
/// 灵感数据完整保留待恢复。用 PRAGMA 探测列存在性,缺失才 ALTER
|
||||
/// (同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17/v18/v19/v20/v23/v24 模式),对新库/老库均安全。
|
||||
fn migrate_v28(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ideas", "deleted_at") {
|
||||
conn.execute("ALTER TABLE ideas ADD COLUMN deleted_at TEXT", [])?;
|
||||
tracing::info!("v28: 补建 ideas.deleted_at 列(灵感软删回收站)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [28])?;
|
||||
tracing::info!("迁移 v28 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V29:知识图谱 Phase 1 任务网络基础 — 幂等补 tasks.queue / parent_id / content_json 三列
|
||||
///
|
||||
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.1,
|
||||
/// 为「AI 拥有完整项目知识图谱、自主分解任务编排依赖」打数据层地基。三列各司其职:
|
||||
///
|
||||
/// - `queue TEXT NOT NULL DEFAULT 'todo'`:管理维度池标记,与 status(执行维度)正交。
|
||||
/// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。
|
||||
/// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),向后兼容:历史任务原 status=todo,
|
||||
/// 落 todo 池语义一致。queue 与 status 一致性约束由 IPC 层校验(不进状态机,不进 DB 约束)。
|
||||
///
|
||||
/// - `parent_id TEXT REFERENCES tasks(id)`:父任务纵向关联(AI 分解-执行编排结构)。
|
||||
/// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务。
|
||||
/// 限制 1 级嵌套(无孙任务)由 IPC 层校验,不进 DB 约束。父任务=容器模型,status 由
|
||||
/// 子任务聚合计算(不走状态机)。TEXT NULL 向后兼容(老任务无 parent → None)。
|
||||
///
|
||||
/// - `content_json TEXT`:结构化需求规格 JSON 字符串(AI 可读写的执行规格)。
|
||||
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
|
||||
/// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。
|
||||
/// TEXT NULL 向后兼容(老任务无 content_json → None)。
|
||||
///
|
||||
/// 对标 v14(v14 tasks.deleted_at)/ v24(v24 ideas.related_ids)幂等模式:每列用 PRAGMA
|
||||
/// 探测存在性,缺失才 ALTER,对新库/老库/坏库均安全(列已存在时跳过 ALTER 不报 duplicate column)。
|
||||
fn migrate_v29(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "tasks", "queue") {
|
||||
conn.execute(
|
||||
"ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo'",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v29: 补建 tasks.queue 列(管理池,知识图谱 Phase 1)");
|
||||
}
|
||||
if !column_exists(conn, "tasks", "parent_id") {
|
||||
conn.execute(
|
||||
"ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v29: 补建 tasks.parent_id 列(父任务纵向关联,知识图谱 Phase 1)");
|
||||
}
|
||||
if !column_exists(conn, "tasks", "content_json") {
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN content_json TEXT", [])?;
|
||||
tracing::info!("v29: 补建 tasks.content_json 列(结构化需求规格,知识图谱 Phase 1)");
|
||||
}
|
||||
// task_links 表(横向关联,对标设计 §2.2):depends_on/blocks/relates_to。
|
||||
// CREATE TABLE IF NOT EXISTS 幂等:新库建、老库(V29 之前的库已跑过前半三列)已有则跳过。
|
||||
// 循环依赖检测走应用层(TaskLinkRepo::create_link BFS),非 DB 约束(对标设计 D8)。
|
||||
// 软删除语义:Task 软删不级联删 link(恢复后关系还在),故无 ON DELETE,FK 仅引用完整性。
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS task_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
target_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
link_type TEXT NOT NULL,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_links_source ON task_links(source_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_links_target ON task_links(target_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v29: 建 task_links 表 + 索引(任务横向关联,知识图谱 Phase 1)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [29])?;
|
||||
tracing::info!("迁移 v29 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V30:知识图谱 Phase 2 统一事件流 — project_events 追加型审计表
|
||||
///
|
||||
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4,
|
||||
/// 为「AI 精准检索项目事件流」打数据层地基。一张表承载跨实体(idea/task/workflow/
|
||||
/// knowledge/module/service/project)的全部事件,回答"上周做了什么 / 这个任务为何 blocked /
|
||||
/// 这个决策何时做出"——此前需跨 6 张表 JOIN。
|
||||
///
|
||||
/// **追加型审计表**(对标 knowledge_events V10 / idea_evaluations V22):只 INSERT 不 UPDATE/
|
||||
/// DELETE,历史不改可追溯。故表无 updated_at,无通用 update/delete 路径,无列白名单登记
|
||||
/// (ProjectEventRepo 走专用方法,与 TaskLinkRepo 同款全专用路径)。
|
||||
///
|
||||
/// **与 knowledge_events 的关系**(对标设计 D9 / §2.4):knowledge_events 保持不变(知识库专属,
|
||||
/// 向后兼容),project_events 是全局事件流;knowledge 相关事件**同时写入两者**——本表只管写入,
|
||||
/// 双写策略在埋点层(commands/IPC hook)实现,非本表职责。
|
||||
///
|
||||
/// 列语义(对标设计 §2.4):
|
||||
/// - `event_type`:事件类型白名单(idea_created/idea_promoted/...task_created/task_advanced/
|
||||
/// .../decision_made 等,见设计 §2.4 注释枚举)。白名单**应用层校验**(类比 task_links
|
||||
/// link_type),不进 DB 约束(保留扩展性,新增事件类型无需迁移)。
|
||||
/// - `entity_type` / `entity_id`:事件指向的实体(可空——部分事件无明确实体,如纯决策日志)。
|
||||
/// entity_type 白名单同样应用层校验(idea/project/task/workflow/knowledge/module/service)。
|
||||
/// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值,如 task_advanced;
|
||||
/// created/referenced 类为 NULL)。
|
||||
/// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。
|
||||
/// - `source`:ai / human / system(AI Working 溯源——区分是 AI 自主操作还是人操作)。
|
||||
/// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。
|
||||
///
|
||||
/// **埋点策略**(设计 §2.4 hook/after):在现有 IPC 命令(create_task/advance_task/create_project
|
||||
/// /idea_promote 等)执行后追加事件写入,不侵入业务逻辑。事件写入失败 best-effort 不阻断主操作
|
||||
/// (设计 §10.1 风险已识别),由埋点层实现,非本表职责。
|
||||
///
|
||||
/// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V30 之前的库)已有则跳过,均安全。
|
||||
/// 索引覆盖最高频查询:
|
||||
/// - `idx_project_events_project(project_id, created_at)`:按项目查事件流(时间倒序,Dashboard 时间线)
|
||||
/// - `idx_project_events_entity(entity_type, entity_id)`:按实体反查(AI「这个任务发生过什么」)
|
||||
fn migrate_v30(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
context_json TEXT,
|
||||
source TEXT,
|
||||
conversation_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_events_project \
|
||||
ON project_events(project_id, created_at)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_events_entity \
|
||||
ON project_events(entity_type, entity_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v30: 建 project_events 表 + 索引(统一事件流,知识图谱 Phase 2)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [30])?;
|
||||
tracing::info!("迁移 v30 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V31:知识图谱 Phase 3 基础设施数据层 — project_services 表
|
||||
///
|
||||
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3,
|
||||
/// 为「AI 执行任务时拥有项目基础设施上下文」打数据层地基。AI 知道"这项目用了什么数据库、
|
||||
/// Redis 在哪、有没有 MQ、API 在哪个地址",从而在编码/部署/排障时不必反复问人。
|
||||
///
|
||||
/// **定位**:项目基础设施配置的元数据层(对标设计 §2.3)。
|
||||
/// - `service_type`:基础设施类型(mysql/postgresql/sqlite/redis/mongodb/mq/api/other),
|
||||
/// **应用层校验**(类比 task_links.link_type / project_events.event_type,不进 DB 约束,
|
||||
/// 保留扩展性,新增类型无需迁移)。
|
||||
/// - `endpoint`:连接地址(localhost:3306 / URL),纯连接信息。
|
||||
/// - `config_json`:类型相关配置 JSON 字符串(如数据库连接池参数、MQ topic 列表)。
|
||||
/// - `environment`:环境标识(development/staging/production),DEFAULT 'development'。
|
||||
/// 同一服务可在不同环境各存一行(项目维度 + 环境维度组合定位)。
|
||||
///
|
||||
/// **边界(D10,对标设计 §2.3)**:⚠️ **不存敏感凭证**(密码/密钥/Token)。本表只存连接信息,
|
||||
/// 凭证走环境变量/外部密钥管理。config_json 不应含 password/secret/key 字段——此约束由
|
||||
/// 应用层(ProjectServiceRepo)在 insert/update_full 时做内容审查(检测 password/secret/key/
|
||||
/// token 子串拒绝),DB 层无 CHECK 约束(SQLite CHECK 对 JSON 内容无法表达)。
|
||||
///
|
||||
/// **元数据非运维工具**:不做连接池/健康检查/探活,仅记录"项目用了什么基础设施"这一事实,
|
||||
/// 供 AI 与 Dashboard 检索消费。运维能力是后续独立模块的职责。
|
||||
///
|
||||
/// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V31 之前的库)已有则跳过,均安全。
|
||||
/// 索引 `idx_project_services_project(project_id)` 覆盖最高频查询:按项目列其全部基础设施
|
||||
/// (Dashboard 项目视图 / AI 项目上下文注入)。
|
||||
fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_services (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
service_type TEXT NOT NULL,
|
||||
endpoint TEXT,
|
||||
config_json TEXT,
|
||||
environment TEXT NOT NULL DEFAULT 'development',
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_services_project \
|
||||
ON project_services(project_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v31: 建 project_services 表 + 索引(基础设施数据层,知识图谱 Phase 3)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [31])?;
|
||||
tracing::info!("迁移 v31 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -1261,4 +1506,85 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(v, 20);
|
||||
}
|
||||
|
||||
/// V27:审批状态统一 executed→completed(TD-260621-05)
|
||||
/// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed)
|
||||
#[test]
|
||||
fn v27_unifies_executed_to_completed() {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
|
||||
CREATE TABLE ai_tool_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
status TEXT NOT NULL,
|
||||
requested_at TEXT,
|
||||
executed_at TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create ai_tool_executions");
|
||||
|
||||
// 混合状态:2 executed(待转)+ completed/rejected/failed(应不变)
|
||||
conn.execute_batch(
|
||||
"INSERT INTO ai_tool_executions (id, tool_call_id, tool_name, status) VALUES
|
||||
('e1', 'tc1', 'write_file', 'executed'),
|
||||
('e2', 'tc2', 'read_file', 'executed'),
|
||||
('c1', 'tc3', 'list_directory', 'completed'),
|
||||
('r1', 'tc4', 'write_file', 'rejected'),
|
||||
('f1', 'tc5', 'run_command', 'failed');",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_v27(&conn).expect("v27 应成功统一状态");
|
||||
|
||||
// executed 全部转 completed
|
||||
let executed_left: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'executed'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(executed_left, 0, "executed 应全部转为 completed");
|
||||
|
||||
let completed: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'completed'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(completed, 3, "原 2 executed + 1 completed = 3 completed");
|
||||
|
||||
// 其他状态不受影响
|
||||
let rejected: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'rejected'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(rejected, 1, "rejected 不变");
|
||||
let failed: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'failed'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(failed, 1, "failed 不变");
|
||||
|
||||
let v: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 27, "应写入版本号 27");
|
||||
}
|
||||
|
||||
/// V27 幂等:无 executed 时再跑不崩(UPDATE 0 行),版本号照写
|
||||
#[test]
|
||||
fn v27_idempotent_no_executed() {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
|
||||
CREATE TABLE ai_tool_executions (id TEXT PRIMARY KEY, status TEXT NOT NULL);",
|
||||
)
|
||||
.expect("create tables");
|
||||
conn.execute(
|
||||
"INSERT INTO ai_tool_executions (id, status) VALUES ('c1', 'completed')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_v27(&conn).expect("v27 无 executed 时应幂等成功(UPDATE 0 行)");
|
||||
|
||||
let v: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 27);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,118 @@ pub struct TaskRecord {
|
||||
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
|
||||
#[serde(default)]
|
||||
pub idea_id: Option<String>,
|
||||
/// 管理维度池标记(知识图谱 Phase 1,V29 列)。与 status(执行维度)正交。
|
||||
/// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。
|
||||
/// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),TaskRecord 字段为 String(非 Option)。
|
||||
/// #[serde(default = "default_queue")] 兼容旧前端 JSON(无该字段时为 "todo")。
|
||||
#[serde(default = "default_queue")]
|
||||
pub queue: String,
|
||||
/// 父任务 ID(知识图谱 Phase 1,V29 列,父任务纵向关联)。
|
||||
/// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务(限制 1 级嵌套,无孙任务,
|
||||
/// 由 IPC 层校验不进 DB 约束)。父任务=容器模型,status 由子任务聚合计算(不走状态机)。
|
||||
/// TEXT NULL 向后兼容,TaskRecord 字段为 Option<String>(老任务无 parent → None)。
|
||||
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1,V29 列)。
|
||||
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
|
||||
/// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。
|
||||
/// TEXT NULL 向后兼容,TaskRecord 字段为 Option<String>。
|
||||
/// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_json: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 任务横向关联记录(task_links 表,知识图谱 Phase 1 V29,对标设计 §2.2)。
|
||||
///
|
||||
/// 表达任务间依赖/阻塞/弱关联关系,AI 拓扑排序编排调度的基础。独立表方案替代 JSON 列
|
||||
/// (Idea related_ids 的缺陷:无类型区分/单向/无法高效反向查询)。
|
||||
///
|
||||
/// - `source_id` / `target_id`:关联两端任务 id(FK tasks.id)。软删除语义:Task 软删不
|
||||
/// 级联删 link(对标设计 §2.2 边界「软删除保留」——恢复后关系还在),故无 ON DELETE。
|
||||
/// - `link_type`:关联类型白名单(应用层校验,非 DB 约束):
|
||||
/// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度
|
||||
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
|
||||
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
|
||||
/// - `remark`:可选备注。
|
||||
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
|
||||
/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskLinkRecord {
|
||||
pub id: String,
|
||||
pub source_id: String,
|
||||
pub target_id: String,
|
||||
pub link_type: String,
|
||||
pub remark: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 项目事件流记录(project_events 表,知识图谱 Phase 2 V30,追加型审计表)。
|
||||
///
|
||||
/// 一张表承载跨实体(idea/task/workflow/knowledge/module/service/project)的全部事件,
|
||||
/// 是 AI 精准检索项目事件流的基础(回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出")。
|
||||
/// 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4。
|
||||
///
|
||||
/// **追加型审计**(对标 KnowledgeEventRecord / IdeaEvaluationRecord):只 INSERT 不 UPDATE/
|
||||
/// DELETE,历史不改可追溯。无 updated_at 字段,无通用 update/delete 路径。
|
||||
///
|
||||
/// - `project_id`:所属项目(FK projects.id)。
|
||||
/// - `event_type`:事件类型白名单(idea_created/task_advanced/decision_made 等,应用层校验)。
|
||||
/// - `entity_type` / `entity_id`:事件指向的实体(可空——纯决策日志等无明确实体)。entity_type
|
||||
/// 白名单应用层校验(idea/project/task/workflow/knowledge/module/service)。
|
||||
/// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值;created/referenced 类为 None)。
|
||||
/// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。
|
||||
/// - `source`:ai / human / system(AI Working 溯源——区分 AI 自主操作还是人操作)。
|
||||
/// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。
|
||||
///
|
||||
/// **与 knowledge_events 关系**(设计 D9):knowledge_events 保持不变(知识库专属),knowledge
|
||||
/// 相关事件同时写入两者,双写在埋点层(commands/IPC hook)实现。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectEventRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub event_type: String,
|
||||
pub entity_type: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub from_state: Option<String>,
|
||||
pub to_state: Option<String>,
|
||||
pub context_json: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub conversation_id: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 项目基础设施配置记录(project_services 表,知识图谱 Phase 3 V31)。
|
||||
///
|
||||
/// 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
|
||||
/// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计 docs/02-架构设计/专项设计/
|
||||
/// 项目知识图谱与任务队列系统-2026-06-26.md §2.3。
|
||||
///
|
||||
/// - `project_id`:所属项目(FK projects.id)。
|
||||
/// - `name`:服务名(如"主数据库"/"缓存"/"消息队列",人/AI 可读标识)。
|
||||
/// - `service_type`:基础设施类型白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,
|
||||
/// 应用层校验,不进 DB 约束)。
|
||||
/// - `endpoint`:连接地址(localhost:3306 / URL),可空(部分服务无固定 endpoint,如 serverless API)。
|
||||
/// - `config_json`:类型相关配置 JSON 字符串(如连接池参数/MQ topic 列表),可空。
|
||||
/// - `environment`:环境标识(development/staging/production),同一服务可在不同环境各存一行。
|
||||
/// - `remark`:备注说明,可空。
|
||||
///
|
||||
/// ⚠️ **不存敏感凭证(D10)**:本记录仅存连接信息,密码/密钥/Token 走环境变量/外部密钥管理,
|
||||
/// 不进 config_json。内容审查在 ProjectServiceRepo insert/update_full 时检测敏感字段拒绝。
|
||||
///
|
||||
/// **元数据非运维工具**:不做连接池/健康检查,仅记录"项目用了什么基础设施"这一事实。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectServiceRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub service_type: String,
|
||||
pub endpoint: Option<String>,
|
||||
pub config_json: Option<String>,
|
||||
pub environment: String,
|
||||
pub remark: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -205,6 +317,12 @@ fn default_weight() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
/// TaskRecord.queue 的 serde 默认("todo",知识图谱 Phase 1 V29)。老库/缺字段 JSON → "todo"。
|
||||
/// 对齐 DB 列 DEFAULT 'todo' 语义,保证字段始终非空 String。
|
||||
fn default_queue() -> String {
|
||||
"todo".to_string()
|
||||
}
|
||||
|
||||
/// AI 对话记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiConversationRecord {
|
||||
|
||||
@@ -44,6 +44,9 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
# AI 原生工作模式:上下文地图与去 AI 化进化系统
|
||||
|
||||
> **文档状态**:构想 → 架构设计
|
||||
> **创建日期**:2026-06-26
|
||||
> **关联灵感**:Idea `84797cb1-37f2-4599-ba5f-ae8d8555dc0f`
|
||||
> **核心理念**:让 AI 做得越来越少——高频路径逐步结晶为确定性逻辑,AI 退守到真正需要推理的边界。
|
||||
|
||||
---
|
||||
|
||||
## 0. 问题诊断:当前 AI 工作流的资源浪费
|
||||
|
||||
### 0.1 现状痛点
|
||||
|
||||
| 症状 | 根因 | 代价 |
|
||||
|------|------|------|
|
||||
| 每次对话都从零检索项目结构 | 无持久化的工程地图 | 30-40% token 浪费在重复探索 |
|
||||
| 简单文件操作也要 LLM 推理 | 无模式结晶机制 | 推理延迟 + API 成本 |
|
||||
| AI 不知道上次改了什么 | 无上下文缓存层 | 重复扫描 + 上下文断裂 |
|
||||
| 高频任务路径无法优化 | 无执行频度感知 | 永远全量推理,无法降级 |
|
||||
|
||||
### 0.2 核心洞察
|
||||
|
||||
> AI 原生不等于"什么都让 AI 做"。真正的 AI 原生是:**AI 构建认知基础设施,然后将高频路径交给确定性逻辑,自身退守到创造性边界。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构总览:三层地图 + 缓存层 + 结晶器
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI Agentic Loop │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ ┌────────────┐ │
|
||||
│ │ Working │ │ Coding │ │ Tool │ │ Context │ │
|
||||
│ │ Map │──▶│ Map │──▶│ Map │──▶│ Cache │ │
|
||||
│ │ (任务态) │ │ (代码态) │ │ (工具态) │ │ (会话态) │ │
|
||||
│ └──────────┘ └──────────────┘ └───────────┘ └─────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼───────┐ │
|
||||
│ │ Pattern │ │
|
||||
│ │ Crystallizer │ │
|
||||
│ │ (模式结晶器) │ │
|
||||
│ └──────────┬───────┘ │
|
||||
└─────────────────────────────────────────────────┼─────────┼────────┘
|
||||
│ │
|
||||
┌─────────────▼─┐ ┌───▼──────────┐
|
||||
│ Deterministic │ │ Frequency │
|
||||
│ Scripts │ │ Tracker │
|
||||
│ (确定性脚本) │ │ (频度追踪) │
|
||||
└───────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 1.1 设计原则
|
||||
|
||||
1. **地图先行**:AI 的每次操作都基于预构建的地图,而非实时盲搜
|
||||
2. **缓存就近**:会话级上下文缓存,避免跨轮重复推理
|
||||
3. **频度驱动结晶**:高频路径自动进入结晶候选队列
|
||||
4. **渐进去 AI 化**:结晶后的路径从 LLM 推理降级为确定性脚本
|
||||
|
||||
---
|
||||
|
||||
## 2. 三层地图设计
|
||||
|
||||
### 2.1 Working Map(工作地图 — 任务态)
|
||||
|
||||
**职责**:描述"当前在做什么"——任务、项目、灵感、工作流的实时状态拓扑。
|
||||
|
||||
```
|
||||
WorkingMap
|
||||
├── active_projects[] // 活跃项目清单(含 stack/path/bind 状态)
|
||||
├── active_tasks[] // 进行中任务(含 status/assignee/blocker)
|
||||
├── task_dependencies[] // 任务间依赖(子任务/阻塞关系)
|
||||
├── recent_decisions[] // 近期决策记录(灵感→任务转化轨迹)
|
||||
├── workflow_state // 当前工作流执行状态
|
||||
└── project_modules[] // 多工程结构(复用 modules 表)
|
||||
```
|
||||
|
||||
**DevFlow 现状对接**:
|
||||
- `projects` 表 → `active_projects`
|
||||
- `tasks` 表 → `active_tasks`
|
||||
- `ideas` 表 → `recent_decisions`(灵感→任务转化链)
|
||||
- `modules` + `module_dependencies` 表 → `project_modules`(多工程功能,子1-5 任务在建)
|
||||
|
||||
**构建时机**:
|
||||
- 冷启动:从 SQLite 全量加载活跃实体
|
||||
- 热更新:CRUD 操作触发增量更新(事件驱动,复用全局事件总线)
|
||||
|
||||
### 2.2 Coding Map(代码地图 — 结构态)
|
||||
|
||||
**职责**:描述"代码长什么样"——文件树、符号索引、依赖图、技术栈。
|
||||
|
||||
```
|
||||
CodingMap
|
||||
├── module_index[] // 每个工程的文件树 + 技术栈
|
||||
│ ├── file_tree // 目录结构(懒加载,按需展开)
|
||||
│ ├── symbol_index // AST 符号索引(函数/结构体/类)
|
||||
│ └── stack_signature // 技术栈指纹(Cargo.toml/package.json/...)
|
||||
├── cross_module_deps[] // 跨工程依赖(API 调用/类库引入/消息通信)
|
||||
├── recent_changes[] // 近期文件变更(git diff + 时间窗口)
|
||||
└── hotspots[] // 热点文件(高频访问/修改的文件)
|
||||
```
|
||||
|
||||
**DevFlow 现状对接**:
|
||||
- `read_symbol` 工具 → `symbol_index` 的查询入口
|
||||
- `detect_stack` 逻辑 → `stack_signature`
|
||||
- `modules` + `module_dependencies` → `cross_module_deps`
|
||||
- 知识库 Tier1 → `CodingMap` 的知识层补充
|
||||
|
||||
**构建策略**:
|
||||
- 符号索引:按需解析(首次访问某文件时触发 AST 解析,结果缓存)
|
||||
- 依赖图:module 绑定目录时构建,变更时增量更新
|
||||
- 热点追踪:记录工具调用中的文件访问频度
|
||||
|
||||
### 2.3 Tool Map(工具地图 — 能力态)
|
||||
|
||||
**职责**:描述"AI 能做什么"——工具注册表、工具调用历史、工具审批策略。
|
||||
|
||||
```
|
||||
ToolMap
|
||||
├── registered_tools[] // 已注册工具清单(名称/参数schema/风险等级)
|
||||
├── tool_call_history[] // 近期调用历史(参数/结果/耗时/是否审批)
|
||||
├── approval_policies[] // 审批策略(自动批准/人工审批/拒绝)
|
||||
├── tool_success_rate // 各工具成功率统计
|
||||
└── parameter_patterns[] // 常见参数模式(用于结晶候选识别)
|
||||
```
|
||||
|
||||
**DevFlow 现状对接**:
|
||||
- 工具注册表(68 IPC commands)→ `registered_tools`
|
||||
- 工具审批机制(Agentic Loop + 审批)→ `approval_policies`
|
||||
- `tool_call_history` → 新增,记录到 SQLite 或内存环形缓冲
|
||||
|
||||
---
|
||||
|
||||
## 3. Context Cache(上下文缓存层)
|
||||
|
||||
**职责**:在 Agentic Loop 的多轮对话中,缓存已检索/已推理的上下文,避免重复劳动。
|
||||
|
||||
### 3.1 缓存结构
|
||||
|
||||
```rust
|
||||
pub struct ContextCache {
|
||||
/// 会话级:当前对话已探索的文件/符号
|
||||
explored_files: HashMap<PathBuf, FileSnapshot>,
|
||||
|
||||
/// 任务级:当前任务关联的上下文窗口
|
||||
task_context: HashMap<TaskId, TaskContext>,
|
||||
|
||||
/// 推理缓存:相同输入的推理结果(短 TTL)
|
||||
inference_cache: LruCache<InferenceKey, InferenceResult>,
|
||||
|
||||
/// 工具结果缓存:相同参数的工具调用结果
|
||||
tool_result_cache: LruCache<ToolCallSignature, ToolResult>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 缓存失效策略
|
||||
|
||||
| 缓存类型 | TTL | 失效条件 |
|
||||
|----------|-----|----------|
|
||||
| `explored_files` | 会话级 | 文件被修改(mtime 变化) |
|
||||
| `task_context` | 任务级 | 任务状态变更 |
|
||||
| `inference_cache` | 5 分钟 | 时间过期或上下文窗口滑动 |
|
||||
| `tool_result_cache` | 10 分钟 | 时间过期或目标文件变更 |
|
||||
|
||||
### 3.3 预期收益
|
||||
|
||||
- 减少 **50-60%** 的重复推理量
|
||||
- 工具调用延迟降低(缓存命中直接返回)
|
||||
- 对话连续性增强(AI"记得"上次探索的结果)
|
||||
|
||||
---
|
||||
|
||||
## 4. Pattern Crystallizer(模式结晶器)
|
||||
|
||||
**职责**:识别高频任务路径,将其从 LLM 推理降级为确定性脚本/规则。
|
||||
|
||||
### 4.1 结晶流水线
|
||||
|
||||
```
|
||||
Frequency Tracker Pattern Analyzer Crystallizer
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 记录每次工具 │──▶ N 次 ─▶│ 分析参数模式 │──▶ 稳定 ─▶│ 生成确定性 │──▶ 注册为
|
||||
│ 调用的签名 │ 以上 │ + 成功率 │ 模式 │ 脚本/规则 │ 新工具
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 4.2 数据结构
|
||||
|
||||
```rust
|
||||
pub struct FrequencyTracker {
|
||||
/// 工具调用签名 → 出现次数
|
||||
call_signatures: HashMap<ToolCallSignature, u32>,
|
||||
|
||||
/// 参数模式聚类(相同工具 + 相似参数归为一类)
|
||||
parameter_clusters: HashMap<ToolName, Vec<ParameterPattern>>,
|
||||
}
|
||||
|
||||
pub struct CrystallizedPattern {
|
||||
id: String,
|
||||
/// 触发条件(何时使用此结晶路径)
|
||||
trigger: PatternTrigger,
|
||||
/// 确定性执行逻辑(脚本/规则/模板)
|
||||
execution: DeterministicExecution,
|
||||
/// 置信度(基于历史成功率)
|
||||
confidence: f32,
|
||||
/// 回退策略(结晶路径失败时回退到 LLM 推理)
|
||||
fallback: FallbackStrategy,
|
||||
}
|
||||
|
||||
pub enum DeterministicExecution {
|
||||
/// 预定义脚本(Shell/Python)
|
||||
Script(PathBuf),
|
||||
/// 参数化模板(填充变量后直接执行)
|
||||
Template(Template),
|
||||
/// 规则引擎(条件→动作映射)
|
||||
Rule(RuleSet),
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 结晶示例
|
||||
|
||||
| 高频模式 | 结晶前(LLM 推理) | 结晶后(确定性逻辑) |
|
||||
|----------|-------------------|---------------------|
|
||||
| 创建新 Rust crate | LLM 推理目录结构 + Cargo.toml | 脚本:`cargo new --lib` + 模板填充 |
|
||||
| 添加 IPC command | LLM 生成 handler + 注册代码 | 模板:参数化生成 + 自动注册 |
|
||||
| 运行测试套件 | LLM 推理测试命令 | 直接执行 `cargo test` |
|
||||
| 代码审查 | LLM 全量分析 | 规则引擎:lint 规则 + diff 分析 |
|
||||
|
||||
### 4.4 回退机制
|
||||
|
||||
结晶路径不是"永久替代"——当确定性执行失败或结果异常时,自动回退到 LLM 推理:
|
||||
|
||||
```
|
||||
执行结晶路径
|
||||
├── 成功 → 直接返回结果(跳过 LLM)
|
||||
├── 失败 → 记录失败 + 回退到 LLM 推理
|
||||
└── 连续失败 N 次 → 标记结晶路径为"待审查",暂停使用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 演进路线图
|
||||
|
||||
### Phase 1:地图构建(预计减少 30-40% 无效检索)
|
||||
|
||||
**目标**:三张地图的基础设施就位,AI 操作基于地图而非盲搜。
|
||||
|
||||
| 工作项 | 依托 | 状态 |
|
||||
|--------|------|------|
|
||||
| Working Map 数据层 | projects/tasks/ideas/modules 表 | ✅ 已有 |
|
||||
| Coding Map 符号索引 | read_symbol 工具 | ✅ 已有 |
|
||||
| Coding Map 跨工程依赖 | module_dependencies 表 | 🔨 子1-5 在建 |
|
||||
| Tool Map 注册表 | 68 IPC commands | ✅ 已有 |
|
||||
| Tool Map 调用历史 | 新增 tool_call_log 表 | ❌ 待建 |
|
||||
| 地图查询 API | 新增 IPC commands | ❌ 待建 |
|
||||
|
||||
### Phase 2:上下文缓存(预计减少 50-60% 推理量)
|
||||
|
||||
**目标**:Agentic Loop 内置缓存层,跨轮复用已检索上下文。
|
||||
|
||||
| 工作项 | 说明 |
|
||||
|--------|------|
|
||||
| ContextCache Rust 结构 | 内存级缓存 + LRU 淘汰 |
|
||||
| 文件快照机制 | mtime 监控 + 增量失效 |
|
||||
| 工具结果缓存 | 签名匹配 + TTL 失效 |
|
||||
| 缓存命中率指标 | 可观测性面板 |
|
||||
|
||||
### Phase 3:模式结晶器(高频任务去 AI 化)
|
||||
|
||||
**目标**:高频路径自动结晶为确定性逻辑,LLM 退守创造性边界。
|
||||
|
||||
| 工作项 | 说明 |
|
||||
|--------|------|
|
||||
| FrequencyTracker | 工具调用签名记录 + 聚类 |
|
||||
| PatternAnalyzer | 参数模式分析 + 稳定性评估 |
|
||||
| Crystallizer 引擎 | 确定性脚本/模板/规则生成 |
|
||||
| 回退机制 | 结晶失败自动回退 LLM |
|
||||
| 结晶管理 UI | 查看/审批/禁用结晶路径 |
|
||||
|
||||
### Phase 4:自治进化(长期愿景)
|
||||
|
||||
**目标**:系统自主识别优化机会,提出并验证新的结晶路径。
|
||||
|
||||
| 工作项 | 说明 |
|
||||
|--------|------|
|
||||
| 异常检测 | 识别低效路径(高 token / 低成功率) |
|
||||
| 结晶提案 | 自动生成结晶候选方案 |
|
||||
| A/B 验证 | 结晶路径 vs LLM 路径对比 |
|
||||
| 自治注册 | 验证通过后自动注册新工具 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 DevFlow 现有架构的融合点
|
||||
|
||||
```
|
||||
DevFlow 现有架构 本方案融合点
|
||||
┌────────────────────┐
|
||||
│ Vue 3 前端 │ ← 结晶管理 UI / 缓存命中率面板 / 地图可视化
|
||||
├────────────────────┤
|
||||
│ Tauri IPC 层 │ ← 地图查询 API / 缓存管理 IPC / 结晶审批 IPC
|
||||
├────────────────────┤
|
||||
│ Rust 后端 │
|
||||
│ ├─ df-storage │ ← tool_call_log 表 / crystallized_patterns 表
|
||||
│ ├─ df-project │ ← Working Map + Coding Map 数据源
|
||||
│ ├─ df-workflow │ ← 结晶路径作为工作流节点
|
||||
│ ├─ df-agent │ ← ContextCache 注入 Agentic Loop
|
||||
│ └─ df-tools │ ← Tool Map + FrequencyTracker + Crystallizer
|
||||
├────────────────────┤
|
||||
│ SQLite │ ← 新增迁移 V17+(patterns/tool_call_log)
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
### 6.1 新增 Crate 规划
|
||||
|
||||
| Crate | 职责 | 依赖 |
|
||||
|-------|------|------|
|
||||
| `df-context` | ContextCache + 三张地图的内存结构 | df-storage, df-project |
|
||||
| `df-crystallize` | FrequencyTracker + PatternAnalyzer + Crystallizer | df-tools, df-context |
|
||||
|
||||
### 6.2 新增数据表(V17+ 迁移)
|
||||
|
||||
```sql
|
||||
-- 工具调用历史
|
||||
CREATE TABLE tool_call_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT,
|
||||
tool_name TEXT NOT NULL,
|
||||
parameters TEXT, -- JSON
|
||||
result_summary TEXT, -- 截断的结果摘要
|
||||
success INTEGER NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
approved INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 结晶模式
|
||||
CREATE TABLE crystallized_patterns (
|
||||
id TEXT PRIMARY KEY,
|
||||
trigger_condition TEXT NOT NULL, -- JSON: 何时触发
|
||||
execution_type TEXT NOT NULL, -- script / template / rule
|
||||
execution_body TEXT NOT NULL, -- 脚本路径 / 模板内容 / 规则集
|
||||
confidence REAL NOT NULL,
|
||||
status TEXT NOT NULL, -- active / suspended / reviewing
|
||||
success_count INTEGER DEFAULT 0,
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键设计决策
|
||||
|
||||
### 7.1 为什么是"三层地图"而非"一张大地图"?
|
||||
|
||||
- **职责分离**:任务态(Working)、结构态(Coding)、能力态(Tool)的生命周期和更新频率不同
|
||||
- **按需加载**:AI 当前操作只需要相关地图层,避免全量加载
|
||||
- **独立演进**:每层地图可以独立优化数据结构和索引策略
|
||||
|
||||
### 7.2 为什么结晶器需要回退机制?
|
||||
|
||||
- 结晶路径基于历史模式,无法覆盖所有边界情况
|
||||
- 回退到 LLM 推理是安全网,确保系统鲁棒性
|
||||
- 连续失败自动暂停,避免反复走死路
|
||||
|
||||
### 7.3 为什么缓存层放在 Rust 后端而非前端?
|
||||
|
||||
- Rust 后端是 Agentic Loop 的执行体,缓存就近放置延迟最低
|
||||
- 避免前后端数据同步的复杂性
|
||||
- Rust 的内存管理适合 LRU 缓存场景
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 地图数据过期 | AI 基于过期信息做决策 | mtime 监控 + 事件驱动增量更新 |
|
||||
| 结晶路径覆盖不全 | 部分场景回退频繁 | 回退机制 + 逐步扩大结晶覆盖 |
|
||||
| 缓存内存膨胀 | 内存占用过高 | LRU 淘汰 + TTL 过期 + 容量上限 |
|
||||
| 结晶脚本安全风险 | 恶意/错误脚本执行 | 审批机制 + 沙箱执行 + 权限限制 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 总结
|
||||
|
||||
本方案的核心贡献是将 AI 工作流从"每次全量推理"进化为"地图导航 + 缓存加速 + 高频结晶"的三级加速体系:
|
||||
|
||||
1. **地图层**解决"去哪找"——减少 30-40% 无效检索
|
||||
2. **缓存层**解决"别重复"——减少 50-60% 推理量
|
||||
3. **结晶器**解决"别想了,直接做"——高频路径去 AI 化
|
||||
|
||||
最终愿景:**AI 构建认知基础设施,然后将确定性路径交给脚本/规则,自身专注于真正需要创造力的边界问题。这才是 AI 原生的终局——让 AI 做得越来越少,但每一步都精准。**
|
||||
|
||||
---
|
||||
|
||||
> **下一步行动**:
|
||||
> 1. 将 Phase 1 中待建项(tool_call_log 表、地图查询 API)拆解为具体任务
|
||||
> 2. 在 `df-context` crate 中实现 ContextCache 原型
|
||||
> 3. 设计 FrequencyTracker 的数据采集埋点方案
|
||||
@@ -0,0 +1,108 @@
|
||||
# AI 对话「目标丢失 / 意图丢失」诊断报告
|
||||
|
||||
> 日期:2026-06-26
|
||||
> 触发:AI Chat 使用中,用户发送 Bug / 分析需求目标后,分析过程中目标丢失,系统不知道做什么、完全跑偏。
|
||||
> 方法:直读对话数据库(`AppData/Roaming/top.1216.devflow/devflow-dev.db`)实测会话 + 源码机制走查。
|
||||
> 性质:基于代码 + 数据库实证的机制级诊断(非模型分析猜测)。
|
||||
|
||||
---
|
||||
|
||||
## 一、现场铁证(会话 `54c8b5bd`)
|
||||
|
||||
| 指标 | 实测值 | 含义 |
|
||||
|---|---|---|
|
||||
| `prompt_tokens` | **4,809,714**(约 480 万) | 远超 GLM 1M 窗口,反复触发压缩 / 裁剪 |
|
||||
| 消息数 | 308 条 | 长 ReAct 链,工具结果堆积 |
|
||||
| 原始目标 `[seq5]` | `分析一下 灵感模块 存在的不足 [项目: DevFlow]`,`status=compressed` | **目标消息已被踢出 LLM 上下文**(`sanitize_messages` step0 过滤 `is_active`) |
|
||||
| 话题标记 `[seq0]` | `task → project`(active) | 系统判定「当前 project 话题」,但真实目标是 idea 域(灵感模块) |
|
||||
| 尾部 `[seq287–307]` | 连续 10+ 次 `search_files`,关键词 `auto-execute-mode / auto_execute / 全部接管 / autoExecute / execute-mode / execute_mode / auto-exec / 接管 / df-ai-auto`,**全部 `total:0` 空结果** | 失去目标后的游荡死循环 |
|
||||
|
||||
**结论**:原始目标是「分析灵感模块存在的不足」,LLM 最终在反复搜索「自动执行模式 / 全部接管」——与目标毫无关系。目标彻底丢失。
|
||||
|
||||
---
|
||||
|
||||
## 二、根因(机制级)
|
||||
|
||||
### 根因 1:上下文爆炸 → 目标消息物理出局(核心)
|
||||
|
||||
- `df-ai/src/context.rs`:`sanitize_messages` step0 按 `is_active()` 过滤发送视图。目标 user 消息被 F-15 压缩标 `status=compressed` 后,**不再进入 LLM 上下文**。
|
||||
- 目标仅靠**压缩摘要 system 消息**「续命」。`prompt.rs::compress_prompt` 四段式(意图 / 决策 / 文件 / 约束)+ 主题词锚点保留——但摘要是 **LLM 二次生成,信息降级严重**。「分析灵感模块不足」这类具体目标,在数次压缩后语义稀释到无法指导后续动作。
|
||||
- 480 万 token 的会话,目标消息经历多轮压缩,保真度无法保证。
|
||||
|
||||
### 根因 2:话题切换标记误判,反向强化错误方向
|
||||
|
||||
- `agentic/mod.rs:735-772`:`pending_topic_marker` 基于末两条 user 消息的 `IntentRecognizer` 关键词推断(双高置信 ≥0.7 才标),`insert_at(0)` 软提示 system「请以新话题为准」。
|
||||
- 失效点:
|
||||
- 长链里 user 消息早被压缩出局,topic 推断基于**已失真历史**。
|
||||
- 本例最后 active 标记是 `task→project`,与真实目标(灵感模块 / idea 域)**背离**——软提示反而告诉 LLM「现在是 project 话题」,帮倒忙。
|
||||
- marker 只在 push **user 消息**时检测,工具轮次间不参与。
|
||||
|
||||
### 根因 3:单链 ReAct,无目标重锚定(架构本质)
|
||||
|
||||
- `df-ai/src/coordinator.rs`:**纯空壳 TODO**(B 路线占位,`AgentCoordinator::run` 直接返 `"TODO: Agent 协作结果"`)。
|
||||
- `df-ai/src/planner.rs`:Plan DAG + Kahn 分层 + validate 齐全,但文件头明确「纯函数零 IO 零状态」「不接入 agentic loop,待 Phase B+C」。`PLANNING_ENABLED=true` 只门控**工具排序**(`filter_tool_defs_planned`),**不做多步规划执行**。
|
||||
- agentic loop 每轮只在 push 新 user 消息时检测话题切换,**工具轮次间不重申目标**。没有「当前目标 + 进度」结构化状态,LLM 每轮自行决定下一步,长链必然游荡(`[seq287–307]` 实证)。
|
||||
|
||||
### 根因 4:无探索熔断
|
||||
|
||||
- `[seq287–307]` 连续 10+ 次空结果不中断。系统不跟踪「已搜过什么 / 连续 N 次无进展」,LLM 无限换关键词。
|
||||
- 这是目标丢失的**放大器**:不知道目标 → 不知道该停 → 无限游荡,token 失控(480 万)。
|
||||
|
||||
### 根因 5:system prompt「聚焦准则」是文字说教,非机制
|
||||
|
||||
- `prompt.rs:104-108` 有「聚焦准则:始终围绕用户当前请求的核心目标」——纯 prompt 文字,靠 LLM 自觉。
|
||||
- 目标一旦从上下文(消息流)消失,文字准则**无锚点可抓**。没有「每轮把目标钉在 system 顶部」的机制。
|
||||
|
||||
---
|
||||
|
||||
## 三、为什么现有设计压不住
|
||||
|
||||
| 现有机制 | 性质 | 为什么压不住 |
|
||||
|---|---|---|
|
||||
| 压缩摘要(F-15) | **被动**信息降级 | 摘要保真靠 LLM,多轮压缩后目标语义稀释 |
|
||||
| 话题切换标记 | 软提示 + 关键词推断 | 基于失真历史,本例反向误导 |
|
||||
| 聚焦准则 prompt | 文字说教 | 目标消息出局后无锚点 |
|
||||
| `insert_at(0)` system 锚点 | 多个堆首位 | 话题标记 + 压缩摘要 + 关键词兜底三个锚点互相稀释 |
|
||||
|
||||
三个 `insert_at(0)`(话题标记 `agentic/mod.rs:762`、压缩摘要 `:918`、关键词兜底 `:969`)堆在首位,互相稀释,没有一个是「权威目标」。
|
||||
|
||||
---
|
||||
|
||||
## 四、关联现状
|
||||
|
||||
- 当前分支 `conv-state-convergence` + `agentic/conv_state.rs`(452 行状态机)已在尝试收敛对话状态,**但目标作为一等状态尚未固化进状态机**——这是未完成的工作。
|
||||
- memory `devflow-aichat-session-analysis-2026-06-22` 已标的「最高杠杆 P0-2 编排规划能力弱」即此问题的上层概括。本报告是其**数据库实证版**。
|
||||
|
||||
---
|
||||
|
||||
## 五、方向建议(G1/G2/G4 已实施 commit 8ce18cb,2026-06-26)
|
||||
|
||||
最高杠杆:**把「用户当前目标」从消息流提升为一等结构化状态**。
|
||||
|
||||
1. **目标钉扎** ✅ G1 已实施(8ce18cb):首轮提取用户目标为 `PerConvState.pinned_goal`,run_agentic_loop 入口拼进 system_prompt(loop 不变量,免疫 F-15 压缩 / build_eviction_units 裁剪 / sanitize step0 is_active 过滤)。目标消息压缩出局后,结构化目标仍在。
|
||||
2. **探索熔断** ✅ G2 已实施(8ce18cb):连续 N 次空结果无进展 → 两段式警示 + AiHelpRequired 熔断,防游荡死循环 + token 失控(实测 seq287-307 连续 10+ 次盲搜死循环的场景)。
|
||||
3. **状态机收敛** — G3 文档衔接(conv_state.rs / mod.rs 注释:pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum)。
|
||||
4. **话题标记修正** ✅ G4 已实施(8ce18cb):TOPIC_MARKER_GOAL_AWARE guard,pinned_goal 存在时 take 后丢弃跳过 topic marker insert(消除 seq0 task→project 与真实 idea 目标背离的反向误导)。
|
||||
|
||||
附 G5 排查结论:seq1/seq4 system 异常消息是会话早期**旧版本遗留脏数据**(thinking 独白 + tool_result 被错存 system,早期 timestamp),非当前 compress 机制问题(摘要四段式正常),最新 system(seq0 topic marker)正常。**数据治理项,不改代码**。
|
||||
|
||||
---
|
||||
|
||||
## 附:待查 Bug
|
||||
|
||||
- `[seq1]` 一条 LLM 内部独白("等等,`process_tool_calls` 是从 `super::audit` 导入的。让我检查一下…")被存为 `system` 消息(`status=active`)。疑似 reasoning / 思考内容泄漏进 system 消息(`stream_recv` 归位问题),进一步污染上下文。待独立排查。
|
||||
|
||||
---
|
||||
|
||||
## 证据定位(源码)
|
||||
|
||||
| 机制 | 位置 |
|
||||
|---|---|
|
||||
| 上下文压缩 / sanitize / topic 字段 | `crates/df-ai/src/context.rs` |
|
||||
| 话题切换 marker 消费 | `src-tauri/src/commands/ai/agentic/mod.rs:735-772` |
|
||||
| 压缩 insert_at 摘要 | `src-tauri/src/commands/ai/agentic/mod.rs:820, 918, 969` |
|
||||
| 多 Agent 协调(空壳) | `crates/df-ai/src/coordinator.rs` |
|
||||
| Plan 规划(未接入 loop) | `crates/df-ai/src/planner.rs` |
|
||||
| 意图识别(关键词) | `crates/df-ai/src/intent.rs` |
|
||||
| system prompt 聚焦准则 / 压缩 prompt | `src-tauri/src/commands/ai/prompt.rs:104, 233` |
|
||||
| 对话状态机(进行中) | `src-tauri/src/commands/ai/agentic/conv_state.rs` |
|
||||
@@ -102,8 +102,11 @@ AST 是语法层(parse tree)。**语法层能**:
|
||||
| `.ts`/`.tsx` | tree-sitter-typescript | 前端 script |
|
||||
| `.js`/`.jsx` | tree-sitter-javascript | |
|
||||
| `.vue` | **借 tree-sitter-typescript** | 切 `<script>` 段喂 ts;template/style 不做符号(Vue 逻辑在 script) |
|
||||
| `.go` | tree-sitter-go | 2026-06-25 扩展 |
|
||||
| `.java` | tree-sitter-java | 2026-06-25 扩展 |
|
||||
| `.py` | tree-sitter-python | 2026-06-25 扩展 |
|
||||
|
||||
**3 个 grammar 覆盖 4 类文件**。Vue 借 TS 省 grammar + 避 SFC 嵌套复杂度。
|
||||
**6 个 grammar 覆盖 9 类文件**。Vue 借 TS 省 grammar + 避 SFC 嵌套复杂度。Go/Java/Python 2026-06-25 扩展(表驱动加表项:`DEFINITION_KINDS` 补 kind + `grammar_for` 加分支 + `Cargo.toml` 加 crate 0.23,ABI 兼容 0.25 主 crate,零架构改动)。
|
||||
|
||||
**扩展**:加标准语言 = 加 grammar crate + 一张节点映射表(表驱动,~30 行/语言)。小众(C++/Kotlin)按需;动态加载/集成插件化见插件机制设计文档。
|
||||
|
||||
|
||||
@@ -277,8 +277,40 @@ if has_tool_calls { /* push */ }
|
||||
|
||||
---
|
||||
|
||||
## 收口记录(2026-06-25)
|
||||
|
||||
> 双轨状态机退役,enum 单一真相源。本文档 §3.2「轻量状态机,不引入框架」的轻量路线在批3 完成**升级**:原方案是「bool 保留 + enum 视图只读」,收口后 enum 成为写侧真相源,bool 与开关双轨全部删除。
|
||||
|
||||
### 收口了什么
|
||||
|
||||
1. **双轨退役(写侧)**:
|
||||
- `generating: bool` 字段(顶层单例 + `PerConvState` 内)删除。
|
||||
- `CONV_STATE_ENABLED` 灰度开关删除(批2 常量删,批3 全部门控分支删)。
|
||||
- `ConvState` enum(`conv_state.rs`,5 态 Idle/Generating/Stopping/Error/Compressed)成为**唯一真相源**,所有状态写入经 `transition_to` 守卫(非法转换拒绝 `Err(InvalidTransition)`)。
|
||||
- `GeneratingGuard`(guard.rs)的 new/reset/drop 改为无条件迁移 `ConvState` + emit `AiConvStateChanged`(原 `if CONV_STATE_ENABLED` 门控删)。
|
||||
|
||||
2. **AiCompressed 拆 Manual/Auto(治 BUG-260624-05)**:
|
||||
- 原 `AiCompressed` 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话。
|
||||
- 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 `isCompressing` 防按钮卡死,miniapp 仍插摘要气泡)。
|
||||
|
||||
3. **can_accept_request 接入(读侧)**:
|
||||
- `ConvState::can_accept_request()`(Idle/Error 可接)接入 chat 域 4 处入口拦截:`ai_chat_send` / `ai_regenerate` / `ai_chat_edit` / `ai_is_generating`。
|
||||
- 比旧 `is_active()` 更严谨:覆盖 Stopping 态(漏拦停止中接新请求的竞态)。
|
||||
|
||||
### 为什么收口
|
||||
|
||||
- **双轨隐患**:bool 字段与 enum 开关共存期间,任一 guard 入口漏加门控即绕过 enum 守卫回退旧 bool 写,状态机形同虚设。开关是渐进过渡的临时灰度机制,收口即删才是终态,长期保留等于把「过渡态」固化成「架构债」。
|
||||
- **AiCompressed 合流**:手动(用户主动压缩,要反馈)与自动(loop 内压缩,静默)是两种 UX 语义,共用事件必致前端按「最严」语义(弹 toast)兜底,自动路径每次都误触发。拆分是按语义而非按字段。
|
||||
|
||||
### 残留过渡分支(非回归,待清理)
|
||||
|
||||
代码内保留若干 `CONV_STATE_ENABLED off 回退分支` 注释(agentic/mod.rs:1608/1722、chat.rs:1533、remote_bridge.rs:762、前端 types.ts/ChatInput.vue/MaxRoundsCard.vue/useAiEvents.ts),均标注「enum 单路径」,用于兼容老后端/前端未追踪态的兜底。开关常量已删,这些注释分支实际不可触发,属说明性遗留,后续清理时一并删。
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [df-ai AI 集成模块](../../03-模块文档/df-ai-AI集成模块-2026-06-12.md)
|
||||
- [aichat 审查报告-2026-06-14](../构想审查/aichat审查报告-2026-06-14.md)
|
||||
- [流式 Markdown 渲染调研-2026-06-15](../构想审查/aichat流式Markdown渲染调研-2026-06-15.md)
|
||||
- `src-tauri/src/commands/ai/agentic/conv_state.rs`(`ConvState` enum 实现源)
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
# 项目知识图谱与任务队列系统 — 设计
|
||||
|
||||
> **日期**: 2026-06-26
|
||||
> **状态**: 设计阶段(待评审)
|
||||
> **前提**: AI Working — AI 是系统的主要操作者,人是监督者与决策者
|
||||
> **关联**: ARCHITECTURE.md §AI Working 定位体系 / 多工程方案(子1-子5)/ todo.md 灵感模块 + AI 对话目标丢失
|
||||
|
||||
---
|
||||
|
||||
## 〇、核心命题
|
||||
|
||||
**DevFlow 的终局是:AI 拥有完整的项目信息知识图谱,比任何人都了解项目。**
|
||||
|
||||
人在 AI Chat 中描述目标,AI 基于图谱自主分解任务、编排依赖、执行推进、自检完成度。人的角色是决策确认和方向把控,不是填表单和翻列表。
|
||||
|
||||
本文档定义支撑这一终局所需的数据结构、关联关系和实现路径。
|
||||
|
||||
---
|
||||
|
||||
## 一、问题诊断
|
||||
|
||||
### 1.1 当前缺失
|
||||
|
||||
| 缺失 | 后果 |
|
||||
|------|------|
|
||||
| **无全局工作队列** | 任务散落在各项目下,无法跨项目看"所有待办" |
|
||||
| **需求无承载体** | Idea 是模糊灵感,Task 是具体执行,中间缺"已确认要做但还没排期"的需求层 |
|
||||
| **决策无队列** | AI Agentic Loop 中产生的"需要人决策"事项散落在对话流里,错过即丢失 |
|
||||
| **任务无纵向关联** | 无法表达"一个需求拆出多个子任务"(当前用 `[子N]` 标题前缀硬编码) |
|
||||
| **任务无横向关联** | 无法表达任务间依赖关系,AI 无法做拓扑排序编排 |
|
||||
| **需求无结构化规格** | description 是纯文本,AI 无法提取验收标准做自检 |
|
||||
| **无基础设施记录** | AI 不知道项目用了什么数据库/缓存/MQ |
|
||||
| **无统一事件流** | 回答"上周做了什么"需跨 6 张表 JOIN |
|
||||
| **溯源链断裂** | 无法从任务回溯到灵感、从灵感到对话、从对话到决策 |
|
||||
|
||||
### 1.2 核心原则
|
||||
|
||||
> **每一条数据结构都不是给人填的表单,是给 AI 消费的上下文。**
|
||||
>
|
||||
> 前端可视化是最后的投影层,不是数据结构存在的原因。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型设计
|
||||
|
||||
> 当前迁移版本 V27(V21=消息拆分 / V22=灵感评估 / V23=嵌入状态 / V24=灵感关联 / V25=评估唯一约束 / V26=任务索引 / V27=审批状态统一)。本设计从 V28 起。
|
||||
|
||||
### 2.1 Task 表扩展(V28 迁移)
|
||||
|
||||
```sql
|
||||
-- 队列字段:AI 行为约束信号
|
||||
ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo';
|
||||
-- backlog(需求池) / todo(待办池) / decision(待决策池) / active(执行中) / done(已完成)
|
||||
|
||||
-- 父任务 ID:AI 分解-执行编排结构
|
||||
ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id);
|
||||
-- NULL = 叶子任务;非空 = 子任务(限制 1 级,不允许孙任务)
|
||||
|
||||
-- 结构化需求规格:AI 可读写的执行规格
|
||||
ALTER TABLE tasks ADD COLUMN content_json TEXT;
|
||||
-- JSON: { background, acceptance_criteria[], scope[], technical_design, custom_fields }
|
||||
```
|
||||
|
||||
#### queue 字段语义
|
||||
|
||||
| queue 值 | 含义 | AI 行为约束 |
|
||||
|----------|------|------------|
|
||||
| `backlog` | 需求池:确认有价值,未排期 | AI **不自主推进**,等待人确认排期 |
|
||||
| `todo` | 待办池:已排期,待执行 | AI **可自主推进**到 active + in_progress |
|
||||
| `decision` | 待决策池:需要人拍板 | AI **暂停执行**,请求人类输入 |
|
||||
| `active` | 执行中:正在推进 | AI **按状态机推进** |
|
||||
| `done` | 已完成 | 终态 |
|
||||
|
||||
#### queue 与 status 的关系
|
||||
|
||||
两个正交维度,各管各的:
|
||||
|
||||
```
|
||||
queue(管理维度):backlog → todo → active → done
|
||||
↑
|
||||
decision(临时池)
|
||||
|
||||
status(执行维度):todo → in_progress → in_review → testing → done
|
||||
↑
|
||||
blocked / cancelled
|
||||
```
|
||||
|
||||
**一致性约束**(IPC 层校验,不进状态机):
|
||||
- queue=done 时 status 必须=done
|
||||
- queue=backlog 时 status 必须=todo
|
||||
- queue=active 时 status ∈ {in_progress, in_review, testing}
|
||||
- status=blocked 时 queue 可为 decision 或 active
|
||||
|
||||
#### parent_id 纵向关联
|
||||
|
||||
- **限制 1 级嵌套**:parent 有 parent_id 时拒绝再创建子任务
|
||||
- **父任务=容器模型**:父任务 status 不走状态机(不能 advance_task),由子任务聚合计算
|
||||
- **聚合规则**:
|
||||
|
||||
| 子任务状态 | 父任务推导 status |
|
||||
|-----------|-----------------|
|
||||
| 全 todo | todo |
|
||||
| 任一 in_progress | in_progress |
|
||||
| 任一 blocked | blocked |
|
||||
| 全 done/cancelled | done |
|
||||
|
||||
#### content_json 结构
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"background": "当前 DevFlow 一个项目只能绑一个目录...", // 背景:AI 理解动机
|
||||
"acceptance_criteria": [ // 验收标准:AI 自检清单
|
||||
"看板三列拖拽",
|
||||
"跨项目展示任务"
|
||||
],
|
||||
"scope": ["前端 Board.vue", "task_repo", "router"], // 影响范围:AI 操作边界
|
||||
"technical_design": "新增 queue 字段 + task_links 表...", // 技术方案:AI 执行路径
|
||||
"custom_fields": {} // 扩展字段
|
||||
}
|
||||
```
|
||||
|
||||
**定位**:不是人填的表单,是 AI 和人协作填充的结构化规格。AI 从对话中提取信息填充,人确认后 AI 执行中用 acceptance_criteria 自检。
|
||||
|
||||
### 2.2 task_links 表(横向关联)
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
target_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
link_type TEXT NOT NULL, -- depends_on / blocks / relates_to
|
||||
remark TEXT, -- 可选备注
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_task_links_source ON task_links(source_id);
|
||||
CREATE INDEX idx_task_links_target ON task_links(target_id);
|
||||
```
|
||||
|
||||
#### link_type 语义
|
||||
|
||||
| 类型 | 语义 | AI 用途 |
|
||||
|------|------|---------|
|
||||
| `depends_on` | source 依赖 target(target 完成后 source 才能开始) | **拓扑排序调度** |
|
||||
| `blocks` | source 阻塞 target(source 不完成则 target 无法推进) | 依赖的反向声明 |
|
||||
| `relates_to` | 弱关联,无执行约束 | 上下文提示 |
|
||||
|
||||
#### 为什么不用 JSON 列(仿 Idea related_ids)
|
||||
|
||||
Idea 的 related_ids JSON 方案是 todo.md 待修的 P1 问题(#8 关联双向同步)。JSON 列的缺陷:
|
||||
- 无类型区分(不能表达 depends_on vs relates_to)
|
||||
- 单向(A 关联 B 不意味着 B 知道)
|
||||
- 无法高效反向查询("谁依赖了我"需全表扫描 JSON)
|
||||
|
||||
独立表方案:有类型、双向查询、可扩展。
|
||||
|
||||
#### 边界约束
|
||||
|
||||
| 约束 | 规则 |
|
||||
|------|------|
|
||||
| 循环依赖 | 创建 link 时 BFS 检测环,拒绝 A→B→A |
|
||||
| 跨项目 | 允许(现实中有跨项目依赖) |
|
||||
| 软删除 | Task 软删除时保留 link(恢复后关系还在) |
|
||||
| 执行约束 | AI **应遵守** depends_on(检查依赖全 done 才推进),人**可绕过** |
|
||||
|
||||
### 2.3 project_services 表(基础设施配置)
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_services (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL, -- "主数据库" / "缓存" / "消息队列"
|
||||
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
|
||||
endpoint TEXT, -- 连接地址(localhost:3306 / URL)
|
||||
config_json TEXT, -- 类型相关配置 JSON
|
||||
environment TEXT NOT NULL DEFAULT 'development', -- development/staging/production
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**定位**:AI 执行任务时的基础设施上下文。AI 知道"这个项目用了什么数据库、Redis 在哪、有没有 MQ"。
|
||||
|
||||
**边界**:
|
||||
- ⚠️ **不存敏感凭证**(密码/密钥)—— 只存连接信息,凭证走环境变量/外部密钥管理
|
||||
- 这是元数据,不是运维工具 —— 不做连接池/健康检查
|
||||
|
||||
### 2.4 project_events 表(统一事件流)
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
event_type TEXT NOT NULL,
|
||||
-- idea_created / idea_promoted / idea_evaluated
|
||||
-- task_created / task_advanced / task_blocked / task_completed
|
||||
-- workflow_started / workflow_completed
|
||||
-- knowledge_extracted / knowledge_referenced
|
||||
-- service_added / service_updated
|
||||
-- module_bound / module_unbound
|
||||
-- decision_made
|
||||
entity_type TEXT, -- idea/project/task/workflow/knowledge/module/service
|
||||
entity_id TEXT, -- 对应实体 ID
|
||||
from_state TEXT, -- 状态变化前
|
||||
to_state TEXT, -- 状态变化后
|
||||
context_json TEXT, -- 事件附加上下文
|
||||
source TEXT, -- ai / human / system
|
||||
conversation_id TEXT, -- 触发事件的对应对话(AI Working 溯源)
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_project_events_project ON project_events(project_id, created_at);
|
||||
CREATE INDEX idx_project_events_entity ON project_events(entity_type, entity_id);
|
||||
```
|
||||
|
||||
**定位**:AI 精准检索的基础。回答"上周做了什么""这个任务为什么 blocked""这个决策是什么时候做的"。
|
||||
|
||||
**与 knowledge_events 的关系**:knowledge_events 保持不变(知识库专属,向后兼容)。project_events 是全局事件流,knowledge 事件同时写入两者。
|
||||
|
||||
**埋点策略**:在现有 IPC 命令(create_task / advance_task / create_project 等)的执行后追加事件写入。不侵入业务逻辑,用 hook/after 模式。
|
||||
|
||||
### 2.5 Inbox 项目(project_id NOT NULL 兼容)
|
||||
|
||||
需求池的任务可能还没决定归到哪个项目。保持 `project_id NOT NULL` 约束,系统初始化自动创建 Inbox 项目:
|
||||
|
||||
- name = "收件箱"
|
||||
- status = planning
|
||||
- 特殊标记不可删除
|
||||
- 不参与 Dashboard 统计
|
||||
|
||||
---
|
||||
|
||||
## 三、完整图谱视图
|
||||
|
||||
```
|
||||
💡 Idea
|
||||
╱ │ ╲
|
||||
evaluations related_ids
|
||||
│
|
||||
promoted_to
|
||||
│
|
||||
▼
|
||||
┌────── 📂 Project ──────────────────────────────────┐
|
||||
│ ╱ │ ╲ │
|
||||
│ modules services project_events │
|
||||
│ (多工程) (基础设施) (统一事件流) │
|
||||
│ │
|
||||
│ ╱ │ ╲ │
|
||||
│ parent links workflow │
|
||||
│ (纵向) (横向) (执行) │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ 🔀 Task ── Task ── ⚙️ Workflow │
|
||||
│ │ ╱ ╲ │
|
||||
│ content_json nodes branches │
|
||||
│ (执行规格) (节点执行) (分支) │
|
||||
│ │
|
||||
│ queue (管理池: backlog/todo/decision/active/done) │
|
||||
│ │
|
||||
│ ai_tool_executions ←─ conversation_id ──→ 💬 对话 │
|
||||
│ (AI 操作审计) (AI Working 留痕) │
|
||||
│ │
|
||||
│ knowledges ←── knowledge_events │
|
||||
│ (经验沉淀) (知识事件) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
|
||||
每条线都是 AI 可查询、可推理的关系。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、AI 上下文注入
|
||||
|
||||
### 4.1 get_project_context 工具
|
||||
|
||||
AI "比人懂项目"的技术实现 —— 一次调用获得完整心智模型:
|
||||
|
||||
```jsonc
|
||||
// get_project_context(project_id) →
|
||||
{
|
||||
"project": {
|
||||
"id", "name", "status", "stack",
|
||||
"created_from": { "idea_id", "ai_analysis", "score" }
|
||||
},
|
||||
"modules": [
|
||||
{ "name", "path", "stack", "dependencies": [...] }
|
||||
],
|
||||
"services": [
|
||||
{ "type", "endpoint", "environment" }
|
||||
],
|
||||
"task_summary": {
|
||||
"total", "by_status", "by_queue",
|
||||
"blocked_tasks": [{ "id", "title", "blocked_by" }],
|
||||
"in_progress": [{ "id", "title", "progress" }]
|
||||
},
|
||||
"task_graph": {
|
||||
"parent_children": { "parent_id": ["child_id", ...] },
|
||||
"dependencies": [{ "from", "to", "type" }]
|
||||
},
|
||||
"recent_events": [/* 最近 20 条事件 */],
|
||||
"key_decisions": [/* 重要决策记录 */],
|
||||
"known_pitfalls": [/* knowledges kind=pitfall */]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 自动注入策略
|
||||
|
||||
此上下文可自动注入到每次 AI 对话的 system prompt(类似现有 useAiContext),让 AI 在每轮交互中都带着完整的项目认知。
|
||||
|
||||
**Token 预算**:完整上下文可能较大,需分级注入:
|
||||
- **L0(始终注入)**:project 基础信息 + task_summary(~500 token)
|
||||
- **L1(对话开始注入)**:task_graph + recent_events(~2000 token)
|
||||
- **L2(按需调用)**:full context(get_project_context 工具)
|
||||
|
||||
---
|
||||
|
||||
## 五、AI 工具注册表
|
||||
|
||||
| 工具 | 用途 | 风险等级 |
|
||||
|------|------|---------|
|
||||
| `create_task` | 创建任务(支持 queue/parent_id/content_json) | low |
|
||||
| `list_tasks` | 查询(支持 queue/parent_id/status 筛选) | low(只读) |
|
||||
| `move_task_queue` | 跨池移动(backlog→todo→active→done) | medium |
|
||||
| `create_task_link` | 建立任务关联(depends_on/blocks/relates_to) | medium |
|
||||
| `remove_task_link` | 解除关联 | medium |
|
||||
| `list_task_links` | 查询任务关联(AI 编排调度用) | low(只读) |
|
||||
| `get_task_tree` | 获取父子任务树(含子任务进度聚合) | low(只读) |
|
||||
| `update_content` | 更新 content_json 结构化规格 | medium |
|
||||
| `get_project_context` | 获取项目完整图谱投影 | low(只读) |
|
||||
| `get_project_timeline` | 查询项目事件流 | low(只读) |
|
||||
| `add_project_service` | 添加基础设施配置 | medium |
|
||||
| `list_project_services` | 查询基础设施 | low(只读) |
|
||||
|
||||
已有工具(扩展参数):`advance_task`、`update_task`、`list_tasks`。
|
||||
|
||||
---
|
||||
|
||||
## 六、AI Working 完整工作流
|
||||
|
||||
```
|
||||
人在 AI Chat 说:"我想给 DevFlow 加队列看板功能"
|
||||
│
|
||||
▼ ① AI 创建需求
|
||||
create_task(
|
||||
title="队列看板功能",
|
||||
queue="backlog",
|
||||
content_json={ background, acceptance_criteria, scope, technical_design }
|
||||
)
|
||||
│
|
||||
▼ ② 人确认需求(AI Chat 内审批)
|
||||
人修改 content_json → AI 执行 move_task_queue(backlog → todo)
|
||||
│
|
||||
▼ ③ AI 自主分解
|
||||
AI 创建子任务(parent_id 指向父任务):
|
||||
child-1: "V28 迁移 + queue 列 + task_links 表"
|
||||
child-2: "TaskRecord + Repo 扩展"
|
||||
child-3: "Board.vue 看板视图"
|
||||
child-4: "AI 工具注册"
|
||||
│
|
||||
▼ ④ AI 建立依赖
|
||||
create_task_link(child-3, depends_on, child-1)
|
||||
create_task_link(child-3, depends_on, child-2)
|
||||
create_task_link(child-4, depends_on, child-2)
|
||||
│
|
||||
▼ ⑤ AI 按拓扑序执行
|
||||
拓扑排序:child-1 → child-2 → child-3 ∥ child-4
|
||||
AI 逐个 advance_task(todo → in_progress → ... → done)
|
||||
每完成一个检查后继解锁
|
||||
每步操作写入 project_events
|
||||
│
|
||||
▼ ⑥ AI 自检
|
||||
对照 content_json.acceptance_criteria 逐项检查
|
||||
│
|
||||
▼ ⑦ 父任务聚合
|
||||
全部子任务 done → 父任务自动聚合 done
|
||||
AI 报告人类:"队列看板功能已完成"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、改动影响评估
|
||||
|
||||
| 层 | 改动 | 量级 | 风险 |
|
||||
|---|------|------|------|
|
||||
| df-storage V28 | queue 列 + parent_id 列 + content_json 列 + task_links 表 + project_services 表 + project_events 表 | 中 | 低(纯加列加表) |
|
||||
| df-types | TaskRecord 加 3 字段 + TaskLink / ProjectService / ProjectEvent 类型 | 小 | 低 |
|
||||
| task_repo | insert/update 补字段 + TaskLinkRepo / ProjectServiceRepo / ProjectEventRepo | 中 | 低 |
|
||||
| task_state_machine | **不改** | 无 | 无 |
|
||||
| 父任务聚合逻辑 | 新增(子任务 status 变化时重算父 status) | 中 | 中(需定义聚合规则) |
|
||||
| 事件埋点 | 在现有 IPC 命令后追加事件写入 | 中 | 低(hook 模式,不侵入) |
|
||||
| IPC | create_task 加可选参数 + link/service/event CRUD + move_task_queue | 中 | 低 |
|
||||
| AI 工具 | 新增 12 个工具注册 + create_task 参数扩展 | 中 | 低 |
|
||||
| 前端 Board 看板 | 新增视图 | 中 | 低 |
|
||||
| 前端 Task 详情 | 结构化渲染 content_json + 子任务列表 + 关联面板 | 中 | 低 |
|
||||
| 前端项目详情 | 基础设施面板 + 时间线面板 | 中 | 低 |
|
||||
| Inbox 项目 | 系统初始化创建 | 小 | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实现路径(按 AI 价值排序)
|
||||
|
||||
### Phase 1 — 任务网络基础(AI 编排的地基)
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| V28 迁移 | queue + parent_id + content_json 列 + task_links 表 | 无 |
|
||||
| TaskRecord 扩展 | df-types + df-storage models 补字段 | V28 |
|
||||
| TaskLinkRepo | CRUD + 按 source/target 查询 + 循环依赖检测 | V28 |
|
||||
| task_repo 扩展 | insert/update 补 queue/parent_id/content_json | TaskRecord |
|
||||
| IPC: create_task 扩展 | 加 queue/parent_id/content_json 可选参数 | task_repo |
|
||||
| IPC: task_link CRUD | create/remove/list_task_links | TaskLinkRepo |
|
||||
| IPC: move_task_queue | 跨池移动 + 一致性约束校验 | task_repo |
|
||||
| 父任务聚合 | 子任务 status 变化时重算父 status | task_repo |
|
||||
| AI 工具注册 | create_task_link / remove_task_link / list_task_links / get_task_tree / move_task_queue / update_content | IPC |
|
||||
|
||||
### Phase 2 — 统一事件流(AI 精准检索的地基)
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| project_events 表 | V28(或 V29 独立迁移) | 无 |
|
||||
| ProjectEventRepo | CRUD + 按 project_id 查询 + 按 entity 查询 | project_events |
|
||||
| 事件埋点 | create_task / advance_task / create_project / idea_promote 等追加事件写入 | ProjectEventRepo |
|
||||
| IPC: get_project_timeline | 事件流查询(支持时间范围/事件类型筛选) | ProjectEventRepo |
|
||||
| AI 工具注册 | get_project_timeline | IPC |
|
||||
|
||||
### Phase 3 — 项目基础设施(AI 上下文补全)
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| project_services 表 | V28(或 V30 独立迁移) | 无 |
|
||||
| ProjectServiceRepo | CRUD | project_services |
|
||||
| IPC: service CRUD | add/update/remove/list_project_services | ProjectServiceRepo |
|
||||
| modules 表 | 复用多工程方案子1 | 无 |
|
||||
| AI 工具注册 | add_project_service / list_project_services | IPC |
|
||||
|
||||
### Phase 4 — AI 上下文注入(AI 使用知识图谱的接口)
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| get_project_context 工具 | 聚合查询返回完整图谱投影 | Phase 1-3 |
|
||||
| 分级注入策略 | L0 始终注入 / L1 对话开始 / L2 按需调用 | get_project_context |
|
||||
| useAiContext 扩展 | 对接现有上下文注入机制 | 分级注入策略 |
|
||||
|
||||
### Phase 5 — 前端可视化(人的查看层)
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| Board 看板 | 三列拖拽 + 跨项目展示 + 快速录入 | Phase 1 |
|
||||
| Task 详情增强 | content_json 结构化渲染 + 子任务列表 + 关联面板 | Phase 1 |
|
||||
| 项目时间线 | 事件流可视化 | Phase 2 |
|
||||
| 基础设施面板 | 服务列表 + 增删改 | Phase 3 |
|
||||
| 项目图谱视图 | 全景关系图(可选,优先级最低) | Phase 1-3 |
|
||||
|
||||
---
|
||||
|
||||
## 九、关键设计决策记录
|
||||
|
||||
| # | 决策点 | 选择 | 理由 |
|
||||
|---|--------|------|------|
|
||||
| D1 | queue vs status 扩展 | 新增 queue 字段 | 正交维度,状态机不动,职责分离 |
|
||||
| D2 | parent_id 嵌套深度 | 限制 1 级 | 避免递归复杂度和进度幻觉 |
|
||||
| D3 | 父任务语义 | 容器模型,status 聚合计算 | 父任务不执行工作流 |
|
||||
| D4 | 横向关联 | 独立 task_links 表 | 有类型、可双向查询,避免 JSON 方案已知缺陷 |
|
||||
| D5 | 需求结构化 | content_json + description 并存 | 结构化渲染 + 自由描述互补 |
|
||||
| D6 | project_id 约束 | 保持 NOT NULL + Inbox 项目 | 最小改动,GTD 收件箱模式 |
|
||||
| D7 | depends_on 执行约束 | AI 应遵守,人可绕过 | 过强约束会被绕过 |
|
||||
| D8 | 循环依赖检测 | 创建 link 时 BFS 检测 | task_links 数据量小,检测成本低 |
|
||||
| D9 | 事件流与 knowledge_events | 并存不替代 | knowledge_events 向后兼容 |
|
||||
| D10 | 敏感凭证 | 不存入 project_services | 安全边界,凭证走环境变量 |
|
||||
| D11 | 数据结构定位 | 为 AI 消费设计,非为人填表单 | AI Working 核心原则 |
|
||||
| D12 | 实现优先级 | 按 AI 价值排序非按 UI 需求 | AI Working 核心原则 |
|
||||
|
||||
---
|
||||
|
||||
## 十、任务全景视图设计(Mission Control)
|
||||
|
||||
> **核心目标**:一个页面,看清全部项目的任务推进过程、状态分布、依赖关系、时间维度、卡点阻塞。
|
||||
|
||||
### 10.1 信息架构
|
||||
|
||||
用户要的是五个维度的清晰呈现,对应五种视图模式:
|
||||
|
||||
| 维度 | 视图 | 核心问题 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| **推进过程** | 看板视图 | 任务在 backlog→todo→active→done 各池的分布和流动 | tasks.queue |
|
||||
| **状态分布** | 热力矩阵 | 每个项目 × 每个状态的交叉分布,一眼看全局 | tasks GROUP BY project, status |
|
||||
| **依赖关系** | DAG 图 | 任务间依赖链、拓扑序、关键路径 | task_links + parent_id |
|
||||
| **时间维度** | 时间线 | 任务的创建/推进/完成时间线,最近活跃度 | tasks.created_at/updated_at + project_events |
|
||||
| **卡点阻塞** | 阻塞面板 | 哪些任务 blocked、为什么 blocked、阻塞了谁 | tasks.status=blocked + task_links(blocks) |
|
||||
|
||||
### 10.2 路由 & 页面结构
|
||||
|
||||
```
|
||||
/mission-control → MissionControl.vue(壳页面,顶部 Tab 切换五种视图)
|
||||
├── Tab 1: 看板(KanbanView.vue) — 默认视图
|
||||
├── Tab 2: 矩阵(MatrixView.vue)
|
||||
├── Tab 3: 依赖图(DagView.vue)
|
||||
├── Tab 4: 时间线(TimelineView.vue)
|
||||
├── Tab 5: 阻塞(BlockedView.vue)
|
||||
└── 公共侧栏: 筛选器(项目/优先级/负责人)
|
||||
```
|
||||
|
||||
侧栏新增导航项:**🚀 任务全景**(Mission Control),位于"任务"和"知识库"之间。
|
||||
|
||||
### 10.3 各视图详细设计
|
||||
|
||||
#### 10.3.1 看板视图(KanbanView)— 推进过程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 💡 需求池(3) │ 📋 待办(5) │ 🔨 执行中(2) │ ✅ 完成(12) │
|
||||
│ │ │ │ │
|
||||
│ ┌───────────────┐ │ ┌────────────────┐ │ ┌──────────────┐ │ ┌──────────┐ │
|
||||
│ │ 队列看板功能 │ │ │ V28 迁移+表结构 │ │ │ TaskRecord扩 │ │ │ 软删除 │ │
|
||||
│ │ DevFlow · P1 │ │ │ DevFlow · P0 │ │ │ DevFlow·P1 │ │ │ B-13 │ │
|
||||
│ │ 🔗 4 子任务 │ │ │ 🔗 → Board.vue │ │ │ ▓▓▓▓▓░░ 70% │ │ │ 06-16 │ │
|
||||
│ └───────────────┘ │ └────────────────┘ │ └──────────────┘ │ └──────────┘ │
|
||||
│ ┌───────────────┐ │ ┌────────────────┐ │ │
|
||||
│ │ 插件机制 │ │ │ DagView 依赖图 │ │ │
|
||||
│ │ DevFlow · P2 │ │ │ DevFlow · P1 │ │ │
|
||||
│ └───────────────┘ │ └────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ⚠️ 待决策(1) │ │ 🚫 阻塞(1) │
|
||||
│ ┌───────────────┐ │ │ ┌──────────────┐ │
|
||||
│ │ API 协议选型 │ │ │ │ IPC 层封装 │ │
|
||||
│ │ ❓需确认 │ │ │ │ 被 V28 阻塞 │ │
|
||||
│ └───────────────┘ │ │ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**列定义**(对应 queue 值):
|
||||
|
||||
| 列 | queue | 子列 | 说明 |
|
||||
|----|-------|------|------|
|
||||
| 需求池 | backlog | — | 确认有价值,未排期 |
|
||||
| 待决策 | decision | — | 需要人拍板,**红色高亮** |
|
||||
| 待办 | todo | — | 已排期,可执行 |
|
||||
| 执行中 | active | 正常 | 按状态机推进中 |
|
||||
| 执行中 | active | 阻塞 | status=blocked,**红色边框 + 阻塞原因** |
|
||||
| 完成 | done | — | 终态 |
|
||||
|
||||
**卡片信息密度**:
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 任务标题(1 行截断) │ ← title
|
||||
│ 项目名 · 优先级标签 │ ← project.name + priority badge
|
||||
│ 🔗 3 子任务 / 🔗→ 依赖任务 │ ← parent_id 子任务计数 / depends_on 链
|
||||
│ ▓▓▓▓░░ 60% │ ← 子任务完成率(父任务)/ 状态机进度(叶子)
|
||||
│ 🚫 被 xxx 阻塞 │ ← blocked 时显示阻塞源(仅红色卡片)
|
||||
│ 06-26 │ ← updated_at(相对时间)
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**交互**:
|
||||
- 点击卡片 → 跳转 TaskDetail
|
||||
- 卡片左滑/右滑 → 快捷 move_task_queue(需确认)
|
||||
- 列头显示计数 + 拖拽提示
|
||||
- 顶部全局筛选:项目(多选)/ 优先级 / 负责人
|
||||
|
||||
**跨项目展示**:卡片左上角的项目色条(3px)区分项目归属,悬停显示项目全名。
|
||||
|
||||
#### 10.3.2 热力矩阵(MatrixView)— 状态分布
|
||||
|
||||
```
|
||||
todo in_progress in_review testing blocked done 总计
|
||||
DevFlow 3 2 1 0 2 12 20
|
||||
u-ask 1 0 0 0 0 5 6
|
||||
u-img 0 1 0 0 0 3 4
|
||||
workpod 2 0 0 0 0 1 3
|
||||
─────────────────────────────────────────────────────────────────────────
|
||||
总计 6 3 1 0 2 21 33
|
||||
```
|
||||
|
||||
**视觉**:每个格子是一个色块,数字越大颜色越深(热力图)。blocked 列**红色系**,done 列**绿色系**,其他列**蓝色系**。
|
||||
|
||||
**交互**:
|
||||
- 点击格子 → 展开该项目该状态的任务列表(inline accordion 或侧抽屉)
|
||||
- 行/列可排序(按总计降序)
|
||||
- 顶部摘要条:`33 任务 · 2 阻塞 · 3 执行中 · 完成率 64%`
|
||||
|
||||
**AI 消费价值**:get_project_context 的 task_summary.by_status / by_queue 就是这个矩阵的数据源。AI 读到 `blocked: 2` 就知道要主动排查阻塞。
|
||||
|
||||
#### 10.3.3 依赖图(DagView)— 依赖关系
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ V28 迁移 │ ← 无依赖,可立即执行
|
||||
│ (done) │
|
||||
└────┬─────┘
|
||||
│ depends_on
|
||||
▼
|
||||
┌──────────┐
|
||||
│ TaskRepo │
|
||||
│ 扩展(todo) │
|
||||
└──┬────┬──┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────┐ ┌──────────┐
|
||||
│ IPC 封装 │ │ AI 工具 │
|
||||
│(blocked)│ │ 注册(todo) │ ← 被 V28 阻塞(虚线 blocks)
|
||||
└────┬────┘ └──────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│Board.vue │
|
||||
│ (todo) │
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
**节点视觉**:
|
||||
- 状态颜色:todo=灰 / in_progress=蓝 / in_review=黄 / testing=橙 / done=绿 / blocked=红
|
||||
- 父任务=大圆角矩形,子任务=小圆角矩形,用虚线框分组
|
||||
- depends_on=实线箭头 → / blocks=红色虚线箭头 ⇢ / relates_to=灰色点线 ⋯
|
||||
|
||||
**布局算法**:
|
||||
- 拓扑排序分层(Sugiyama / 简化版:按依赖深度分层)
|
||||
- 同层节点水平排列
|
||||
- 父子关系用虚线框圈住
|
||||
|
||||
**交互**:
|
||||
- 点击节点 → 高亮该节点的所有依赖链(上游+下游路径加粗)
|
||||
- 双击节点 → 跳转 TaskDetail
|
||||
- 悬停节点 → 显示 tooltip(标题/状态/阻塞原因)
|
||||
- 右键节点 → 快捷操作(推进/解除阻塞/编辑依赖)
|
||||
- 顶部工具栏:仅看阻塞链 / 仅看关键路径 / 按项目着色
|
||||
|
||||
**关键路径标注**:拓扑排序后,从入度=0 的节点到出度=0 的节点的最长路径,用粗线标注。
|
||||
|
||||
**数据量控制**:
|
||||
- 默认只渲染 active + todo + blocked 的任务(隐藏 done/cancelled)
|
||||
- 超过 30 个节点时提示"节点过多,请按项目筛选"
|
||||
- 支持按项目过滤
|
||||
|
||||
#### 10.3.4 时间线(TimelineView)— 时间维度
|
||||
|
||||
两种模式:
|
||||
|
||||
**模式 A:甘特图(横向时间轴)**
|
||||
|
||||
```
|
||||
06-24 06-25 06-26 06-27 06-28 06-29 06-30
|
||||
V28迁移 ████████←done
|
||||
TaskRepo ████████←in_progress 70%
|
||||
IPC封装 ██████←blocked(等V28)
|
||||
Board.vue ░░░░░░←todo(未开始)
|
||||
AI工具注册 ░░░░░░←todo
|
||||
```
|
||||
|
||||
- 横轴=日期(created_at → 预估完成 / 实际 updated_at)
|
||||
- 每行一个任务,条形颜色=状态色
|
||||
- blocked 段叠加红色斜纹
|
||||
- 父任务行展开显示子任务
|
||||
|
||||
**模式 B:事件流(纵向时间轴)**
|
||||
|
||||
```
|
||||
今天 06-26
|
||||
│
|
||||
├─ 14:30 ✅ V28 迁移完成 DevFlow
|
||||
├─ 13:15 🔨 TaskRepo 扩展开始 DevFlow
|
||||
├─ 11:00 🚫 IPC 封装被阻塞 DevFlow ← 红色高亮
|
||||
│ 原因:等待 V28 迁移完成
|
||||
├─ 09:45 💡 创建需求"队列看板" DevFlow
|
||||
│
|
||||
昨天 06-25
|
||||
│
|
||||
├─ 22:10 ✅ B-27 审批状态统一 DevFlow
|
||||
├─ 18:30 🔨 V28 迁移开始 DevFlow
|
||||
...
|
||||
```
|
||||
|
||||
- 来源:project_events 表
|
||||
- 事件类型图标 + 颜色区分
|
||||
- blocked/decision 事件**红色高亮**
|
||||
- 按天分组,可折叠
|
||||
- 支持按项目/事件类型筛选
|
||||
- 无限滚动加载
|
||||
|
||||
**切换**:顶部 Toggle 切换甘特/事件流两种模式。
|
||||
|
||||
#### 10.3.5 阻塞面板(BlockedView)— 卡点
|
||||
|
||||
**这是最重要的视图** —— 回答"哪里卡住了、为什么、阻塞了谁"。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🚫 阻塞任务 (2) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🔴 IPC 层封装 DevFlow · P1 │
|
||||
│ 状态: blocked ← 3 天 │
|
||||
│ 阻塞原因: 等待 V28 迁移完成 │
|
||||
│ 阻塞了谁: ↓ │
|
||||
│ └─ Board.vue 看板视图 (todo,无法开始) │
|
||||
│ └─ AI 工具注册 (todo,无法开始) │
|
||||
│ 连锁影响: 2 个下游任务被阻塞 │
|
||||
│ [解除阻塞] [查看详情] │
|
||||
│ │
|
||||
│ 🔴 API 协议选型 u-ask · P0 │
|
||||
│ 状态: blocked ← 7 天 ⚠️ 长时间阻塞 │
|
||||
│ 阻塞原因: 需要确认 REST vs GraphQL │
|
||||
│ 阻塞了谁: ↓ │
|
||||
│ └─ 后端 API 重构 (todo) │
|
||||
│ └─ 前端适配 (todo) │
|
||||
│ 连锁影响: 3 个下游任务被阻塞 │
|
||||
│ [解除阻塞] [查看详情] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ⚠️ 待决策 (1) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ❓ 缓存策略选型 workpod · P2 │
|
||||
│ 队列: decision ← 2 天 │
|
||||
│ 决策内容: Redis vs 本地缓存 │
|
||||
│ 阻塞了谁: ↓ │
|
||||
│ └─ 缓存模块实现 (backlog) │
|
||||
│ [做出决策] [查看详情] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**数据来源**:
|
||||
- `tasks WHERE status = 'blocked'` → 阻塞任务列表
|
||||
- `task_links WHERE target_id = :blocked_task_id AND link_type = 'depends_on'` → 查谁被它阻塞
|
||||
- `tasks WHERE queue = 'decision'` → 待决策列表
|
||||
- BFS 遍历依赖链计算"连锁影响数"(下游被间接阻塞的任务总数)
|
||||
|
||||
**关键视觉**:
|
||||
- 阻塞时间 > 3 天 → 黄色警告标记
|
||||
- 阻塞时间 > 7 天 → 红色闪烁标记 + 置顶
|
||||
- 连锁影响 > 3 → 加粗影响数
|
||||
- 每个阻塞项可展开"阻塞链"树形图
|
||||
|
||||
**排序策略**:
|
||||
1. 连锁影响数降序(阻塞越多越优先处理)
|
||||
2. 阻塞时间降序(越久越紧急)
|
||||
3. 优先级降序
|
||||
|
||||
### 10.4 公共筛选器(侧栏)
|
||||
|
||||
所有视图共享的筛选维度:
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 🔍 筛选 │
|
||||
│ │
|
||||
│ 项目 │
|
||||
│ ☑ DevFlow │
|
||||
│ ☑ u-ask │
|
||||
│ ☐ u-img │
|
||||
│ ☑ workpod │
|
||||
│ │
|
||||
│ 优先级 │
|
||||
│ ☑ P0 紧急 │
|
||||
│ ☑ P1 高 │
|
||||
│ ☑ P2 中 │
|
||||
│ ☐ P3 低 │
|
||||
│ │
|
||||
│ 负责人 │
|
||||
│ [全部 ▾] │
|
||||
│ │
|
||||
│ 隐藏已完成 │
|
||||
│ [开关] │
|
||||
│ │
|
||||
│ 仅看阻塞链 │
|
||||
│ [开关] │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 10.5 后端 API 设计
|
||||
|
||||
| IPC Command | 用途 | 参数 |
|
||||
|-------------|------|------|
|
||||
| `get_mission_kanban` | 看板视图数据(按 queue 分组) | project_ids?, priority? |
|
||||
| `get_mission_matrix` | 热力矩阵数据(project × status 交叉统计) | project_ids? |
|
||||
| `get_mission_dag` | DAG 依赖图数据(节点+边) | project_ids?, include_done? |
|
||||
| `get_mission_timeline` | 时间线数据(事件流 or 甘特图) | project_ids?, date_from?, date_to?, mode |
|
||||
| `get_mission_blocked` | 阻塞面板数据(含连锁影响计算) | project_ids? |
|
||||
|
||||
**性能考虑**:
|
||||
- 任务量级 ~50-200(桌面应用单用户),全量查询无压力
|
||||
- DAG 连锁影响用 Rust BFS 遍历,不走 SQL 递归
|
||||
- 时间线分页加载(每次 50 条事件)
|
||||
- 矩阵统计用 SQL `GROUP BY project_id, status` 一次查询
|
||||
|
||||
### 10.6 AI 消费场景
|
||||
|
||||
这些视图的数据结构同时也是 AI 的上下文:
|
||||
|
||||
| AI 场景 | 数据来源 | 对应视图 |
|
||||
|---------|---------|---------|
|
||||
| "项目进展如何?" | get_mission_matrix | 热力矩阵 |
|
||||
| "有什么卡住了?" | get_mission_blocked | 阻塞面板 |
|
||||
| "下一步做什么?" | get_mission_dag(拓扑排序入度=0 的 todo 任务) | 依赖图 |
|
||||
| "上周做了什么?" | get_mission_timeline(事件流) | 时间线 |
|
||||
| "这个需求拆得对不对?" | get_task_tree + get_mission_dag | 看板 + 依赖图 |
|
||||
|
||||
**AI 自动排查阻塞**:当 get_project_context 返回 blocked_tasks 非空时,AI 可主动在对话中提示"检测到 2 个任务被阻塞,是否需要排查?"并附带阻塞链摘要。
|
||||
|
||||
### 10.7 实现优先级
|
||||
|
||||
| 优先级 | 视图 | 理由 |
|
||||
|--------|------|------|
|
||||
| **P0** | 阻塞面板 | 最高价值:一眼看卡点,当前完全缺失 |
|
||||
| **P0** | 看板视图 | 核心交互:queue 流动可视化 |
|
||||
| **P1** | 热力矩阵 | 全局分布:跨项目状态一览 |
|
||||
| **P1** | 时间线(事件流模式) | 追溯能力:做了什么/何时做的 |
|
||||
| **P2** | 依赖图(DAG) | 编排可视化:需要 task_links 先落地 |
|
||||
| **P2** | 时间线(甘特图模式) | 锦上添花:需要预估完成时间字段 |
|
||||
|
||||
**P0 可独立先做**:阻塞面板 + 看板视图只需要 tasks 表的 queue/status 字段(Phase 1),不依赖 task_links(依赖图才需要)。
|
||||
|
||||
---
|
||||
|
||||
## 十一、边界与风险
|
||||
|
||||
### 10.1 已识别风险
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| queue 与 status 一致性 | 两个字段可能不同步 | IPC 层强制校验约束规则 |
|
||||
| 父任务聚合性能 | 子任务变化触发父重算 | 项目内任务量小(~50),实时计算无压力 |
|
||||
| 事件流写入失败 | 埋点失败影响事件完整性 | 事件写入失败不阻断主操作(best-effort) |
|
||||
| get_project_context 体积 | 大项目上下文可能超 token 预算 | 分级注入 L0/L1/L2 |
|
||||
| task_links 循环依赖 | 死锁 | BFS 检测 + 拒绝创建 |
|
||||
|
||||
### 10.2 不做的事
|
||||
|
||||
- ❌ 不做无限嵌套任务树(限制 1 级)
|
||||
- ❌ 不做凭证存储/管理(安全边界外)
|
||||
- ❌ 不做事件流回填(历史数据不补事件,从上线时间点开始记录)
|
||||
- ❌ 不做前端拖拽排序的 sort_order(queue 内排序按 priority + created_at)
|
||||
- ❌ 不做看板的 WIP 限制(列宽限制,过度设计)
|
||||
@@ -37,6 +37,7 @@
|
||||
- **影响**:1.32M 接近模型上下文上限,长会话必爆;成本几乎全烧在重复搬运文件内容。
|
||||
- **待核验源码点**:compress 模块(`src-tauri/src/commands/ai/` 下 compress 相关)——压缩后重组 messages 时是否真替换为摘要,还是仅打标记原样回灌。
|
||||
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] 3-compress 单元的 2 个 smell。
|
||||
- **[2026-06-24 走查结论:已隐式修复,非当前缺陷]** 源码逐环核验:`build_for_request`(context.rs:147)两分支(未超预算 :161 / 超预算裁剪 :200)都走 `sanitize_messages`,step0(:231-234) `filter is_active()` 把 `status=compressed` 滤出发送 prompt;`is_active` 白名单(provider.rs:97-99 `None|Some("active")`)使 compressed→false;`compress_old_messages`(:820-831)标 compressed+扣 token,`push`(:76)仅 active 计 history_tokens → **compressed 真不进 LLM prompt**。is_active 白名单提交 `8a142c2`(2026-06-17)在 P0-1 实测会话(84dc36c8 ~06-22)之前。报告所见「66 compressed 但 prompt 1.32M」真因 = F-15 白名单前的老数据残留 / 或落库全量消息数 vs 送 LLM prompt token 测量混淆。**compress_with_focus 不必要**。
|
||||
|
||||
### [P0-2] 工具返回值协议不一致:`patch_file` / `read_file` 部分路径裸吐文件全文
|
||||
|
||||
|
||||
+140
-5
@@ -8,6 +8,77 @@
|
||||
|
||||
---
|
||||
|
||||
## 🎯 统一规划(2026-06-26 全景)
|
||||
|
||||
> 整合知识图谱设计 + 22 条未完成项 + G1 目标钉扎(✅落地待实测) + 多工程方案。
|
||||
> **核心主线:AI Working** — AI 拥有项目知识图谱,自主分解/编排/执行/自检,人只决策。
|
||||
> **执行策略(两阶段)**:① 先攒批无冲突小 bug/小调整 → 提交;② workflow 编排相关任务批量推进大重构(减少文件读取/LLM 交互,提高上下文效率)。
|
||||
|
||||
### 主线脉络
|
||||
```
|
||||
G1目标钉扎(✅落地,待实测) → 知识图谱Phase1(任务网络,AI编排地基)
|
||||
↓ ↓
|
||||
实测验证 Phase4注入(复用G1的system_prompt注入基建)
|
||||
↓ ↑
|
||||
F-09多会话B(立项) ────────────────┘ (Phase4前置:多会话各自注入)
|
||||
```
|
||||
|
||||
### 任务图谱(DAG · 有方向有状态)
|
||||
|
||||
> 节点=任务(着色=状态),边=依赖方向(前置 → 后继)。**推进时更新状态**(CLI/edit 同步此表 + Mermaid)。
|
||||
> 图例:✅完成 / 🔨进行中 / 📋待办 / 🚫阻塞
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
classDef done fill:#bfb,stroke:#393,stroke-width:2px
|
||||
classDef doing fill:#ffd,stroke:#993,stroke-width:2px
|
||||
classDef todo fill:#eef,stroke:#99c
|
||||
classDef blocked fill:#fcc,stroke:#c33
|
||||
|
||||
G1["G1 目标钉扎<br/>✅代码 8ce18cb / 🔨待实测"]:::doing
|
||||
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
|
||||
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
|
||||
P3["父③ AI对话体验<br/>📋待办"]:::todo
|
||||
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
|
||||
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
|
||||
P6["父⑥ Phase2-5<br/>🔨Phase2-3✅·4-5待办"]:::doing
|
||||
P7["父⑦ 技术债<br/>📋待办"]:::todo
|
||||
|
||||
P1 -.先行.-> P2
|
||||
G1 ==基建==> P6
|
||||
P2 ==> P6
|
||||
P4 ==> P6
|
||||
P2 -.task_links.-> P5
|
||||
```
|
||||
|
||||
| 任务 | 状态 | 子项 | 依赖(前置) |
|
||||
|---|---|---|---|
|
||||
| **G1** 目标钉扎 | 🔨 代码✅`8ce18cb` 待实测 | G1/G2/G4 落地 | — |
|
||||
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
|
||||
| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) |
|
||||
| **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — |
|
||||
| **父④** F-09 per-conv | ✅ 前端per-conv | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
|
||||
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
|
||||
| **父⑥** Phase2-5 | 🔨 Phase2-3✅ | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31 project_services+D10凭证审查+IPC+AI工具,基线41) / ⑥.3注入(依赖全就绪) / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 |
|
||||
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
|
||||
|
||||
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
|
||||
|
||||
### 协同点(跨主题复用)
|
||||
1. **IDEA-FIX-07 关联单向 ↔ 知识图谱 `task_links`**:并入 Phase 1 用独立表(§2.2 论证 JSON 缺陷),别补 JSON 双向同步
|
||||
2. **G1 system_prompt 注入基建 ↔ Phase 4 L0 注入**:复用 `pinned_goal` 拼接链路
|
||||
3. **F-09 per-conv ↔ Phase 4 多会话注入**:F-09 前置
|
||||
4. **REFACTOR-260619-04 审批状态机 ↔ `queue=decision` 待决策池**:审批对齐
|
||||
|
||||
### 待决策
|
||||
| 决策点 | 选项 |
|
||||
|---|---|
|
||||
| 多工程合并 | 方案 A 合并 V28(前提:确认子1 表设计,find 未找到独立文档)/ 知识图谱独立 Phase 1 |
|
||||
| conditions 启用 | 启用求值 / 保留观察 |
|
||||
| BUG-260620-05 层2 | workspace_root 外默认放行(黑名单制)?— 详见 `## 🔴 2026-06-20 BUG-260620-05` |
|
||||
|
||||
---
|
||||
|
||||
## 交接状态(2026-06-14)
|
||||
|
||||
**代码健康度**:`cargo test --workspace` 全过、`npx vue-tsc --noEmit` 0 error(主代理独立验证,非 mission 自报)。
|
||||
@@ -40,10 +111,12 @@
|
||||
- [x] TD-260621-02 [P1·归 F-09 B 路线 per-conv state] ✅(批2-A 落地·pendingMaxRounds ref<string|null> 挂起 convId + 守卫比对 activeConversationId·DirAuthDialog visibleDirAuths filter conversationId·2026-06-21;🟡D pendingDirAuths 全清→per-conv filter 由 wqnd4axf8 补) — **pendingDirAuth/pendingMaxRounds 全局单 ref 非 per-conv**。`useAiEvents.ts:99/113` 模块级单实例;DirAuthDialog:60/MaxRoundsCard:54 守卫仅判 isViewingGenerating 未比对 conversationId(pendingMaxRounds 纯 boolean 无 convId 字段)。真并发下挂起弹窗错显于非挂起会话。修:pendingMaxRounds 改 ref<string|null>(convId)+ DirAuth 守卫加 convId 比对。归 F-09 B 路线。
|
||||
- [x] TD-260621-03 [P1·patch_file 落地债 F-260615] ✅(读改写整体移入 _patch_guard 锁内串行化防 lost update + 删 entry().or_insert() 内存泄漏 + drop 写序列后释放·2026-06-21) — **patch_file 读改写在 FILE_LOCKS 外,lost update**。`tool_registry.rs:1247` 指纹校验(1176)+old_text CAS(1209)+new_content 计算(1197-1240)全在无锁纯读段;FILE_LOCKS(1242-1269)仅序列化 backup→tmp→rename 不防读写交错。两并发 patch 同文件:B 用基于旧内容算的 new_content 覆盖 A。write_file 不取 FILE_LOCKS 同根因。修:读+校验+new_content 整体移入锁内,或 per-path Mutex。
|
||||
- [x] TD-260621-04 [P1·patch_file 落地债 F-260615] ✅(read_file 三返回点返 file_hash 闭环 + 抽 compute_file_hash helper read_file/patch_file 共用消格式漂移·2026-06-21) — **expected_hash 契约破裂,指纹防并发形同虚设**。`tool_registry.rs:1112` schema 描述"read_file 返回的 file_hash"但 read_file handler(892-976)从不返回该字段。expected_hash 实由 patch_file 内部 format!("{}_{}",modified_secs,len)(1180)即造即比,LLM 拿不到前置值只能瞎传/省略→常 mismatch bail 或被跳过。L3 防行号漂移设计在实际 LLM 调用链落空。修:read_file 返回 file_hash 同格式闭环回传。
|
||||
- [ ] TD-260621-05 [P1·归 SMELL-P1-6] — **审批状态字符串双轨**。chat.rs:466/668 写 "executed" vs audit/mod.rs:490/545 + cache.rs:113 写 "completed",DTO 文档(models.rs:221)只列 completed。审计表混存两种"成功"态。归 SMELL-P1-6(String→enum)统一,行为变更需评估 AuditLog 显示 + find_cached 透传。
|
||||
- [x] ✅(2026-06-24·ba811ea chat.rs executed→completed 6处:460幂等读/584 ai_approve写/634判定/668 IPC返回/885 authorize_dir日志/889 authorize_dir写 + df-storage V27 迁移 UPDATE 存量 executed→completed + v27 两测试 unify/idempotent·cargo check EXIT 0 + df-storage 78 passed) **TD-260621-05** [P1·归 SMELL-P1-6] — **审批状态字符串双轨**(已统一)。核验全绿:前端无硬编码 'executed'(grep 全 src 零命中,AuditLog:84 是 executed_at 字段名/settings:120 是文案)+ 无 WHERE status='completed'/'executed' 统计查询(仅 pending 查询)+ find_cached SW-16 已透传真实值兼容双值 + ai_authorize_dir 无第二处判定。治 AuditLog executed 记录显示错位(蓝pending标签+raw"executed")+ DTO audit/mod.rs:53 契约对齐。**注:仅统一状态值(String 仍 String),SMELL-P1-6 String→newtype/enum 强类型化整体仍待决策(独立项,本修复不涉)**。
|
||||
|
||||
**P2 精选(25 项·详单见报告)**:try_continue pending_conv_id 跨会话泄漏(agentic:1314)/ patch_file FILE_LOCKS 永不清理内存泄漏(tool_registry:1248)/ auto-compress 成功未即时 save(agentic:778)/ MidStream 保文 chunk.error 未回填 err(stream_recv:275)/ audit_tool_call `let _ =` 吞 insert 错(finalize:35)/ switch+delete retain 未配套 finalize 占位终态化(conversation:317)/ PerConvState.created_at 全死字段(mod:520)/ PendingApproval.diff 写而不读(mod:570)。
|
||||
|
||||
**[2026-06-24 核验销账]**:audit_tool_call 吞错 ✅已修(commit a7dbd50,finalize:35 `let _ =`→match Err tracing::error,对齐 audit_finalize)+ switch+delete retain 占位终态化 ✅已修(a7dbd50,conversation/chat finalize_pending_placeholders pub(crate))+ patch_file FILE_LOCKS 永不清理 ✅已修(TD-260621-03 06-21,_patch_guard 串行化 + 删 entry().or_insert())+ PerConvState.created_at ✅已是正确态(mod.rs:772 #[allow(dead_code)]+预留注释「待 upsert 接入会话创建时间」)+ PendingApproval.diff ✅已是正确态(阶段3a 下沉 enum ApprovalKind::Risk{diff} 活调用链 audit/remote_bridge/useAiEvents,误删断前端 diff 预览);剩 3 项登记不动 —— try_continue pending_conv_id 🟡语义复杂(R-PD-6 优先审批 conv_id 设计+try_continue 回退,非确定性低风险)/ MidStream chunk.error 🟡UX 结构变更(Partial 保文加 err 字段+前端渲染,非 bug)/ auto-compress save 🟡需理解 loop save 时机判真缺口。
|
||||
|
||||
**销账核对(已登记 8 复核)**:SMELL-P1-6(line221·#5 覆盖 executed/completed 双轨)/ MED-1(tool_registry:546 bind_directory)/ REFACTOR-260619-08(tool_registry:881 拆)/ REFACTOR-260619-09(context.rs:1 God 1552行)/ ARC-260619-05(agentic:318 超长)/ UX-260617-28(双监听器 INFO)。**均仍在未完成,审查复核确认,保留**。
|
||||
|
||||
**残留诊断代码(待清)**:chat.rs `authz_debug`/`authz-debug.log`(L2 调试遗留,P2 报 chat.rs:548 硬编码绝对路径),L2 完成 + 用户实测后可清。
|
||||
@@ -440,7 +513,7 @@
|
||||
|
||||
**P1**
|
||||
|
||||
- [ ] **REFACTOR-260619-04 [P1]** — **ToolCard.vue(1527 行)拆 5 子组件+composable+util**。先 `useToolFormat.ts`(纯函数 ~280,零风险)+ `ToolResultBody.vue`(420 最大块);再 ToolCardHeader/ToolApproval/useToolApproval。风险:折叠态 shouldKeepOpen 三处共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue`
|
||||
- [ ] **REFACTOR-260619-04 [P1·部分已做]** — **ToolCard.vue 拆分**。**2026-06-24 核验**:ToolCard 已从 1527→**428 行**,已抽 `ToolResultBody.vue` + `useToolCardHeader.ts` + `useToolCardRender.ts`(主体瘦身完成);但原计划的 `useToolFormat.ts`/`useToolApproval.ts`/`ToolCardHeader.vue`/`ToolApproval.vue` 未独立(当前 useToolCard/useToolCardHeader/useToolCardRender 三模块)。剩余:审批状态机独立(useToolApproval)+ 头部组件(ToolCardHeader)。风险:折叠态 shouldKeepOpen 共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue`
|
||||
- [x] ✅(2026-06-20 核验已拆分·`agentic.rs` 已删除→`src-tauri/src/commands/ai/agentic/` 目录:mod.rs(83390 字节·run_agentic_loop 主 loop)/guard.rs(2894·GeneratingGuard+ContinueSnapshot 抽出对齐 A+E+F `agentic_runtime`)·stream_recv.rs(独立文件,StreamOutcome+stream_one_provider 对齐 B+C `agentic_stream`)) **REFACTOR-260619-05 [P1]** — **agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**。主 loop 是**单函数 720 行不可按函数拆**(工具执行/审批是 loop 内 if 分支;process_tool_calls 在 audit.rs)。先抽 A+E+F `agentic_runtime.rs`(GeneratingGuard+try_continue_agent_loop+ContinueSnapshot,~195,最高收益最低风险)+ B+C `agentic_stream.rs`(StreamOutcome+stream_one_provider,~165)。**D run_agentic_loop 等 F-09 B 批4 落地再评估**(避免 per_conv 双线作战)。— `src-tauri/src/commands/ai/agentic.rs`
|
||||
- [x] ✅(2026-06-20 核验·已拆:ai_node.rs 423 + ai_self_review_node.rs 447 + ai_helpers.rs 298,测试分块清晰) **REFACTOR-260619-06 [P1]** — **ai_node.rs(1107 行)拆 3 模块**。`ai/{mod,params,ai_node,self_review}.rs`。params.rs(provider 解析 helper ~270)+ ai_node.rs(~130)+ self_review.rs(~300)。测试分块清晰(752/939/1069 三段),低风险。fixture provider_stub/config_with 留 params.rs `pub(super)`。— `crates/df-nodes/src/ai_node.rs`
|
||||
- [x] ✅(2026-06-20 核验已拆分·`src/components/ai/` 子目录已存在子组件:ConversationSidebar.vue + ChatInput.vue + MessageList.vue + TopBar.vue + DirAuthDialog.vue + MaxRoundsCard.vue·AiChat.vue 从 4075 行缩到 750 行·状态/composable 已外移,模板拆分完成) **REFACTOR-260619-07 [P1]** — **(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。方案已定,状态/composable 已外移,拆 template+局部 script。前置:先提交工作区未提交改动(本会话 shouldRenderMsg/scroll/1214 预检等)。— `src/components/AiChat.vue`
|
||||
@@ -579,6 +652,7 @@
|
||||
> **与 MCP Server(F-260619-02)无关**:本需求是 DevFlow 应用内部 AI 工具(read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。
|
||||
|
||||
- [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A 持久白名单 + Phase B 会话临时/弹窗 + Phase C 黑名单 + workspace_root 去固定根完整,CR-09 ✅ + b22e9ae reload 尊重用户 persistent)
|
||||
- **2026-06-24 三档授权演进**:Phase B 弹窗从 once/always 两档 → **once/session/always 三档**(本次单次执行后清 / 当前会话切会话清 / 始终落KV)。修 once 原写 session 致"本次"实为"本会话"名实不符(确定性 bug)。AllowedDirs 加 `once` 层(state.rs)+ ai_authorize_dir decision 改 match 四分支(chat.rs)+ 执行后 clear_once + tracing 审计 + 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval/api/types.ts/i18n 联合类型扩 session。cargo check 0 + vue-tsc 0。审查见待审查 CR-260624-01。
|
||||
|
||||
**核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。
|
||||
|
||||
@@ -945,7 +1019,7 @@
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 · 诊断workflow→论证workflow)
|
||||
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
|
||||
|
||||
> 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。
|
||||
>
|
||||
@@ -959,9 +1033,70 @@
|
||||
>
|
||||
> **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。
|
||||
>
|
||||
> **BUG-260624-03 工具执行心跳误报根治**(用户 b1f644a7「一边 aichat 回复着内容,一边抛"工具已执行完成,后续回复中断"」):
|
||||
> - 根因(论证 workflow 4 维度·7 条源码证据闭环):前端 watchdog 130s 无活跃事件→判断断流→抛错;后端 AiHeartbeat 30s 静默期 emit 让前端 reset,但**心跳只在 `stream_llm`(provider 读 chunk 的 select! 循环,stream_recv.rs:333)内,工具执行在 audit/mod.rs(stream_llm 外)无心跳**。单次工具执行>130s(bash 跑 cargo/测试、read 大文件、全盘 search)→ watchdog 到期→该 conv 仍 generatingConvs→守卫放行→前轮有 completed 工具即命中"工具已执行完成后续中断"文案;前一轮 delta 文本在屏 = "一边流一边抛"。**反复修未根治因都在前端守卫打补丁(B-260615-03 文案/BUG-260623-06 MaxRounds clear/TD-260621-01 残留 timer),没人补工具执行阶段的心跳缺口**。
|
||||
> - 修复(A 根治 + B 兜底,论证质量 A 可发布):
|
||||
> - **A 后端根治**(audit/mod.rs):新增 `execute_with_heartbeat` helper(execute 期间 30s interval emit AiHeartbeat,弃首 tick 对齐 stream_recv:174;**RAII HeartbeatGuard** drop 自动 stop+abort,panic unwind 也不泄漏 task),替换 trust_hits/low_risk 两处 `tools.execute()`。补工具执行阶段心跳缺口。
|
||||
> - **B 前端兜底**(useAiStream onStreamTimeout):stillGenerating 守卫后扫 messages running 工具卡→resetStreamWatchdog 续等不抛错(防心跳丢失/老后端/审批后路径误杀)。审批路径(chat.rs:575/877)非盲区——已包 60s timeout<130s watchdog 互补治理(论证勘误维度3 误判)。
|
||||
> - 验证:cargo check exit 0(RAII 编译过)+ vue-tsc 0。B 维度因网络错未跑,已自核验 ToolCard approving 计时器仅复位按钮 loading 非 running(running 由后端 AiToolCallCompleted 驱动),真死锁续等优于误报(用户可 stop),A 心跳覆盖正常执行。
|
||||
>
|
||||
> **BUG-260624-04 搜索工具误授权根治**(用户 b1f644a7「搜索代码也要授权,检查这样的会话搜索为什么需要授权」):
|
||||
> - 根因:`grep` 工具被标 `RiskLevel::Medium`(tool_registry.rs:1682),而同类只读 `read_file`/`search_files`/`read_symbol` 均 Low。注释 line 1661 "risk:Med(读文件内容,授权目录内放行/外申请)" 把"读内容需 path_auth 授权"误塞 risk_level —— path_auth 是独立安全层(read_file Low 也走),与 risk_level(只读/写/删)混淆。默认 low 模式下 Medium 需审批 → 每次搜索弹审批。叠加 `tool_display_hint`(tool_registry:2116)无 grep 条目 → reason.rs:158 fallback default `"创建操作,请确认是否执行"` → 只读搜索显示"创建操作"误导。
|
||||
> - 修复:`grep` Medium→**Low**(对齐只读同类)+ tool_display_hint 加 `"grep" => Some("搜索代码内容")`(文案准确)+ 注释更正。cargo check exit 0。
|
||||
> - 全量核对:28 工具 risk_level 分级,**除 `restore_project`: High 过保守(恢复非破坏,对齐 create/update 该 Medium;但 High 安全方向不算错,未改)** 外全合理。
|
||||
>
|
||||
> **BUG-260624-05 压缩后每轮停止根治**(用户「上下文压缩后,后续所有对话都提示已压缩然后停止」):
|
||||
> - 根因(诊断 workflow 4 维度·DB 铁证 top.1216.devflow 库 565 compressed/4246 active,ead0f7e5 seq=0 是 active 兜底摘要,12 会话前7条有 active system):
|
||||
> - **#1(主因·无限循环)**:压缩摘要 `insert_at(0, ChatMessage::system(summary))`(agentic/mod.rs:923)→ status=None → is_active=true,落在可压缩区 [0..protect_start) → 每轮 `has_compressible_messages` 恒 true → 无限循环压缩。topic-switch system(DB 3eafdb85 三条堆 seq 0/1/2)同理。
|
||||
> - **#2(次因·前端停止)**:压缩失败 emit AiError(useAiEvents:648 无条件 setStreaming(false))→ 前端误判生成结束(后端 loop 实际继续)。
|
||||
> - 修复(P0 根治循环 + P1 前端停止):
|
||||
> - **P0**(context.rs:803 `has_compressible_messages` 排除 system 角色,用 `!matches!(role, System)` 因 MessageRole 无 derive PartialEq):摘要/话题锚点是上下文锚点非压缩目标,排除后不再因 system 触发。compress_old_messages 不改(被调用时仍标旧 system 摘要 compressed 被新摘要替代,防堆积)。DB 自愈:排除 system 不限 status,旧 active 摘要也被排除,**无需 migration**。
|
||||
> - **P1**(agentic/mod.rs:985 压缩失败不发 AiError):前端 AiError 无条件停流,降级 warn + build_for_request 裁剪兜底,loop 继续 stream_llm。
|
||||
> - **纠正 workflow 方案 A 矛盾**:workflow 建议 is_active 排除 summarized,但会致 build_for_request 不送摘要进 LLM 丢历史;改用 has_compressible 排除 system(摘要仍 active 进 LLM)。
|
||||
>
|
||||
> **BUG-260625-01 grep 传单文件路径报「目录名称无效」根治**(用户「grep path 需要传目录不能传单文件·怎么总是失败」):
|
||||
> - 根因(DB 铁证 ai_tool_executions fb549a0b):grep handler 直接 `grep_recursive(root)` → 内部 `read_dir(root)`,root 是单文件时报 os error 267「目录名称无效」。grep 命令行原生支持单文件,LLM 常传文件路径(64 次 grep 调用多次传文件),工具只认目录 →「总是失败」。
|
||||
> - 修复(tool_registry.rs):提取 `grep_one_file`(从 grep_recursive 循环体搬移,continue→return Ok),grep_recursive 改调它(DRY 复用),handler 判 `root_meta.is_file()` → 单文件走 grep_one_file,is_dir → 递归。描述加「path 文件或目录均可」。
|
||||
> - 验证:cargo test grep **13 passed**(9 个 grep_recursive 测试全过 = 提取零回归)+ audit 3 grep 测试。
|
||||
>
|
||||
> **BUG-260625-02 read_file search 模式显示「0 行」根治**(用户「读取 MessageList.vue 0 行·65.2KB·怎么总是失败」):
|
||||
> - 根因(DB 铁证 ai_tool_executions cad747e9):read_file 带 `search` 参数返 `{matches:[{line,content}],total,has_more}` **无 content/lines/returned_lines**。ToolResultBody.vue read_file 分支只认 content/lines → 显示「0 行 · 65.2KB」+ 空 content 区。LLM 拿到 matches 能继续,但**用户看 UI 以为读取失败/空**。
|
||||
> - 修复(ToolResultBody.vue + useToolCard.ts):read_file 分支识别 search 模式(parsed.search),header 显「『search』 命中 N 处」(复用 grepHitsN)+ 命中行渲染(复用 grep content 样式,read_file matches 仅 {line,content} 无 file);ToolResult 加 `search?: string` 字段。
|
||||
> - 验证:vue-tsc EXIT 0。
|
||||
> - 附:state.rs/tool_registry.rs test 的 AllowedDirs once 字段初始化是工作区被改掉的预存状态,本次恢复到 HEAD(once 功能早已提交),**未引入新改动**。
|
||||
>
|
||||
> **剩余低优 [ ](非阻断)**:
|
||||
> - [ ] BUG-260624-01 残留:scrollToFirstPending 去抖(同 toolCallId 不重复 scroll,防多 pending 连发滚动打断)+ 全实例广播过滤(props.toolCalls.some 跳过无 pending 实例,大列表性能)
|
||||
> - [x] ✅(2026-06-24·ebba973) BUG-260624-01 残留:① MessageList:503 scrollToFirstPending return 过早修复(遍历全实例调,各内部 querySelector 无 pending 则 no-op,治 pending 落后续实例滚不到)+ ② ToolCardList listener 加 props.toolCalls.some(pending) 过滤短路(降大列表 N 实例×M emit 的 DOM 查询)。**scrollToFirstPending 时间窗去抖评估后不做**:同 id 连发被上游守卫拦截(AiDirAuthRequired/AiApprovalRequired useAiEvents:359/472 some 去重 + AiToolCallStarted:427 findToolCall 守卫),不同 id 连发是多 pending 合理场景每次滚到首个合理,vue-tsc EXIT 0。
|
||||
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到)
|
||||
> - [ ] MessageList:503 badge scrollToFirstPending return 过早(既有 bug,遍历实例应找含卡的那个,不首个 return)
|
||||
> - [x] ✅(2026-06-24·ebba973) MessageList:503 scrollToFirstPending return 过早已修(遍历全实例调,各内部 querySelector 无 pending 则 no-op,治 pending 落后续实例滚不到)
|
||||
> - [ ] F-09 根因:streaming/currentText 全局单例改 per-conv(根治多会话串扰,见 docs/待决策.md)
|
||||
> - [x] ✅(2026-06-24 核验伪问题销账) CR-260622-01-P2-1 — **ChatMessage.timestamp 打戳"致时序错乱"核验为伪**。grep 全 src `.sort(` 零消息按 timestamp 排序:ai.ts:188 会话列表 updated_at / ConversationSidebar pinned / TopBar weight / MessageList:231 mention span start。**消息渲染顺序由 renderItems(store.state.messages 数组顺序)= DB list_by_conversation ORDER BY seq,不依赖 ChatMessage.timestamp**。timestamp 仅 formatRelative 展示(system 摘要显示插入时刻=压缩时刻,语义合理)。agent A 假设"前端按 timestamp 排序"不成立,非真实缺陷。
|
||||
> - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2026-06-25 generating 状态机双轨收口 + 自动压缩路径残留修复
|
||||
|
||||
- [x] ✅(2026-06-25) **Task#1 自动压缩成功路径残留**(BUG-260624-05 衍生)— **AiCompressed 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话**。治法:`AiCompressed` 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 isCompressing 防按钮卡死,miniapp 仍插摘要气泡)。按语义拆事件而非按字段。— `src-tauri/src/commands/ai/mod.rs:207/212`
|
||||
- [x] ✅(2026-06-25) **Task#2 双轨状态机收口** — `generating: bool` 字段(顶层单例 + PerConvState)与 `CONV_STATE_ENABLED` 灰度开关双轨全部删除,`ConvState` enum(`conv_state.rs`,5 态)成唯一真相源:写侧经 `transition_to` 守卫(非法转换拒绝),`GeneratingGuard` new/reset/drop 无条件迁移 + emit;读侧 `can_accept_request()` 接入 chat 域 4 入口(ai_chat_send/ai_regenerate/ai_chat_edit/ai_is_generating),比 `is_active()` 更严谨(覆盖 Stopping 态竞态)。开关常量已删,代码内 `CONV_STATE_ENABLED off 回退分支` 注释为说明性遗留(实际不可触发,待清理)。详案见 [generating状态机加固-2026-06-15.md §收口记录](./02-架构设计/专项设计/generating状态机加固-2026-06-15.md)。— `src-tauri/src/commands/ai/agentic/{conv_state.rs,guard.rs}` + `commands/ai/{mod.rs,commands/chat.rs}`
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🔍 2026-06-26 灵感模块诊断待办(合并推进·单任务)
|
||||
|
||||
> 全面审查灵感模块(df-storage / df-ideas / commands/idea.rs / Ideas.vue / IdeaDetail.vue)后发现的 P0-P2 问题。
|
||||
> 排除项:~~#3 status 无状态机~~(暂保留状态可跳跃迁移,不处理)。
|
||||
> 合并为单个任务推进,不拆子任务。
|
||||
|
||||
- [ ] **IDEA-FIX-01 [P0🔴]** — **ideas 表无软删除**。`delete_idea` 是硬删除(直接 `delete()`),无 `deleted_at` 列、无 restore API。误删不可恢复。需对标 tasks/projects 的软删除全套模板(`deleted_at` 列 + V28 迁移 + `list_active` 过滤 + restore IPC)。— `commands/idea.rs:104` + `df-storage/src/crud/idea_repo.rs` + `migrations.rs`
|
||||
- [ ] **IDEA-FIX-02 [P0🔴]** — **ideas 白名单含 id/created_at**。`settings.rs:122` ideas 白名单包含 `id`、`created_at`,可通过 `update_idea` 篡改主键或创建时间。需移除(对标 B-260616-16 tasks 白名单修复)。— `commands/settings.rs:122`
|
||||
- [ ] **IDEA-FIX-03 [P0🔴]** — **priority 无值域校验**。`create_idea` / `update_idea` 不校验 priority 范围,传入 -5 或 999 静默降级为 Medium。需加 `parse::<i32>() ∈ 0..=3` 校验(对标 B-260615-15 tasks priority 修复)。— `commands/idea.rs:73,88`
|
||||
- [ ] **IDEA-FIX-04 [P1🟡]** — **record_to_idea 有损转换**。`record_to_idea()` 硬编码 `status=Draft`(丢弃 DB 真实 status)+ `related_ids=空 Vec`(丢弃已保存关联)。评估时上下文不完整。需从 IdeaRecord 读真实值。— `commands/idea.rs:298-312`
|
||||
- [ ] **IDEA-FIX-05 [P1🟡]** — **评分关键词硬编码**。`scoring.rs` 关键词全写死中文("复用"/"重构"/"迁移"…),不可配置,英文/非技术项目评分失真。需提取为配置文件或 DB 表。— `df-ideas/src/scoring.rs`
|
||||
- [ ] **IDEA-FIX-06 [P1🟡]** — **promote 补偿用 purge(不可恢复)**。`promote_idea` 回写失败时 `purge_with_descendants`(永久删除项目)。应改为 `delete`(软删除,可恢复)。— `commands/idea.rs:179`
|
||||
- [ ] **IDEA-FIX-07 [P1🟡]** — **关联单向**。A 关联 B 只写 A 的 `related_ids`,B 不自动补 A。需双向同步(关联时补入对方,解绑时同步移除)。— `commands/idea.rs` + 前端 `IdeaDetail.vue`
|
||||
- [ ] **IDEA-FIX-08 [P1🟡]** — **创建表单无 tags 输入**。后端 `CreateIdeaInput` 支持 tags,但前端捕捉表单(Ideas.vue capture modal)只有 title + description,tags 恒为 None。需加 tags 输入(逗号分隔 → JSON 数组)。— `Ideas.vue:108-119` + `confirmCapture()`
|
||||
- [ ] **IDEA-FIX-09 [P2🟠]** — **创建表单缺 priority/source**。捕捉模态框无 priority 下拉和 source 输入。需补全。— `Ideas.vue:108-119`
|
||||
- [ ] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。`hot`(score≥80)和 `pending`(多状态 OR)在前端 filter,但后端已分页返回子集,可能漏掉符合条件的灵感。需下沉后端 IdeaQuery。— `Ideas.vue:221-226`
|
||||
- [ ] **IDEA-FIX-11 [P2🟠]** — **假雷达图**。i18n key 和 CSS class 叫 "radar",但实际是水平进度条。需正名为 "score-bar" 或引入真正的雷达图组件。— `IdeaDetail.vue` + `i18n/zh-CN/ideas.ts` + `i18n/en/ideas.ts`
|
||||
|
||||
+57
@@ -24,6 +24,61 @@
|
||||
---
|
||||
|
||||
## 当前队列
|
||||
|
||||
### ✅ 2026-06-26 任务图谱推进待决策(已按推荐方向全部决策)
|
||||
|
||||
> 来源:用户 2026-06-26 多角度论证(基于代码实况) + 确认「按推荐方向决策」,7 DEC + 3 专题全部拍板。
|
||||
> **关键修正**:DEC-01 V28 已被灵感软删除(`ideas.deleted_at`,commit 005079f)占用 → 知识图谱用 **V29**;DEC-03 推荐由 a 修正为 **c**(启发式是过渡,接 LLM 评分后按需升 a)。
|
||||
|
||||
#### ✅ DEC-260626-01 父② 知识图谱 Phase 1 多工程合并
|
||||
- **决策**:**b** — 知识图谱独立 Phase 1(**V29**,V28 已被灵感软删除占用)。多工程代码零行(grep 全空),合并无理由,多工程后续 V30+。
|
||||
- **关联**:todo 父②
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-02 ⑤.2 #7 promote 补偿删除方式
|
||||
- **决策**:**b** — 保留 purge。补偿是内部回滚建错的 project(非用户删除),purge 干净不污染回收站。
|
||||
- **关联**:todo ⑤.2 #7(标"保留 purge 不改")
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-03 ⑤.2 #6 评分关键词配置形式
|
||||
- **决策**:**c** — 先拆 const 到独立文件(中/英两套,最小改动)。原推荐 a 修正:启发式是过渡(scoring.rs TODO 接 LLM 语义评分),接 LLM 后按需升 a(JSON 文件)。
|
||||
- **关联**:todo ⑤.2 #6
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-04 ⑤.2 #9/#10 表单补全交互
|
||||
- **决策**:**a** — tags 逗号分隔输入框(对标 Knowledge.vue 先例)+ priority 下拉 + source 输入。chip 组件留 UX 升级。
|
||||
- **关联**:todo ⑤.2 #9/#10
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-05 BUG-260620-05 层2 授权政策
|
||||
- **决策**:**a** — 维持方案①(白名单+弹窗三档)。层1(工程内免授权,F-260619-03 方案①)已解痛点,层2 黑名单制安全风险 + 分发冲突。
|
||||
- **关联**:todo BUG-260620-05 层2(标"维持①不做")
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-06 父③ ToolCard 跨轮合并 + 审批状态机排期
|
||||
- **决策**:**b** — 等 G1 实测 + 父② 后再做。审批是高危路径,AiChat.vue 仍在频繁改动,择稳定窗口专项。
|
||||
- **关联**:todo 父③(排父②后)
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ DEC-260626-07 父④ F-09 per-conv 真多会话排期
|
||||
- **决策**:**a** — 父② Phase 1 后启动。F-09 是 Phase 4 前置,大改不与父② 并行(回归交叉)。
|
||||
- **关联**:todo 父④(排父②后)
|
||||
- **状态**:✅ 已决(2026-06-26)
|
||||
|
||||
#### ✅ 专题-1 workspace_root 分发适配
|
||||
- **决策**:**① 先行**(去默认白名单,dev 自用 projects.path 自动授权够),**分发前定②**(去相对锚)。
|
||||
- **状态**:✅ 已决(2026-06-26,① 立即/② 分发前)
|
||||
|
||||
#### ✅ 专题-2 BUG-260623-03 审批 pending 超时兜底
|
||||
- **决策**:**c 无超时**(不做超时兜底)。用户:等待审批就是等待,一直等待,超时概念多余。审批是用户主动行为,不审批自然挂起等待,无需超时强制 reject/倒计时。
|
||||
- **状态**:✅ 已决(2026-06-26,不做超时,关闭 BUG-260623-03)
|
||||
|
||||
#### ✅ 专题-3 F-09 streaming/currentText per-conv
|
||||
- **决策**:**暂缓**(随 B 路线多会话并发同批做,即 DEC-07 父④)。
|
||||
- **状态**:✅ 已决(2026-06-26,归父④)
|
||||
|
||||
---
|
||||
|
||||
#### T-260614-11 条件表达式引擎升级
|
||||
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
|
||||
- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
|
||||
@@ -296,6 +351,8 @@
|
||||
|
||||
**关联**:F-260619-03 去固定根(已删 is_authorized 硬编码放行)/ projects.path 自动授权(reload_allowed_dirs)/ memory [[devflow-project-path-binding]]
|
||||
**状态**:🟡 待决策(workspace_root 运行期动态方案 a/b/c/d)。**分发前必须解决**(否则分发后 AI 文件工具全部失效)。
|
||||
**注**:授权弹窗侧三档(once 本次单次 / session 当前会话 / always 始终)已于 2026-06-24 落地(F-260619-03 Phase B 演进,AllowedDirs 加 once 层 + 弹窗加"当前会话"按钮,详见 todo + 待审查 CR-260624-01)——**授权机制侧就绪**。本节待决策的是**相对路径锚点**(固定编译期 root vs 活跃项目),非授权机制。
|
||||
**2026-06-25 弱化决策(用户)**:方向**去掉"活跃项目/根据地"概念抽象**——active_workspace 全局缓存动态锚曾试加(state.rs OnceLock+SyncRwLock + reload_active_workspace KV active_project_id)后撤回,用户明确不要此层。workspace_root 变量**暂留**(不删,resolve 相对锚/trust_key/run_command working_dir 仍引用),**弱化其默认依赖**。实质方案二选一分发前定:① 去默认白名单(default_with_root 不硬塞 workspace_root,工程根靠 projects.path 自动授权)② 去相对锚(AI 改绝对路径,相对路径不 join)。当前:逻辑不动,变量保留。
|
||||
|
||||
**2026-06-23 进展(BUG-260623-01)**:实测会话 6acb7f9b 暴露 write_file/append_file/rename_file 父目录校验**误用 workspace_root**(tool_registry.rs:1112/1418/1555 原用 `starts_with(workspace_root())`)→ 用户授权的其他项目目录(ai-news 等)parent 不 starts_with devflow 源码目录 → 误拒「禁止在项目目录之外创建目录」(用户授权了仍拒)。**已修**:三处改用 `allowed_dirs.is_authorized(parent)` 脱离 workspace_root。**仍写死残留**(待本决策 a/b/c/d 根治):delete_file:1482 trash 目录 / resolve_workspace_path_impl:380 相对路径 join / run_command:1791 working_dir 默认。
|
||||
|
||||
|
||||
+24
-1
@@ -23,7 +23,19 @@
|
||||
|
||||
## 当前队列
|
||||
|
||||
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— 🟡 待审
|
||||
### CR-260624-01 AI 路径授权三档化(once/session/always) — ✅ 已审(PASS·单 agent + 6-agent 对抗深审 0 finding)
|
||||
|
||||
- **范围**:AllowedDirs 加 `once` 层(state.rs:444)+ ai_authorize_dir decision 改 match 四分支 once/session/always(chat.rs:846)+ 执行后 clear_once(chat.rs:924)+ 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval.ts/api/ai.ts/api/types.ts decision 联合类型扩 'session' + i18n zh/en 加 dirAuthSession。
|
||||
- **维度**:
|
||||
- (a) **once 清空时机**:clear_once 在 execute 后无条件清(state.rs:924 附近 chat.rs)。竞态:同 once 路径并发工具调用?现状工具串行(per_conv 单 generating),无并发,OK——但核验确认。
|
||||
- (b) **session vs once 语义边界**:session 切会话清(conversation.rs clear_session)/ once 执行后清 / reload_allowed_dirs 保留 both(state.rs:810 take once + session)。核 reload 是否漏保留 once。
|
||||
- (c) **is_authorized 读 once**:state.rs is_authorized 三层 `persistent || session || once` 任一命中。核 once 命中分支存在。
|
||||
- (d) **前端全链路**:DirAuthDialog handleAuthorize / useAiApproval approveToolCall / api.authorizeDir 三处 decision 联合类型均含 'session',types.ts decision: string 不受限(无类型保护,但 IPC 接受任意字符串,match _ 兜底)。
|
||||
- **核验方式**:grep `once|session` 贯穿 state.rs/chat.rs/前端 + 核 clear_once 调用点 + deny 分支(早 return,once 未写,不需清,核确认)。
|
||||
- **关注点**:批量 approve 仍用 once(单卡语义,不批量 always,useAiApproval:110);always 降级写 session(原降级 add_session,语义对齐)。
|
||||
- **审查结果**(2026-06-25):单 agent 审查(a2b2b35,6 维度全过)+ workflow 对抗深审(w9waeyg36,6 agent 并行 review + 每条 finding 独立 verify,**0 confirmed finding**)。once 清空时机(串行无并发误清)/ session-once 边界 / is_authorized 三层读 / deny 不触发 clear_once / 前端类型一致 全过。
|
||||
|
||||
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— ✅ 已审(ISSUES·低优残留2项·P0清零)
|
||||
|
||||
- **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。
|
||||
- **维度**:(a) 独立核验 db 实测数据(token 比/重复 tool 结果/system 污染/时序);(b) 核验源码根因(报告 finding 均为"假设"待证)。
|
||||
@@ -42,6 +54,17 @@
|
||||
|
||||
---
|
||||
|
||||
**复审结论(2026-06-24·3 agent 独立 grep/read 核验当前源码形态,防上下文污染)**: ✅ **ISSUES(低优残留,P0 清零可归档)**
|
||||
|
||||
- **P0-1/P0-2/P0-3 已销账**(报告 06-23/06-24 修正段:压缩 is_active 白名单隐式修复 / truncate 破坏 JSON 修 / 重复 tool_result 实测 tool_call_id 全不同证伪)
|
||||
- **P1-2 末尾中断 🟡缓解**:`guard.rs` GeneratingGuard RAII(正常 reset + Drop 兜底)+ ConvState 5态 enum 状态机已系统性覆盖"generating 散布复位 + 无 panic 兜底"原根因;报告 06-23 修正推翻原假设(84dc36c8 seq85 后疑 max_iterations 收敛/自然停,非 generating 卡死)。归 BUG-260623-03 审批 pending 无超时(待决策,推荐前端倒计时)。
|
||||
- **P2-1 system 时序 🟡部分缓解**:DB created_at 全消息统一 conv_created_at 不乱;但 `ChatMessage.timestamp`(provider.rs:44 system() 用插入时刻)system 摘要打戳不可靠 → 前端排序应弃 timestamp 改 seq/created_at。role 污染数据级偶发(压缩摘要 LLM 输出含 assistant 语义,非结构 bug)。
|
||||
- **P2-2 非代码文件缓存 🔴仍存**:read_symbol(7c2e3b2/7f6aa1e)治 .rs/.ts/.vue 代码文件(降 24.4x);HTML/markdown/JSON 等非代码文件无 session 级缓存,LLM 仍 read_file 全文回灌。
|
||||
|
||||
**待修项回流 todo**:🟡 2 项低优(CR-260622-01-P2-1 前端排序弃 timestamp / CR-260622-01-P2-2 非代码文件 session 缓存,归 B 路线 prompt 策略或工具侧缓存)。
|
||||
|
||||
---
|
||||
|
||||
### CR-260620-04 Agent 最大轮次支持「不限」(working tree 未提交:0=不限 约定·config.rs clamp 0-50 + mod.rs effective_max 0→usize::MAX + GeneralPanel checkbox + App.vue clamp + i18n labelUnlimited) — ✅ 已审(PASS·⚪2 WATCH·巡检 2026-06-20 独立 grep/read 核验控制流)
|
||||
|
||||
- **范围**:新功能。约定 0=不限:后端 config.rs:69 clamp(1,50)→(0,50);mod.rs:542 loop 上界算 effective_max(0→usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出,达上限暂停 AiMaxRoundsReached 不触发);前端 GeneralPanel 加「不限」checkbox(绑 agentMaxIterations===0)+ input :disabled + clamp 支持 0 + computed unlimited;App.vue:107 clamp 跟进(iter===0?0:...);i18n zh/en desc 加 0=不限 + labelUnlimited。
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"build:dev": "cd src-tauri && cargo build"
|
||||
"build:dev": "tauri build --debug --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
|
||||
@@ -66,6 +66,10 @@ tree-sitter = "0.25"
|
||||
tree-sitter-rust = "0.24.2"
|
||||
tree-sitter-typescript = "0.23.2"
|
||||
tree-sitter-javascript = "0.25.0"
|
||||
# Phase1 扩展:Go / Java / Python(静态编译,表驱动加表项,零架构改动)
|
||||
tree-sitter-go = "0.23"
|
||||
tree-sitter-java = "0.23"
|
||||
tree-sitter-python = "0.23"
|
||||
|
||||
# 阶段3a/3b 统一审批模型开关(path_auth 审批链扎实重构 plan 阶段3)。
|
||||
# 3a 后端合并(单 HashMap + kind 字段)为编译期结构性变更,本 flag 用于文档标记 +
|
||||
|
||||
@@ -15,33 +15,26 @@
|
||||
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
|
||||
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
|
||||
//!
|
||||
//! # 渐进第一步(本批范围)
|
||||
//! # 收口后(批3+)
|
||||
//!
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等
|
||||
//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState`
|
||||
//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html)
|
||||
//! 默认 on,off 降级纯旧 bool 行为(可回退)。
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool
|
||||
//! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点
|
||||
//! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。
|
||||
//!
|
||||
//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到
|
||||
//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。
|
||||
//! 散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)已逐个迁到读
|
||||
//! `ConvState`(`is_active()` / `can_accept_request()`),不再回退旧 bool。
|
||||
//!
|
||||
//! # 与目标钉扎(G1)的衔接(2026-06-26)
|
||||
//!
|
||||
//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边);
|
||||
//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`),
|
||||
//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成
|
||||
//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。
|
||||
//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`,
|
||||
//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 开关
|
||||
// ============================================================
|
||||
|
||||
/// L2 状态机总开关(默认 on)。
|
||||
///
|
||||
/// - **on(默认)**:`GeneratingGuard` set/reset 时同步迁移 [`ConvState`](enum.ConvState.html)
|
||||
/// 作视图层,前后端可读统一状态;guard 失败兜底仍走旧 bool 复位(`generating=false`),
|
||||
/// 不影响语义(状态机层失败不阻断核心复位)。
|
||||
/// - **off(降级)**:guard 不写 `ConvState`,纯旧 `generating` bool 行为(排障 / 对比 / 临时
|
||||
/// 回退用)。设为 false 即可观察无状态机接入的效果,语义等价改动前。
|
||||
///
|
||||
/// 机制优先 prompt 说教:每改进配开关 + 兜底,分阶段可回退(对齐 AI 改进方案三原则)。
|
||||
pub const CONV_STATE_ENABLED: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// ConvState enum
|
||||
// ============================================================
|
||||
@@ -169,8 +162,8 @@ impl ConvState {
|
||||
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
|
||||
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。
|
||||
#[allow(dead_code)]
|
||||
/// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send /
|
||||
/// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。
|
||||
pub fn can_accept_request(self) -> bool {
|
||||
matches!(self, ConvState::Idle | ConvState::Error)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::ai::{AiChatEvent, AiSession};
|
||||
|
||||
use super::conv_state::{self, ConvState};
|
||||
// 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
||||
///
|
||||
@@ -39,14 +40,15 @@ use super::conv_state::{self, ConvState};
|
||||
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
|
||||
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
|
||||
///
|
||||
/// 开关 `CONV_STATE_ENABLED`(默认 on)门控:off 时跳过 enum 迁移与 emit,纯旧 bool 行为(可回退)。
|
||||
/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit
|
||||
/// 无条件执行(单一真相源,无 off 降级分支)。
|
||||
pub(super) struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
|
||||
conv_id: String,
|
||||
done: bool,
|
||||
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
|
||||
/// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。
|
||||
/// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
@@ -59,71 +61,68 @@ impl GeneratingGuard {
|
||||
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
|
||||
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
|
||||
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
let session = session.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
let cid = conv_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let new_state = {
|
||||
let mut session = session.lock().await;
|
||||
let conv = session.conv(&cid);
|
||||
match conv.conv_state.transition_to(ConvState::Generating) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
ns
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。
|
||||
// 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。
|
||||
let session_for_spawn = session.clone();
|
||||
let app_handle_for_spawn = app_handle.clone();
|
||||
let cid = conv_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let new_state = {
|
||||
let mut session = session_for_spawn.lock().await;
|
||||
let conv = session.conv(&cid);
|
||||
match conv.conv_state.transition_to(ConvState::Generating) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
ns
|
||||
}
|
||||
};
|
||||
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: new_state,
|
||||
conversation_id: Some(cid),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: new_state,
|
||||
conversation_id: Some(cid),
|
||||
};
|
||||
let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle_for_spawn.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
Self { session, conv_id, done: false, app_handle }
|
||||
}
|
||||
|
||||
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
|
||||
/// 显式复位生成态。emit 前调用保证顺序。幂等。
|
||||
///
|
||||
/// 仅写 per_conv.conv_id.generating(唯一真相源)。
|
||||
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,CONV_STATE_ENABLED 门控)。
|
||||
/// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。
|
||||
pub(super) async fn reset(&mut self) {
|
||||
if !self.done {
|
||||
let mut session = self.session.lock().await;
|
||||
let conv = session.conv(&self.conv_id);
|
||||
conv.generating = false;
|
||||
// L2 批2:ConvState 持久化(Generating→Idle 写收敛,CONV_STATE_ENABLED 门控)。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
};
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
||||
),
|
||||
// L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
};
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
||||
),
|
||||
}
|
||||
self.done = true;
|
||||
}
|
||||
@@ -142,9 +141,9 @@ impl GeneratingGuard {
|
||||
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
// 未 done 即异常退出(panic/未走正常 return):核心 generating 经 spawn 复位,
|
||||
// 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位,
|
||||
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
|
||||
// 开关 off / done=true(正常 reset/disarm 已走) → 跳过。
|
||||
// done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。
|
||||
if !self.done {
|
||||
let session = self.session.clone();
|
||||
let conv_id = self.conv_id.clone();
|
||||
@@ -154,20 +153,17 @@ impl Drop for GeneratingGuard {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut s = session.lock().await;
|
||||
let conv = s.conv(&conv_id);
|
||||
conv.generating = false;
|
||||
// L2 批2:ConvState 持久化(异常退出兜底→Idle,CONV_STATE_ENABLED 门控)。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
||||
conv.conv_state = ns;
|
||||
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
// L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。
|
||||
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
||||
conv.conv_state = ns;
|
||||
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -188,8 +184,7 @@ mod tests {
|
||||
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。
|
||||
#[test]
|
||||
fn test_guard_new_migrates_to_generating() {
|
||||
// CONV_STATE_ENABLED on → Idle → Generating(读侧持久化字段迁移路径)。
|
||||
assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设");
|
||||
// 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。
|
||||
let mut s = ConvState::Idle;
|
||||
s = s.transition_to(ConvState::Generating).unwrap();
|
||||
assert_eq!(s, ConvState::Generating);
|
||||
|
||||
@@ -48,6 +48,7 @@ use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
|
||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
///
|
||||
@@ -106,6 +107,90 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
|
||||
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
|
||||
pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
||||
//
|
||||
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
|
||||
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
|
||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||
// ============================================================
|
||||
|
||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||
pub const GOAL_PIN_ENABLED: bool = true;
|
||||
|
||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||
///
|
||||
/// true(默认):目标文本前置 `## 当前目标(全程锚定,所有动作须服务于它)` 标题 + 目标内容,
|
||||
/// 显式分隔防与行为准则混淆,提升 LLM 注意力分配。false(裸拼):目标文本直接 append 到
|
||||
/// system_prompt,无标题分隔(紧凑,排障/对比用)。
|
||||
pub const GOAL_INJECT_BANNER: bool = true;
|
||||
|
||||
/// G1 截断长度:目标文本截断上限(默认 500 字符)。
|
||||
///
|
||||
/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。
|
||||
/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版,
|
||||
/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。
|
||||
pub const GOAL_MAX_CHARS: usize = 500;
|
||||
|
||||
/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。
|
||||
///
|
||||
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
|
||||
/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当
|
||||
/// pinned_goal 存在时 take_topic_marker 后丢弃跳过 insert(防 marker 累积 + 消双锚点稀释);
|
||||
/// false(回退)= 无视 pinned_goal 照常 insert,完全退回原双锚点行为(排障/对比用)。
|
||||
///
|
||||
/// 与 TOPIC_MARKER_ENABLED 独立:TOPIC_MARKER_ENABLED 是整段总开关(false=整段跳过),
|
||||
/// TOPIC_MARKER_GOAL_AWARE 是 goal 感知前置 guard(仅 GOAL_AWARE=true 且 pinned_goal
|
||||
/// 存在时跳过 insert)。双关默认 true,各自单点回退不耦合(G1 关闭时 GOAL_AWARE 仍生效,
|
||||
/// 但 pinned_goal 永远 None → 不跳过 → 退原 topic 行为,双层兜底)。
|
||||
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26)
|
||||
//
|
||||
// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) →
|
||||
// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会,
|
||||
// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
|
||||
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词),
|
||||
// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰。
|
||||
// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。
|
||||
// ============================================================
|
||||
|
||||
/// G2 总开关:探索熔断是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count,
|
||||
/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0,
|
||||
/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
|
||||
pub const STALL_BREAKER_ENABLED: bool = true;
|
||||
|
||||
/// G2 连续空结果阈值(默认 3)。
|
||||
///
|
||||
/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽,
|
||||
/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词
|
||||
/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。
|
||||
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
|
||||
|
||||
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
|
||||
///
|
||||
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标,
|
||||
/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
|
||||
/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示,
|
||||
/// 确认无误杀场景用。
|
||||
pub const STALL_BREAKER_WARN_FIRST: bool = true;
|
||||
|
||||
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
|
||||
///
|
||||
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
|
||||
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
|
||||
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
|
||||
|
||||
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
||||
///
|
||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||
@@ -156,18 +241,16 @@ mod guard;
|
||||
use guard::GeneratingGuard;
|
||||
|
||||
// ============================================================
|
||||
// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。
|
||||
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
|
||||
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
||||
//
|
||||
// 本批范围(渐进):
|
||||
// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。
|
||||
// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState
|
||||
// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。
|
||||
// - guard.reset 同步 ConvState→Idle(正常退出路径)。
|
||||
// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。
|
||||
//
|
||||
// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。
|
||||
// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。
|
||||
// 收口后(批3+,2026-06-25):
|
||||
// - conv_state.rs:纯逻辑 enum + 守卫 + 单测。
|
||||
// - run_agentic_loop 入口桥接:迁移 ConvState(Idle→Generating / Error→Generating)。
|
||||
// - guard.reset/drop 同步 ConvState→Idle(正常退出 + 异常兜底)。
|
||||
// - 批3 双轨收口:CONV_STATE_ENABLED 开关与 PerConvState.generating bool 已退役,
|
||||
// ConvState 成为生成态唯一真相源(无条件迁移 + emit,无 off 降级分支)。
|
||||
// - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。
|
||||
// ============================================================
|
||||
pub mod conv_state;
|
||||
|
||||
@@ -419,25 +502,21 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
||||
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
||||
//
|
||||
// L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。
|
||||
// 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum
|
||||
// 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。
|
||||
// L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。
|
||||
// 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源),
|
||||
// emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.generating = true;
|
||||
// L2 批2:ConvState 持久化(Idle→Generating 写收敛,CONV_STATE_ENABLED 门控)。
|
||||
// enum 与 generating bool 双轨(bool 核心复位,enum 供读侧/前端);off 降级跳过。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
||||
),
|
||||
}
|
||||
// L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。
|
||||
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,9 +759,61 @@ pub(crate) async fn run_agentic_loop(
|
||||
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
|
||||
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。
|
||||
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
|
||||
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
|
||||
let mut stall_count: u32 = 0;
|
||||
let mut stall_warned: bool = false;
|
||||
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
|
||||
let mut last_reasoning_content: Option<String> = None;
|
||||
|
||||
// G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。
|
||||
//
|
||||
// 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request
|
||||
// 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非
|
||||
// messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。
|
||||
//
|
||||
// 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 →
|
||||
// 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过,
|
||||
// pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt)。
|
||||
//
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入
|
||||
// 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。
|
||||
let mut system_prompt = system_prompt;
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal_opt: Option<String> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| c.pinned_goal.clone())
|
||||
};
|
||||
if let Some(goal) = goal_opt {
|
||||
let goal_text = goal.trim();
|
||||
if !goal_text.is_empty() {
|
||||
let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", goal_text)
|
||||
} else {
|
||||
goal_text
|
||||
};
|
||||
system_prompt = if GOAL_INJECT_BANNER {
|
||||
format!(
|
||||
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}",
|
||||
system_prompt, goal_text
|
||||
)
|
||||
} else {
|
||||
format!("{}\n\n{}", system_prompt, goal_text)
|
||||
};
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
chars = goal_text.chars().count(),
|
||||
"[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
@@ -748,7 +879,27 @@ pub(crate) async fn run_agentic_loop(
|
||||
return;
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.take_topic_marker()
|
||||
// G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在
|
||||
// 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。
|
||||
// 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push
|
||||
// user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。
|
||||
let goal_active = TOPIC_MARKER_GOAL_AWARE
|
||||
&& conv
|
||||
.pinned_goal
|
||||
.as_deref()
|
||||
.map(|g| !g.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let marker = conv.messages.take_topic_marker();
|
||||
if goal_active && marker.is_some() {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
"[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
marker
|
||||
}
|
||||
};
|
||||
if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| {
|
||||
// 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。
|
||||
@@ -929,7 +1080,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 自动压缩成功,摘要已插首位"
|
||||
);
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||
let ev = AiChatEvent::AiAutoCompressed {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
summary,
|
||||
};
|
||||
@@ -982,14 +1136,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
);
|
||||
}
|
||||
}
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e),
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
||||
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
||||
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1463,6 +1613,116 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// G2 探索熔断:连续空结果无进展检查(治 R4 游荡死循环 + token 失控,2026-06-26)。
|
||||
//
|
||||
// STALL_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
|
||||
// 仅检查已回填 Tool 消息(同 L1):pending_count>0(待审批)交给上方审批分支,此处看已回填结果。
|
||||
// 取末尾连续 Tool 消息(倒序 take_while role==Tool),逐条 is_empty_tool_result:
|
||||
// - 任一非空 → stall_count=0 重置 + stall_warned=false(有进展,清警示态);
|
||||
// - 全空 → stall_count+=1。
|
||||
// 两段式(STALL_BREAKER_WARN_FIRST):stall_count == THRESHOLD-1 且未警示过 → 置 warned 标志,
|
||||
// 警示文本拼进 system_prompt(本轮 LLM 已 stream 完,实际下轮生效——因 system_prompt 不变量,
|
||||
// 下轮重新 stream 时会读最新 system_prompt;此处通过 insert 一条一次性软提示消息保本轮自纠)。
|
||||
// stall_count >= THRESHOLD → guard.reset + AiHelpRequired + return 强制熔断。
|
||||
// 与 L1 独立:is_empty_tool_result(空成功)不与 L1 is_failure(失败关键词)重叠。
|
||||
if STALL_BREAKER_ENABLED {
|
||||
let all_empty = {
|
||||
let session = session_arc.lock().await;
|
||||
let messages = match session.conv_read(&conv_id) {
|
||||
Some(conv) => conv.messages.all_messages_clone(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果)。
|
||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
if recent_tool_results.is_empty() {
|
||||
// 本轮无工具回填(非工具轮)→ 不计入也不重置,保持 stall_count(不干扰已有计数)。
|
||||
None::<bool>
|
||||
} else {
|
||||
// 任一非空 → 重置;全空 → Some(true)。
|
||||
let any_non_empty = recent_tool_results
|
||||
.iter()
|
||||
.any(|m| !is_empty_tool_result(&m.content));
|
||||
Some(!any_non_empty)
|
||||
}
|
||||
};
|
||||
// 锁已 drop,可安全 await/emit。
|
||||
if let Some(all_empty_flag) = all_empty {
|
||||
if all_empty_flag {
|
||||
stall_count += 1;
|
||||
} else {
|
||||
stall_count = 0;
|
||||
stall_warned = false;
|
||||
}
|
||||
// 两段式警示:THRESHOLD-1 次(如 THRESHOLD=3 即第 2 次)首次警示。
|
||||
if STALL_BREAKER_WARN_FIRST
|
||||
&& stall_count == STALL_BREAKER_THRESHOLD.saturating_sub(1)
|
||||
&& !stall_warned
|
||||
{
|
||||
stall_warned = true;
|
||||
let goal_text = if STALL_BREAKER_GOAL_REMIND {
|
||||
let g = session_arc
|
||||
.lock()
|
||||
.await
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| c.pinned_goal.clone())
|
||||
.unwrap_or_default();
|
||||
if g.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("(当前目标: {})", g.trim())
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let warn_text = format!(
|
||||
"⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。",
|
||||
stall_count, goal_text
|
||||
);
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(&conv_id) {
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
stall_count,
|
||||
"[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// 熔断:达 THRESHOLD 强制停止。
|
||||
if stall_count >= STALL_BREAKER_THRESHOLD {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
stall_count,
|
||||
"[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", stall_count
|
||||
);
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!(
|
||||
"连续 {} 次工具返回空结果无进展,疑似目标漂移已停止",
|
||||
stall_count
|
||||
),
|
||||
context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(),
|
||||
options: vec![
|
||||
"回顾目标".into(),
|
||||
"换思路".into(),
|
||||
"停止".into(),
|
||||
],
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
|
||||
if pending_count > 0 {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
@@ -1556,6 +1816,60 @@ pub(crate) async fn run_agentic_loop(
|
||||
});
|
||||
}
|
||||
|
||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||
///
|
||||
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
|
||||
/// 序列化为紧凑 JSON,无空格):
|
||||
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
|
||||
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
|
||||
/// - read_symbol 未找到 → 含「未找到」回退提示。
|
||||
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
|
||||
///
|
||||
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
|
||||
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
|
||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
|
||||
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
|
||||
const EMPTY_MARKERS: &[&str] = &[
|
||||
"\"total\":0",
|
||||
"\"entries\":[]",
|
||||
"\"matches\":[]",
|
||||
"\"results\":[]",
|
||||
"\"files\":[]",
|
||||
];
|
||||
for marker in EMPTY_MARKERS {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
|
||||
const EMPTY_TEXT: &[&str] = &[
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无匹配",
|
||||
"没有匹配",
|
||||
"未匹配",
|
||||
"未发现",
|
||||
"无记录",
|
||||
"No matches",
|
||||
"no matches",
|
||||
"0 results",
|
||||
"0 matches",
|
||||
"没有数据",
|
||||
"没有符合",
|
||||
];
|
||||
for marker in EMPTY_TEXT {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
///
|
||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
||||
@@ -1610,15 +1924,10 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed),
|
||||
// off 回退 generating bool。双轨过渡:off 等价旧行为。
|
||||
let is_generating = conv.map(|c| {
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false);
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
|
||||
// is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
|
||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
ContinueSnapshot {
|
||||
@@ -1667,11 +1976,19 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保 generating=true(审批等待态),
|
||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
|
||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
||||
// F-09 B 批4:per_conv 唯一真相源,删顶层双写复位。
|
||||
// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(conv_id).generating = false;
|
||||
// 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
|
||||
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
|
||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
|
||||
),
|
||||
}
|
||||
drop(session);
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
@@ -1721,16 +2038,12 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
|
||||
// is_active() 与原 generating 语义一致。
|
||||
let still_generating = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(conv_id).map(|c| {
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false)
|
||||
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
};
|
||||
if !still_generating {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) async fn audit_tool_call(
|
||||
message_id: Option<&str>,
|
||||
) {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
let _ = repo
|
||||
if let Err(e) = repo
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
@@ -51,7 +51,16 @@ pub(crate) async fn audit_tool_call(
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
||||
conv_id,
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||
|
||||
@@ -248,6 +248,60 @@ async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u3
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||
///
|
||||
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||||
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
|
||||
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
|
||||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||||
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
||||
async fn execute_with_heartbeat(
|
||||
tools: &AiToolRegistry,
|
||||
name: &str,
|
||||
args: serde_json::Value,
|
||||
app: &AppHandle,
|
||||
conv_id: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::time::Duration;
|
||||
|
||||
// RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。
|
||||
struct HeartbeatGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for HeartbeatGuard {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let app_h = app.clone();
|
||||
let conv = conv_id.to_string();
|
||||
let stop_c = stop.clone();
|
||||
// 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174),
|
||||
// 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。
|
||||
let heartbeat = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if stop_c.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) };
|
||||
let _ = app_h.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
});
|
||||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||||
tools.execute(name, args).await
|
||||
}
|
||||
|
||||
pub(crate) async fn process_tool_calls(
|
||||
session: &mut AiSession,
|
||||
tool_calls_acc: HashMap<u32, ToolCallDraft>,
|
||||
@@ -601,7 +655,7 @@ pub(crate) async fn process_tool_calls(
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
let exec_result = tools.execute(&draft.name, args).await;
|
||||
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
|
||||
match exec_result {
|
||||
Ok(val) => {
|
||||
let content = val.to_string();
|
||||
@@ -658,7 +712,7 @@ pub(crate) async fn process_tool_calls(
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
let result = tools.execute(&draft.name, args).await;
|
||||
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
|
||||
match result {
|
||||
Ok(val) => {
|
||||
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
||||
|
||||
@@ -39,16 +39,29 @@ const DEFINITION_KINDS: &[&str] = &[
|
||||
"interface_declaration",
|
||||
"enum_declaration",
|
||||
"method_definition",
|
||||
// Go
|
||||
"method_declaration", // Go method(receiver func)
|
||||
"type_declaration", // Go type Xxx struct/interface/func
|
||||
// Java
|
||||
"constructor_declaration", // Java constructor
|
||||
"annotation_type_declaration", // @interface
|
||||
"record_declaration", // Java record(Java 16+)
|
||||
// Python(tree-sitter-python 用 _definition 后缀)
|
||||
"function_definition",
|
||||
"class_definition",
|
||||
];
|
||||
|
||||
/// 文件后缀 → tree-sitter grammar 集中 lookup(静态编译,不动态加载 / 不抽 trait)。
|
||||
///
|
||||
/// 设计(grammar 策略 2026-06-24 定稿,详见插件机制设计文档):
|
||||
/// **3 个 grammar 覆盖 4 类文件**,架构成本最低。
|
||||
/// **6 个 grammar 覆盖 9 类文件**,架构成本最低。
|
||||
/// - `.rs` → tree-sitter-rust
|
||||
/// - `.ts` → tree-sitter-typescript(language_typescript)
|
||||
/// - `.tsx` → tree-sitter-typescript(language_tsx)
|
||||
/// - `.js` / `.jsx` → tree-sitter-javascript
|
||||
/// - `.go` → tree-sitter-go
|
||||
/// - `.java` → tree-sitter-java
|
||||
/// - `.py` → tree-sitter-python
|
||||
/// - `.vue` → 返回 None 标记"需切 script 段"(Vue 借 TS,逻辑在 script,read_symbol 内 extract_vue_script 切段喂 ts)
|
||||
///
|
||||
/// 未来加语言 = 此处加一行 + 可能补 DEFINITION_KINDS(表驱动,~几行),不返工调用方。
|
||||
@@ -59,19 +72,22 @@ pub fn grammar_for(ext: &str) -> Option<Language> {
|
||||
"ts" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT)),
|
||||
"tsx" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TSX)),
|
||||
"js" | "jsx" => Some(Language::from(tree_sitter_javascript::LANGUAGE)),
|
||||
"go" => Some(Language::from(tree_sitter_go::LANGUAGE)),
|
||||
"java" => Some(Language::from(tree_sitter_java::LANGUAGE)),
|
||||
"py" => Some(Language::from(tree_sitter_python::LANGUAGE)),
|
||||
// Vue 借 TS:返 None 标记需切 script 段(read_symbol 内 extract_vue_script + 喂 ts)
|
||||
"vue" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 ext 是否为 Phase1 支持语言(含 Vue 借 TS)。
|
||||
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。
|
||||
///
|
||||
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
|
||||
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
|
||||
#[allow(dead_code)]
|
||||
pub fn is_supported_ext(ext: &str) -> bool {
|
||||
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue")
|
||||
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
|
||||
}
|
||||
|
||||
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
|
||||
@@ -106,6 +122,9 @@ fn extract_vue_script(source: &str) -> Option<(String, usize)> {
|
||||
/// - `symbol`:目标符号名(精确匹配定义节点的 name field)
|
||||
/// - `full`:true=完整定义体,false=骨架
|
||||
/// - `kind_hint`:可选 kind 过滤(如 "function"/"struct"),None 匹配任意定义 kind
|
||||
/// - `drill`:可选子定义名(如 "parse");Some(name)=在目标符号节点子树内递归找名为 name 的
|
||||
/// 嵌套定义(局部函数/impl 方法),命中即返该子定义的 full 态;未命中走兜底(reason=drill_not_found)。
|
||||
/// None=不下钻,走原 full/kind_hint 三态逻辑。
|
||||
pub fn read_symbol(
|
||||
source: &str,
|
||||
file_hash: &str,
|
||||
@@ -113,6 +132,7 @@ pub fn read_symbol(
|
||||
symbol: &str,
|
||||
full: bool,
|
||||
kind_hint: Option<&str>,
|
||||
drill: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_vue = ext == "vue";
|
||||
@@ -136,7 +156,7 @@ pub fn read_symbol(
|
||||
Some(g) => g,
|
||||
None => {
|
||||
return fallback(symbol, path, file_hash, "no_grammar",
|
||||
&format!("不支持 {} 文件符号解析(Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue)", ext));
|
||||
&format!("不支持 {} 文件符号解析(支持 .rs/.ts/.tsx/.js/.jsx/.vue/.go/.java/.py)", ext));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -154,6 +174,21 @@ pub fn read_symbol(
|
||||
let mut found: Option<Node> = None;
|
||||
find_definition(&root, symbol, kind_hint, parse_source.as_bytes(), &mut found);
|
||||
|
||||
// drill:在目标符号节点子树内递归找名为 drill 的嵌套定义(局部函数/impl 方法)。
|
||||
// 命中即返该子定义 full 态;未命中走兜底(reason=drill_not_found,不 panic)。
|
||||
if let Some(drill_name) = drill {
|
||||
if let Some(sym_node) = found {
|
||||
let mut drill_found: Option<Node> = None;
|
||||
find_definition(&sym_node, drill_name, None, parse_source.as_bytes(), &mut drill_found);
|
||||
return match drill_found {
|
||||
Some(dn) => extract_symbol(dn, parse_source.as_bytes(), source, path, file_hash, true, line_offset),
|
||||
None => fallback(drill_name, path, file_hash, "drill_not_found",
|
||||
&format!("符号「{}」内未找到下钻子定义「{}」(可能非嵌套定义/作用域差异)", symbol, drill_name)),
|
||||
};
|
||||
}
|
||||
// found 本身为 None 时 drill 无意义,落入下方 not_found 兜底
|
||||
}
|
||||
|
||||
match found {
|
||||
Some(node) => extract_symbol(node, parse_source.as_bytes(), source, path, file_hash, full, line_offset),
|
||||
None => fallback(symbol, path, file_hash, "not_found",
|
||||
@@ -237,7 +272,7 @@ fn extract_symbol(
|
||||
|
||||
let node_src = node_text(node, source);
|
||||
let signature = extract_signature(&node_src);
|
||||
let calls = collect_calls(node, source);
|
||||
let calls = collect_calls(node, source, line_offset);
|
||||
|
||||
if full {
|
||||
serde_json::json!({
|
||||
@@ -265,7 +300,7 @@ fn extract_symbol(
|
||||
"signature": signature,
|
||||
"calls": calls,
|
||||
"mode": "skeleton",
|
||||
"hint": "骨架态:传 full=true 取完整定义体(语法边界精准);下钻局部分支 drill 待 Phase2",
|
||||
"hint": "骨架态:传 full=true 取完整定义体(语法边界精准);传 drill=\"子定义名\" 下钻符号内嵌套定义(局部函数/impl 方法)",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -289,25 +324,34 @@ fn extract_signature(node_src: &str) -> String {
|
||||
///
|
||||
/// Phase1:扁平收集去重(Phase2 按分支结构组织调用图)。
|
||||
/// Rust/TS/JS 的 call_expression 都有 "function" field(callee),统一提取。
|
||||
fn collect_calls(node: Node, source: &[u8]) -> Vec<String> {
|
||||
/// Phase2:每项带行号(call_expression 所在行,1-based + Vue 偏移,与 start_row 同口径)。
|
||||
fn collect_calls(node: Node, source: &[u8], line_offset: usize) -> Vec<serde_json::Value> {
|
||||
let mut calls = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
collect_calls_inner(node, source, &mut calls, &mut seen);
|
||||
collect_calls_inner(node, source, line_offset, &mut calls, &mut seen);
|
||||
calls
|
||||
}
|
||||
|
||||
fn collect_calls_inner(node: Node, source: &[u8], out: &mut Vec<String>, seen: &mut HashSet<String>) {
|
||||
fn collect_calls_inner(
|
||||
node: Node,
|
||||
source: &[u8],
|
||||
line_offset: usize,
|
||||
out: &mut Vec<serde_json::Value>,
|
||||
seen: &mut HashSet<String>,
|
||||
) {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == "call_expression" {
|
||||
if let Some(func) = child.child_by_field_name("function") {
|
||||
let name = node_text(func, source).to_string();
|
||||
if !name.is_empty() && seen.insert(name.clone()) {
|
||||
out.push(name);
|
||||
// 行号:调用语句所在行(call_expression 节点本身的 start_position),1-based + Vue 偏移
|
||||
let line = child.start_position().row + 1 + line_offset;
|
||||
out.push(serde_json::json!({ "name": name, "line": line }));
|
||||
}
|
||||
}
|
||||
}
|
||||
collect_calls_inner(child, source, out, seen);
|
||||
collect_calls_inner(child, source, line_offset, out, seen);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,23 +403,31 @@ struct Config {
|
||||
assert!(grammar_for("tsx").is_some());
|
||||
assert!(grammar_for("js").is_some());
|
||||
assert!(grammar_for("jsx").is_some());
|
||||
// Phase1 扩展:Go / Java / Python(静态编译,表驱动加表项)
|
||||
assert!(grammar_for("go").is_some());
|
||||
assert!(grammar_for("java").is_some());
|
||||
assert!(grammar_for("py").is_some());
|
||||
// Vue 借 TS:grammar 本身返 None(切段后用 ts)
|
||||
assert!(grammar_for("vue").is_none());
|
||||
// 不支持语言
|
||||
assert!(grammar_for("py").is_none());
|
||||
assert!(grammar_for("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_ext() {
|
||||
assert!(is_supported_ext("rs"));
|
||||
assert!(is_supported_ext("go"));
|
||||
assert!(is_supported_ext("java"));
|
||||
assert!(is_supported_ext("py"));
|
||||
assert!(is_supported_ext("vue"));
|
||||
assert!(!is_supported_ext("py"));
|
||||
// 不支持语言
|
||||
assert!(!is_supported_ext("rb"));
|
||||
assert!(!is_supported_ext("md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_rust_skeleton() {
|
||||
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", false, None);
|
||||
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", false, None, None);
|
||||
assert_eq!(r["fallback"], serde_json::Value::Null);
|
||||
assert_eq!(r["symbol"], "read_symbol");
|
||||
assert_eq!(r["kind"], "function_item");
|
||||
@@ -384,15 +436,16 @@ struct Config {
|
||||
assert!(r["content"].is_null());
|
||||
assert!(r["signature"].as_str().unwrap().contains("read_symbol"));
|
||||
// calls 含同文件静态调用(parse/validate)+ load(跨文件也命中名,Phase1 扁平)
|
||||
// Phase2:每项是 {name, line} 对象
|
||||
let calls = r["calls"].as_array().unwrap();
|
||||
assert!(calls.iter().any(|c| c == "parse"));
|
||||
assert!(calls.iter().any(|c| c == "validate"));
|
||||
assert!(calls.iter().any(|c| c["name"] == "parse"));
|
||||
assert!(calls.iter().any(|c| c["name"] == "validate"));
|
||||
assert_eq!(r["body_lines"].as_u64().unwrap(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_rust_full() {
|
||||
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", true, None);
|
||||
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", true, None, None);
|
||||
assert_eq!(r["mode"], "full");
|
||||
let content = r["content"].as_str().unwrap();
|
||||
// full 态返完整定义体(含函数体 {})
|
||||
@@ -403,7 +456,7 @@ struct Config {
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_struct() {
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, None);
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, None, None);
|
||||
assert_eq!(r["symbol"], "Config");
|
||||
assert_eq!(r["kind"], "struct_item");
|
||||
}
|
||||
@@ -411,14 +464,14 @@ struct Config {
|
||||
#[test]
|
||||
fn test_read_symbol_kind_hint_filter() {
|
||||
// kind_hint="struct" 时,"read_symbol"(function)不应被误匹配为 struct
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, Some("struct"));
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, Some("struct"), None);
|
||||
assert_eq!(r["symbol"], "Config");
|
||||
assert_eq!(r["kind"], "struct_item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_not_found_fallback() {
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "nonexistent_fn", false, None);
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "nonexistent_fn", false, None, None);
|
||||
assert_eq!(r["fallback"], true);
|
||||
assert_eq!(r["reason"], "not_found");
|
||||
assert!(r["suggestion"].as_str().unwrap().contains("grep"));
|
||||
@@ -426,8 +479,10 @@ struct Config {
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_no_grammar_fallback() {
|
||||
let src = "def hello():\n print('hi')\n";
|
||||
let r = read_symbol(src, "h", "test.py", "hello", false, None);
|
||||
// Ruby 无 grammar(.rb 未集成 tree-sitter-ruby),走兜底。
|
||||
// 注:原用 .py 做"无 grammar"样例,Phase1 扩展加 Python 支持后失效。
|
||||
let src = "def hello\n puts 'hi'\nend\n";
|
||||
let r = read_symbol(src, "h", "test.rb", "hello", false, None, None);
|
||||
assert_eq!(r["fallback"], true);
|
||||
assert_eq!(r["reason"], "no_grammar");
|
||||
assert!(r["suggestion"].as_str().unwrap().contains("不支持"));
|
||||
@@ -448,7 +503,7 @@ function helper() {}
|
||||
|
||||
<style scoped></style>
|
||||
"#;
|
||||
let r = read_symbol(vue, "h", "App.vue", "greet", false, None);
|
||||
let r = read_symbol(vue, "h", "App.vue", "greet", false, None, None);
|
||||
assert_eq!(r["fallback"], serde_json::Value::Null);
|
||||
assert_eq!(r["symbol"], "greet");
|
||||
assert_eq!(r["kind"], "function_declaration");
|
||||
@@ -456,17 +511,53 @@ function helper() {}
|
||||
let start = r["line_start"].as_u64().unwrap();
|
||||
assert!(start >= 4, "Vue 行号应映射回原文件(script 段偏移后),实际 {}", start);
|
||||
let calls = r["calls"].as_array().unwrap();
|
||||
assert!(calls.iter().any(|c| c == "helper"));
|
||||
// Phase2:calls 每项是 {name, line} 对象
|
||||
assert!(calls.iter().any(|c| c["name"] == "helper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_vue_no_script_fallback() {
|
||||
let vue = "<template><div>hi</div></template>\n<style></style>";
|
||||
let r = read_symbol(vue, "h", "App.vue", "greet", false, None);
|
||||
let r = read_symbol(vue, "h", "App.vue", "greet", false, None, None);
|
||||
assert_eq!(r["fallback"], true);
|
||||
assert_eq!(r["reason"], "vue_no_script");
|
||||
}
|
||||
|
||||
/// Phase2 drill:下钻目标符号(read_symbol)内的嵌套子定义(parse)。
|
||||
/// RUST_FIXTURE 中 read_symbol 函数体内调用了 parse,但 parse 是顶层函数(非 read_symbol 子树内),
|
||||
/// 故用带嵌套局部函数的 fixture 验证:outer 内定义 inner,drill="inner" 应命中并返 full 态。
|
||||
#[test]
|
||||
fn test_read_symbol_drill_hits_nested() {
|
||||
let src = r#"
|
||||
fn outer() {
|
||||
fn inner(x: i32) -> i32 {
|
||||
x + 1
|
||||
}
|
||||
inner(42)
|
||||
}
|
||||
"#;
|
||||
// 外层骨架态,确认 outer 能解析到
|
||||
let skeleton = read_symbol(src, "h", "t.rs", "outer", false, None, None);
|
||||
assert_eq!(skeleton["symbol"], "outer");
|
||||
// 下钻 inner:应在 outer 子树内命中,返 full 态
|
||||
let drill = read_symbol(src, "h", "t.rs", "outer", false, None, Some("inner"));
|
||||
assert_eq!(drill["fallback"], serde_json::Value::Null, "drill 命中应返符号非兜底");
|
||||
assert_eq!(drill["symbol"], "inner");
|
||||
assert_eq!(drill["mode"], "full", "drill 命中走 full 态(完整定义体)");
|
||||
let content = drill["content"].as_str().unwrap();
|
||||
assert!(content.contains("fn inner"), "drill content 应含 inner 定义体");
|
||||
}
|
||||
|
||||
/// Phase2 drill 未命中兜底:下钻外层符号内不存在的子定义名 → fallback reason=drill_not_found。
|
||||
#[test]
|
||||
fn test_read_symbol_drill_not_found_fallback() {
|
||||
let src = "fn outer() {\n fn inner() {}\n}\n";
|
||||
let drill = read_symbol(src, "h", "t.rs", "outer", false, None, Some("nonexistent"));
|
||||
assert_eq!(drill["fallback"], true);
|
||||
assert_eq!(drill["reason"], "drill_not_found");
|
||||
assert!(drill["suggestion"].as_str().unwrap().contains("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_vue_script() {
|
||||
let vue = "a\nb\n<script>let x = 1;</script>";
|
||||
@@ -495,11 +586,13 @@ function helper() {}
|
||||
let mut parser = Parser::new();
|
||||
parser.set_language(&Language::from(tree_sitter_rust::LANGUAGE)).unwrap();
|
||||
let tree = parser.parse(src, None).unwrap();
|
||||
let calls = collect_calls(tree.root_node(), src.as_bytes());
|
||||
// b 去重只出现一次
|
||||
assert!(calls.iter().any(|c| c == "b"));
|
||||
assert_eq!(calls.iter().filter(|c| *c == "b").count(), 1);
|
||||
assert!(calls.iter().any(|c| c == "c"));
|
||||
let calls = collect_calls(tree.root_node(), src.as_bytes(), 0);
|
||||
// b 去重只出现一次(Phase2:每项是 {name, line} 对象)
|
||||
assert!(calls.iter().any(|c| c["name"] == "b"));
|
||||
assert_eq!(calls.iter().filter(|c| c["name"] == "b").count(), 1);
|
||||
assert!(calls.iter().any(|c| c["name"] == "c"));
|
||||
// line 应为调用所在行(src 单行,a 在第 1 行)
|
||||
assert_eq!(calls[0]["line"].as_u64().unwrap(), 1);
|
||||
}
|
||||
|
||||
/// 多角度验证(用户要求:实现后看效果 + 多角度测试):
|
||||
@@ -514,7 +607,7 @@ function helper() {}
|
||||
let full_bytes = source.len();
|
||||
|
||||
// 骨架态:解析自身 read_symbol 函数(只给该符号,非整个文件)
|
||||
let skeleton = read_symbol(&source, "h0", "code_intel.rs", "read_symbol", false, None);
|
||||
let skeleton = read_symbol(&source, "h0", "code_intel.rs", "read_symbol", false, None, None);
|
||||
assert_ne!(skeleton["fallback"], true, "真实文件应解析出 read_symbol 符号");
|
||||
assert_eq!(skeleton["symbol"], "read_symbol");
|
||||
assert_eq!(skeleton["kind"], "function_item");
|
||||
@@ -531,7 +624,7 @@ function helper() {}
|
||||
assert!(ratio > 5.0, "骨架密度应显著高于全文(降 >5x),实际 {:.1}x", ratio);
|
||||
|
||||
// full 态:完整定义体(语法边界 100% 准)
|
||||
let full_sym = read_symbol(&source, "h1", "code_intel.rs", "read_symbol", true, None);
|
||||
let full_sym = read_symbol(&source, "h1", "code_intel.rs", "read_symbol", true, None, None);
|
||||
let content = full_sym["content"].as_str().expect("full 态应返 content");
|
||||
assert!(content.contains("pub fn read_symbol"));
|
||||
// calls 应含 read_symbol 内部调用的函数(grammar_for/extract_symbol/fallback 等)
|
||||
|
||||
@@ -24,7 +24,9 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED};
|
||||
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||
use super::super::agentic::conv_state::ConvState;
|
||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||
use super::super::augmentation::build_augmentation_segment;
|
||||
use super::super::augmentation::sanitize;
|
||||
@@ -51,7 +53,7 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
||||
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
|
||||
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
|
||||
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
|
||||
fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
|
||||
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
|
||||
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
|
||||
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
|
||||
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
|
||||
@@ -70,6 +72,78 @@ fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id:
|
||||
}
|
||||
}
|
||||
|
||||
/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active),
|
||||
/// 提取后存 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。
|
||||
///
|
||||
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
|
||||
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
|
||||
/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标
|
||||
/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。
|
||||
/// 2. trim 空白。
|
||||
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
|
||||
///
|
||||
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。
|
||||
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
|
||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
||||
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if chars[i] == '[' {
|
||||
// 探测 [kind:...],命中则跳过整段(含 ])。
|
||||
if let Some(close) = find_mention_close(&chars, i, KINDS) {
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
let cleaned = out.trim();
|
||||
// 截断到 GOAL_MAX_CHARS(字符级,非字节级,中文友好)
|
||||
cleaned.chars().take(GOAL_MAX_CHARS).collect()
|
||||
}
|
||||
|
||||
/// 探测 `chars[open]=='['` 起的 mention 段 `[kind:...]`,返回匹配 `]` 的索引。
|
||||
///
|
||||
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
|
||||
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
|
||||
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||
let after = open + 1;
|
||||
for k in kinds {
|
||||
let kc: Vec<char> = k.chars().collect();
|
||||
if chars.len() < after + kc.len() {
|
||||
continue;
|
||||
}
|
||||
let slice = &chars[after..after + kc.len()];
|
||||
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
|
||||
continue;
|
||||
}
|
||||
let mut j = after + kc.len();
|
||||
while j < chars.len() && chars[j].is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') {
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while j < chars.len() {
|
||||
if chars[j] == ']' {
|
||||
return Some(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
return None; // 未闭合 ] → 不当 mention
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。
|
||||
///
|
||||
/// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此,
|
||||
@@ -187,13 +261,16 @@ pub async fn ai_regenerate(
|
||||
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// generating 拦截:仅查目标 conv 的 per_conv.generating。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
|
||||
if is_gen {
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 仅拦
|
||||
// Generating/Compressed(漏拦 Stopping 态),can_accept_request 更严谨(防停止中接新请求竞态)。
|
||||
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
conv.generating = true;
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
conv.agent_language = language.clone();
|
||||
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
|
||||
@@ -212,7 +289,8 @@ pub async fn ai_regenerate(
|
||||
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
|
||||
.unwrap_or(false);
|
||||
if !last_is_user {
|
||||
conv.generating = false;
|
||||
// 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
|
||||
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
|
||||
return Err("没有可重新生成的回复".to_string());
|
||||
}
|
||||
}
|
||||
@@ -282,15 +360,10 @@ pub async fn ai_is_generating(
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。
|
||||
return Ok(false);
|
||||
}
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state==Generating(含 Compressed 派生态,
|
||||
// 压缩期间 loop 仍活跃),off 回退 generating bool(可回退)。双轨过渡:开关 off 等价旧行为零破坏。
|
||||
Ok(session.conv_read(&target).map(|c| {
|
||||
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false))
|
||||
// L2 读侧(双轨收口批1):统一读 conv_state.is_active(),删除 CONV_STATE_ENABLED off 回退分支
|
||||
// (enum 单路径)。is_active() 拦 Generating+Compressed(压缩期间 loop 仍活跃),语义与
|
||||
// 原 c.generating 一致(此读点用于判断「是否在生成中」,应保留拦 Compressed)。
|
||||
Ok(session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// 发送消息并获取流式 AI 响应
|
||||
@@ -351,13 +424,15 @@ pub async fn ai_chat_send(
|
||||
session.active_conv_created_at = Some(now_millis());
|
||||
}
|
||||
}
|
||||
// generating 拦截:仅查目标 conv 的 per_conv.generating(真并发下各 conv 独立)。
|
||||
let is_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false);
|
||||
if is_gen {
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态,
|
||||
// can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。
|
||||
let cs = session.conv_read(&target).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&target);
|
||||
conv.generating = true;
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
conv.agent_language = language.clone();
|
||||
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
|
||||
@@ -388,6 +463,14 @@ pub async fn ai_chat_send(
|
||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
(target, user_msg_id)
|
||||
};
|
||||
|
||||
@@ -457,7 +540,7 @@ pub async fn ai_approve(
|
||||
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
|
||||
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
|
||||
Ok(Some(rec)) => {
|
||||
if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" {
|
||||
if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" {
|
||||
return Ok(format!("已处理({})", rec.status));
|
||||
}
|
||||
}
|
||||
@@ -534,7 +617,7 @@ pub async fn ai_approve(
|
||||
// session_trust —— 下次同会话同类操作(同工具+同目录 TrustKey)自动放行,跳过 pending + 二次确认。
|
||||
// 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。
|
||||
// F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。
|
||||
// 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 executed)。
|
||||
// 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 completed)。
|
||||
if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) {
|
||||
let inserted = if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).session_trust.insert(key.clone())
|
||||
@@ -581,7 +664,7 @@ pub async fn ai_approve(
|
||||
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
|
||||
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
|
||||
let (audit_status, result_val) = match &exec_result {
|
||||
Ok(val) => ("executed", val.clone()),
|
||||
Ok(val) => ("completed", val.clone()),
|
||||
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
|
||||
};
|
||||
// 审计:人工审批后无论成败回填(决策者=human)
|
||||
@@ -601,7 +684,7 @@ pub async fn ai_approve(
|
||||
|
||||
// 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步)
|
||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
|
||||
// 无 conv_id 的无主审批不入 messages(审计仍记 executed/failed)。
|
||||
// 无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
|
||||
@@ -627,11 +710,11 @@ pub async fn ai_approve(
|
||||
|
||||
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
|
||||
// create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行,
|
||||
// 故补全点放此(工具已 executed,可拿到 result.id + args.source)。
|
||||
// 故补全点放此(工具已 completed,可拿到 result.id + args.source)。
|
||||
// message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义);
|
||||
// assistant_with_tools 消息持久驻留 messages,审批时仍可读。
|
||||
// 仅 executed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
|
||||
if audit_status == "executed" {
|
||||
// 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
|
||||
if audit_status == "completed" {
|
||||
let message_id = {
|
||||
let session = state.ai_session.lock().await;
|
||||
conv_id.as_deref().and_then(|cid| {
|
||||
@@ -665,15 +748,15 @@ pub async fn ai_approve(
|
||||
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
|
||||
Ok("executed".to_string())
|
||||
Ok("completed".to_string())
|
||||
}
|
||||
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
|
||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||
fn authz_debug(msg: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true)
|
||||
.open("E:/wk-lab/devflow/authz-debug.log")
|
||||
{
|
||||
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
@@ -691,17 +774,10 @@ fn drive_letter(path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。
|
||||
///
|
||||
/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。
|
||||
/// 失败回退 None(对比退化,不报跨盘提示)。
|
||||
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
|
||||
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
|
||||
fn workspace_drive() -> Option<String> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
drive_letter(&root.to_string_lossy())
|
||||
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
|
||||
@@ -751,9 +827,10 @@ fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json:
|
||||
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
|
||||
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
|
||||
///
|
||||
/// `decision`:
|
||||
/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop
|
||||
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复
|
||||
/// `decision`(三授权档 + 拒):
|
||||
/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
|
||||
/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
|
||||
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
|
||||
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
|
||||
///
|
||||
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
|
||||
@@ -843,23 +920,33 @@ pub async fn ai_authorize_dir(
|
||||
return Ok("rejected".to_string());
|
||||
}
|
||||
|
||||
// ── "once":写会话级临时授权(随会话销毁,不落库) ──
|
||||
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项)。
|
||||
if decision == "once" {
|
||||
for d in &dir_strs {
|
||||
state.add_session_allowed_dir(d.clone()).await;
|
||||
// ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
|
||||
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
|
||||
// deny 已在上方早 return,此处仅 once/session/always。
|
||||
match decision.as_str() {
|
||||
"once" => {
|
||||
for d in &dir_strs {
|
||||
state.add_once_allowed_dir(d.clone()).await;
|
||||
}
|
||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── "always":写持久化授权(Settings KV + 同步内存 persistent) ──
|
||||
if decision == "always" {
|
||||
for d in &dir_strs {
|
||||
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
|
||||
tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): dir={} err={}", d, e);
|
||||
// 降级:写入会话临时授权,保本路径本会话可用
|
||||
"session" => {
|
||||
for d in &dir_strs {
|
||||
state.add_session_allowed_dir(d.clone()).await;
|
||||
}
|
||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
|
||||
}
|
||||
"always" => {
|
||||
for d in &dir_strs {
|
||||
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
|
||||
tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
|
||||
// 降级:写入会话临时授权,保本路径本会话可用
|
||||
state.add_session_allowed_dir(d.clone()).await;
|
||||
}
|
||||
}
|
||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
|
||||
}
|
||||
_ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
|
||||
}
|
||||
|
||||
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
|
||||
@@ -882,11 +969,11 @@ pub async fn ai_authorize_dir(
|
||||
};
|
||||
tracing::debug!(
|
||||
ok = exec_result.is_ok(),
|
||||
status = if exec_result.is_ok() { "executed" } else { "failed" },
|
||||
status = if exec_result.is_ok() { "completed" } else { "failed" },
|
||||
"[AI-AUTHZ] execute 后"
|
||||
);
|
||||
let (audit_status, mut result_val) = match &exec_result {
|
||||
Ok(val) => ("executed", val.clone()),
|
||||
Ok(val) => ("completed", val.clone()),
|
||||
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
|
||||
};
|
||||
// 阶段4(容错/恢复):跨盘预检 → tool_result 提示。
|
||||
@@ -908,6 +995,10 @@ pub async fn ai_authorize_dir(
|
||||
}
|
||||
}
|
||||
|
||||
// once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
|
||||
// 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
|
||||
state.clear_once_allowed_dirs().await;
|
||||
|
||||
// 替换占位 tool_result 为真实结果
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
@@ -1118,7 +1209,8 @@ pub async fn ai_chat_compress_context(
|
||||
conv.messages.set_compressing(false);
|
||||
let cid = session.active_conversation_id.clone();
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
// 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
conversation_id: cid,
|
||||
summary: String::new(),
|
||||
};
|
||||
@@ -1144,7 +1236,8 @@ pub async fn ai_chat_compress_context(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
conversation_id: Some(conv_id),
|
||||
summary: String::new(),
|
||||
};
|
||||
@@ -1209,7 +1302,8 @@ pub async fn ai_chat_compress_context(
|
||||
conv.messages.set_compressing(false);
|
||||
}
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
conversation_id: Some(conv_id),
|
||||
summary,
|
||||
};
|
||||
@@ -1243,8 +1337,10 @@ pub async fn ai_chat_edit(
|
||||
// (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
|
||||
if is_gen {
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态。
|
||||
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -1255,14 +1351,21 @@ pub async fn ai_chat_edit(
|
||||
// ② 其后所有消息标 truncated(无后续也 OK,返回 0)
|
||||
let _ = conv.messages.truncate_after_user_message(&new_message)
|
||||
.map_err(|_| "定位被编辑消息失败".to_string())?;
|
||||
// 占用 generating + stop_flag
|
||||
conv.generating = true;
|
||||
// 占用生成态 + stop_flag
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
conv.agent_language = language.clone();
|
||||
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
||||
conv.model_override = model_override.clone();
|
||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&new_message);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
@@ -1379,7 +1482,9 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
}
|
||||
// ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。
|
||||
let was_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false);
|
||||
// was_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
|
||||
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。is_active() 与原 generating 语义一致。
|
||||
let was_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
|
||||
// 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
|
||||
finalize_pending_placeholders(&mut *session, &target, "已取消");
|
||||
@@ -1387,13 +1492,22 @@ pub async fn ai_chat_force_send(
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
|
||||
{
|
||||
let conv = session.conv(&target);
|
||||
conv.generating = false;
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle 收敛)。
|
||||
// was_gen=false 时仍 Idle(幂等);非活跃态→Idle 非法仅 warn(不阻断强制发送语义)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] 强制发送 ConvState→Idle 非法(不阻断强制发送)"
|
||||
),
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无 generating 拦截——
|
||||
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
|
||||
// 这是"强制"语义本身,前面已主动复位)
|
||||
let conv = session.conv(&target);
|
||||
conv.generating = true;
|
||||
// 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
conv.agent_language = language.clone();
|
||||
// F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。
|
||||
@@ -1417,6 +1531,15 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
|
||||
// pinned_goal 已更新,续跑用新目标)。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id)
|
||||
};
|
||||
|
||||
@@ -1495,15 +1618,9 @@ pub async fn ai_chat_stop(
|
||||
return Ok(());
|
||||
}
|
||||
// 仅查目标 conv 的 generating(真并发下各 conv 独立)。
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed),
|
||||
// off 回退 generating bool。双轨过渡:off 等价旧行为。
|
||||
let is_gen = session.conv_read(&target).map(|c| {
|
||||
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false);
|
||||
// L2 读侧(双轨收口批1):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于判断停止按钮是否可点的预判断。
|
||||
let is_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1522,7 +1639,15 @@ pub async fn ai_chat_stop(
|
||||
finalize_pending_placeholders(&mut *session, &target, "会话已停止");
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
|
||||
let conv = session.conv(&target);
|
||||
conv.generating = false;
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] ai_chat_stop 审批分支 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
@@ -1557,13 +1682,25 @@ pub async fn ai_chat_stop(
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let mut session = session_arc.lock().await;
|
||||
let target_owned = conv_id.clone().unwrap_or_default();
|
||||
// still_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
|
||||
// 3秒后重检是否仍在生成。is_active() 与原 generating 语义一致。
|
||||
let still_gen = if target_owned.is_empty() {
|
||||
false
|
||||
} else {
|
||||
session.conv_read(&target_owned).map(|c| c.generating).unwrap_or(false)
|
||||
session.conv_read(&target_owned).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
};
|
||||
if still_gen {
|
||||
session.conv(&target_owned).generating = false;
|
||||
// 3秒超时强制收尾:generating bool 已退役,改 ConvState 迁移(Generating→Idle)收敛生成态。
|
||||
// 正常路径由 guard.drop 复位,此处仅超时兜底(loop 未及时退出场景)。
|
||||
let conv = session.conv(&target_owned);
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target_owned,
|
||||
error = %e,
|
||||
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
}
|
||||
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
@@ -1602,7 +1739,9 @@ pub async fn ai_continue_loop(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作
|
||||
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
|
||||
// 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。达 max 时
|
||||
// generating=true(conv_state=Generating),is_active() 返回 true 符合预期。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
return Err("AI 未在暂停态,无需继续".to_string());
|
||||
}
|
||||
@@ -1622,11 +1761,11 @@ pub async fn ai_continue_loop(
|
||||
/// 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态用户点「停止」)
|
||||
///
|
||||
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
|
||||
/// generating=true 暂停。用户点停止调本命令 → 复位 generating + emit AiCompleted(标收敛)。
|
||||
/// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。
|
||||
///
|
||||
/// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库(agentic/mod.rs
|
||||
/// 达上限分支),此处仅复位 generating + emit AiCompleted 通知前端收尾。校验复用 ai_approve
|
||||
/// 模式(generating + active_conversation_id 一致性)。
|
||||
/// 达上限分支),此处仅复位生成态 + emit AiCompleted 通知前端收尾。校验复用 ai_approve
|
||||
/// 模式(生成态 + active_conversation_id 一致性)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_stop_loop(
|
||||
app: AppHandle,
|
||||
@@ -1637,14 +1776,24 @@ pub async fn ai_stop_loop(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可停止)。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
|
||||
// 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。停止按钮判断
|
||||
// 是否在生成中(与 ai_continue_loop 同场景)。is_active() 符合语义。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
return Err("AI 未在暂停态,无需停止".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致)
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
conv.generating = false;
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conversation_id,
|
||||
error = %e,
|
||||
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
}
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
|
||||
@@ -297,7 +297,10 @@ pub async fn ai_conversation_switch(
|
||||
drop(session);
|
||||
state.clear_session_allowed_dirs().await;
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
|
||||
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断目标会话是否
|
||||
// 已在生成中(是否需要从 DB reload)。is_active() 与原 generating 语义一致(压缩期间后台 loop
|
||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload)。
|
||||
let already_live = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !already_live {
|
||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
|
||||
@@ -312,6 +315,10 @@ pub async fn ai_conversation_switch(
|
||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
||||
// → ai_pending_tool_calls 查询 → ai_approve 落库)。
|
||||
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):切走目标 conv 前,
|
||||
// 先把其 pending 审批占位 tool_result 终态化为"会话已切换",防占位残留下次发送喂给 LLM。
|
||||
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
||||
@@ -357,6 +364,10 @@ pub async fn ai_conversation_delete(
|
||||
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
|
||||
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
|
||||
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
|
||||
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):删除目标 conv 前,
|
||||
// 先把其 pending 审批占位 tool_result 终态化为"会话已删除",防占位残留下次发送喂给 LLM。
|
||||
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
|
||||
|
||||
@@ -199,8 +199,17 @@ pub enum AiChatEvent {
|
||||
AiContextCleared { conversation_id: Option<String> },
|
||||
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
|
||||
AiCompressing { conversation_id: Option<String> },
|
||||
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
|
||||
AiCompressed { conversation_id: Option<String>, summary: String },
|
||||
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。
|
||||
///
|
||||
/// 治 Task#1(BUG-260624-05):自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷,
|
||||
/// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。
|
||||
/// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。
|
||||
AiManualCompressed { conversation_id: Option<String>, summary: String },
|
||||
/// F-15 阶段2 自动 LLM 压缩完成(agentic loop 触发,治 Task#1):对桌面端静默——
|
||||
/// 桌面 useAiContext 仅复位 isCompressing(防按钮卡死兜底),不弹 toast 不 switchConversation。
|
||||
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
||||
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||
AiAutoCompressed { conversation_id: Option<String>, summary: String },
|
||||
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
///
|
||||
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
||||
@@ -257,8 +266,8 @@ pub enum AiChatEvent {
|
||||
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
|
||||
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
|
||||
///
|
||||
/// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生,
|
||||
/// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。
|
||||
/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 generating bool 已退役,emit 无条件执行
|
||||
/// (enum 单一真相源,无 off 降级分支)。前端按 conv_state 事件流派生展示态。
|
||||
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
|
||||
/// 供前端 union discriminator 直接使用。
|
||||
AiConvStateChanged {
|
||||
@@ -393,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
|
||||
"run_command" => {
|
||||
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => normalize_dir_key(d),
|
||||
None => workspace_root_str(),
|
||||
None => String::new(),
|
||||
};
|
||||
Some(TrustKey::Execute { dir })
|
||||
}
|
||||
@@ -414,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String {
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
normalize_dir_key(&parent.to_string_lossy())
|
||||
}
|
||||
Err(_) => workspace_root_str(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +523,9 @@ impl AiSession {
|
||||
.any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
||||
if has_pending {
|
||||
SessionState::AwaitingApproval
|
||||
} else if self.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) {
|
||||
} else if self.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) {
|
||||
// 读侧(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断是否在
|
||||
// 活跃生成。is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为 Streaming)。
|
||||
SessionState::Streaming
|
||||
} else {
|
||||
SessionState::Idle
|
||||
@@ -557,7 +568,8 @@ mod tests_f09_per_conv {
|
||||
#[test]
|
||||
fn test_per_conv_state_new() {
|
||||
let s = PerConvState::new();
|
||||
assert!(!s.generating, "generating 初值应为 false");
|
||||
// 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。
|
||||
assert_eq!(s.conv_state, ConvState::Idle, "conv_state 初值应为 Idle");
|
||||
assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0");
|
||||
assert!(s.agent_language.is_none(), "agent_language 初值应为 None");
|
||||
assert!(s.model_override.is_none(), "model_override 初值应为 None");
|
||||
@@ -587,7 +599,8 @@ mod tests_f09_per_conv {
|
||||
// 首次 conv() 创建
|
||||
let ptr_first = {
|
||||
let s = session.conv("conv-a");
|
||||
assert!(!s.generating);
|
||||
// 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。
|
||||
assert_eq!(s.conv_state, ConvState::Idle);
|
||||
assert_eq!(s.iteration_used, 0);
|
||||
s as *const PerConvState
|
||||
};
|
||||
@@ -744,15 +757,36 @@ mod tests_f09_per_conv {
|
||||
/// 读写,AiSession 顶层单例字段已全部删除。
|
||||
///
|
||||
/// 字段初值逐字对齐 [`AiSession::new`](#method.new)。
|
||||
///
|
||||
/// # 字段正交性(G1 目标钉扎 / G3 状态机收敛衔接,2026-06-26)
|
||||
///
|
||||
/// 本 struct 同时持有两类正交状态:
|
||||
/// - **生命周期态**(`conv_state: ConvState`):loop 是否在跑(Idle/Generating/Stopping/...),
|
||||
/// 归 `ConvState` enum 管(写收敛见 [`ConvState::transition_to`])。
|
||||
/// - **内容态**(`pinned_goal` 等):用户想达成什么 / 会话进度等内容信息,挂本 struct 兄弟字段。
|
||||
///
|
||||
/// 两者**正交共存**于同一 struct,不互相进变体:目标**绝不**塞进 `ConvState` enum(否则
|
||||
/// 5 态会膨胀成 `GeneratingWithGoalA`/`GeneratingWithGoalB` 爆炸组合,违反 conv-state-convergence
|
||||
/// 分支「轻量状态机不引入框架」原则,见 agentic/conv_state.rs 文件头)。目标钉扎字段随会话
|
||||
/// 销毁不落库(对齐 `knowledge_extracted` 语义,PerConvState 无 serde derive)。
|
||||
pub struct PerConvState {
|
||||
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
|
||||
pub messages: ContextManager,
|
||||
/// 是否正在生成
|
||||
pub generating: bool,
|
||||
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。
|
||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||
pub conv_state: ConvState,
|
||||
/// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。
|
||||
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
|
||||
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goal: Option<String>,
|
||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||
@@ -799,11 +833,12 @@ impl PerConvState {
|
||||
/// - session_trust: HashSet::new()
|
||||
/// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义)
|
||||
/// - knowledge_extracted: false(新会话未提炼,P1 去重标志)
|
||||
/// - pinned_goal: None(G1 目标钉扎,新会话未提取目标)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: ContextManager::new(ContextConfig::default()),
|
||||
generating: false,
|
||||
conv_state: ConvState::Idle,
|
||||
pinned_goal: None,
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
iteration_used: 0,
|
||||
|
||||
@@ -69,6 +69,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
- Create/query projects, tasks, and ideas\n\
|
||||
- Run workflows\n\
|
||||
- Read file contents, list directories, create/write files\n\
|
||||
- To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found\n\
|
||||
- Get file metadata (size, line count, binary detection)\n\
|
||||
- Append content to files, search files by name pattern\n\n\
|
||||
## Guidelines\n\
|
||||
@@ -92,6 +93,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
- 创建/查询项目、任务、灵感\n\
|
||||
- 运行工作流\n\
|
||||
- 读取文件内容、列出目录、创建/写入文件\n\
|
||||
- 理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file\n\
|
||||
- 获取文件元信息(大小、行数、二进制检测)\n\
|
||||
- 追加写入文件、按名称模式搜索文件\n\n\
|
||||
## 行为准则\n\
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
//!
|
||||
//! ## R1 兜底(硬性,不可省,§3.3 + D6)
|
||||
//!
|
||||
//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 双轨口径:
|
||||
//! CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool)。
|
||||
//! generating=true 则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」,
|
||||
//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 口径:
|
||||
//! 批3 收口后读 conv_state.is_active() 单一 enum 路径,开关 + generating bool 已退役)。
|
||||
//! 生成中(Generating/Compressed)则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」,
|
||||
//! 不调 ai_chat_send。这是 miniapp 与桌面同时给同 conv 发消息的双保险,
|
||||
//! 无论 ai_chat_send 内部是否已有 generating guard 都加(防御性编程)。
|
||||
//!
|
||||
@@ -75,8 +75,6 @@ use crate::commands::ai::skills::SkillInfo;
|
||||
use df_types::augmentation::MentionSpanDto;
|
||||
// F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。
|
||||
use df_ai::provider::ChatMessage;
|
||||
// CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。
|
||||
use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED;
|
||||
|
||||
// ============================================================
|
||||
// MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93)
|
||||
@@ -637,11 +635,10 @@ async fn route_list_entities(state: &State<'_, AppState>) {
|
||||
/// ai_chat_send 内部虽有 generating guard,但桥接层再加一层前置检查作为双保险
|
||||
/// (防御性编程,对齐设计文档 D6「桥接层 send 路由硬性加,无论 ai_chat_send 内部是否有 guard」)。
|
||||
///
|
||||
/// 检查口径与 `ai_is_generating` 完全一致(双轨):
|
||||
/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(含 Generating / Compressed 派生态)
|
||||
/// - off → 回退 `generating` bool
|
||||
/// 检查口径与 `ai_is_generating` 完全一致:
|
||||
/// - 读 `conv_state.is_active()`(含 Generating / Compressed 派生态,批3 收口后单一 enum 路径)
|
||||
///
|
||||
/// generating=true 则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。
|
||||
/// 生成中则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。
|
||||
/// (事件透传阶段2 已建通路,miniapp handleEvent 能渲染 AiError,用户可见反馈)。
|
||||
///
|
||||
/// message 必填,缺失跳过(ai_chat_send 无 message 无意义,日志告警)。
|
||||
@@ -738,9 +735,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
|
||||
/// 返 `None` 表示放行(可发)。
|
||||
///
|
||||
/// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293):
|
||||
/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,
|
||||
/// 压缩期间 loop 仍活跃)
|
||||
/// - off → 回退 `generating` bool
|
||||
/// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃;
|
||||
/// 批3 收口后单一 enum 路径,开关 + generating bool 已退役)
|
||||
///
|
||||
/// 目标 conv 解析(对齐 ai_is_generating:278-284):
|
||||
/// - 入参 conversation_id 非空优先
|
||||
@@ -762,16 +758,11 @@ async fn check_generating_reject(
|
||||
Some(id) => id,
|
||||
None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行
|
||||
};
|
||||
// 双轨读 generating(对齐 ai_is_generating,无 conv_read 视为 false 放行)。
|
||||
// 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。对齐 ai_is_generating,无 conv_read 视为 false 放行。
|
||||
let is_gen = session
|
||||
.conv_read(&target)
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
if is_gen {
|
||||
Some(target)
|
||||
@@ -932,8 +923,9 @@ mod tests {
|
||||
// ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ──
|
||||
//
|
||||
// check_generating_reject 需 `State<AppState>`,单元测试难构造。
|
||||
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟 generating 状态,
|
||||
// 复核 conv_read + CONV_STATE_ENABLED 口径对齐 ai_is_generating。
|
||||
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态,
|
||||
// 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。
|
||||
// (批3 收口后单一 enum 路径,开关 + generating bool 已退役。)
|
||||
|
||||
/// R1 判定:无目标 conv 且无 active → 放行(返 None)。
|
||||
#[test]
|
||||
@@ -950,108 +942,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(generating=false)。
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(conv_state=Idle,is_active()=false)。
|
||||
#[test]
|
||||
fn test_r1_target_idle_pass() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-idle");
|
||||
conv.generating = false;
|
||||
// 读 generating 口径(对齐 check_generating_reject / ai_is_generating)
|
||||
conv.conv_state = ConvState::Idle;
|
||||
// 批3 收口:读 conv_state.is_active() 口径(对齐 check_generating_reject / ai_is_generating)。
|
||||
// 开关已退役,直接读 enum 单一路径。
|
||||
let is_gen = session
|
||||
.conv_read("conv-idle")
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
assert!(!is_gen, "conv-idle 未在跑,is_gen 应为 false(放行)");
|
||||
assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)");
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(conv_state=Generating,is_active()=true)。
|
||||
#[test]
|
||||
fn test_r1_target_generating_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-busy");
|
||||
// 双轨一致:CONV_STATE_ENABLED on 时 R1 读 conv_state.is_active()(非 generating bool),
|
||||
// 须同步设 conv_state=Generating(对齐 test_r1_conv_state_active_reject 设态范式)
|
||||
// 批3 收口:generating bool 已退役,生成态经 conv_state=Generating 表达(单一真相源)。
|
||||
conv.conv_state = ConvState::Generating;
|
||||
conv.generating = true;
|
||||
let is_gen = session
|
||||
.conv_read("conv-busy")
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
assert!(is_gen, "conv-busy generating=true 应拒绝");
|
||||
assert!(is_gen, "conv-busy Generating 应拒绝");
|
||||
}
|
||||
|
||||
/// R1 判定:conv_state 派生态(Generating)is_active()=true(双轨 on 路径)。
|
||||
/// R1 判定:conv_state 派生态(Generating)is_active()=true(单一 enum 路径)。
|
||||
///
|
||||
/// 验证 CONV_STATE_ENABLED on 时读 conv_state.is_active() 而非 generating bool ——
|
||||
/// 即便 generating bool 误为 false 但 conv_state=Generating,仍应判定活跃拒绝。
|
||||
/// 验证批3 收口后读 conv_state.is_active() 判定活跃拒绝(enum 唯一真相源)。
|
||||
#[test]
|
||||
fn test_r1_conv_state_active_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
// CONV_STATE_ENABLED 是编译期 const(true),本测试假定 on 路径。
|
||||
// 若未来开关改 runtime,此测试需条件跳过 on 时才跑。
|
||||
// 批3 收口:开关已退役,enum 单一真相源,无条件 on 路径。
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-state-busy");
|
||||
// 模拟状态机写收敛路径:conv_state=Generating,generating bool 同步 true(双轨一致)
|
||||
// 模拟状态机写收敛路径:conv_state=Generating(单一真相源,无 generating bool 双轨)。
|
||||
conv.conv_state = ConvState::Generating;
|
||||
conv.generating = true;
|
||||
let is_gen = session
|
||||
.conv_read("conv-state-busy")
|
||||
.map(|c| {
|
||||
if CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
})
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
assert!(
|
||||
is_gen,
|
||||
"conv_state=Generating is_active() 应为 true(双轨 on 路径拒绝)"
|
||||
"conv_state=Generating is_active() 应为 true(单一 enum 路径拒绝)"
|
||||
);
|
||||
}
|
||||
|
||||
/// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。
|
||||
#[test]
|
||||
fn test_r1_target_input_priority() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
session.active_conversation_id = Some("conv-active".to_string());
|
||||
// active 在跑,但入参 conv-other 未跑 → 入参优先,应放行
|
||||
session.conv("conv-active").generating = true;
|
||||
let _ = session.conv("conv-other"); // 创建但不 generating
|
||||
// active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行
|
||||
session.conv("conv-active").conv_state = ConvState::Generating;
|
||||
let _ = session.conv("conv-other"); // 创建但 Idle(默认)
|
||||
let active_gen = session
|
||||
.conv_read("conv-active")
|
||||
.map(|c| c.generating)
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
let other_gen = session
|
||||
.conv_read("conv-other")
|
||||
.map(|c| c.generating)
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
assert!(active_gen, "conv-active 在跑");
|
||||
assert!(!other_gen, "conv-other 未跑");
|
||||
// 入参 conv-other 优先 → 判定 conv-other.generating=false → 放行
|
||||
// 入参 conv-other 优先 → 判定 conv-other Idle → 放行
|
||||
// (与 check_generating_reject 入参优先逻辑一致)
|
||||
}
|
||||
|
||||
// ── PerConvState 初值对齐(确保 R1 测试基线 generating=false) ──
|
||||
// ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ──
|
||||
|
||||
/// PerConvState::new() generating 初值 false(R1 放行基线)。
|
||||
/// PerConvState::new() conv_state 初值 Idle(R1 放行基线)。
|
||||
#[test]
|
||||
fn test_per_conv_state_generating_default_false() {
|
||||
fn test_per_conv_state_conv_state_default_idle() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused)
|
||||
let s = PerConvState::new();
|
||||
assert!(!s.generating, "PerConvState::new generating 初值应为 false");
|
||||
assert_eq!(s.conv_state, ConvState::Idle, "PerConvState::new conv_state 初值应为 Idle");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
//! 项目事件流相关命令(知识图谱 Phase 2 V30,对标设计 §2.4 + §五 get_project_timeline)
|
||||
//!
|
||||
//! 统一事件流 project_events 的查询入口。供 AI / Dashboard 项目时间线视图使用
|
||||
//! (设计 §10.3.4 模式 B 事件流 + §五 get_project_timeline)。
|
||||
//!
|
||||
//! 筛选策略:Repo::get_by_project 取最近 N 条(上限 200,对标设计 §10.5 单用户桌面应用
|
||||
//! 全量查询无压力),应用层按 event_type / 时间范围过滤。复杂多维筛选不进 SQL(数据量小,
|
||||
//! 应用层过滤足够 + 保持 Repo 专用方法精简,对标 TaskLinkRepo 全专用路径)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use df_storage::models::ProjectEventRecord;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::err_str;
|
||||
|
||||
/// get_project_timeline 查询入参(对标设计 §五 get_project_timeline:time 范围/event_type 筛选)。
|
||||
///
|
||||
/// 所有字段可选:
|
||||
/// - `project_id`:必填(查询某项目的事件流)。
|
||||
/// - `event_type`:可选,按事件类型精确过滤(如 "task_advanced");空/None = 不过滤。
|
||||
/// - `limit`:可选,返回上限(默认 50,Repo 内部钳制最大 200)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetTimelineInput {
|
||||
pub project_id: String,
|
||||
/// 事件类型过滤(精确匹配,如 "task_created" / "idea_promoted")。None/空串 = 不过滤。
|
||||
pub event_type: Option<String>,
|
||||
/// 返回条数上限(默认 50)。Repo 内部钳制 ≤ 200。
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
fn default_limit() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
/// 时间线条目(对标设计 §10.3.4 模式 B 事件流)。直接回 ProjectEventRecord(Serialize 已派生),
|
||||
/// 外层补 total 便于前端分页/计数。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TimelineResult {
|
||||
/// 过滤后的事件列表(时间倒序,Repo 已排)
|
||||
pub items: Vec<ProjectEventRecord>,
|
||||
/// 过滤后总数(≤ limit)
|
||||
pub total: usize,
|
||||
/// 查询的项目 ID(回显)
|
||||
pub project_id: String,
|
||||
}
|
||||
|
||||
/// 查询项目事件流(对标设计 §五 get_project_timeline + §2.4)。
|
||||
///
|
||||
/// 支持按 event_type 过滤 + limit 上限。时间范围过滤暂未实现(设计 §2.4 列出 time 范围筛选,
|
||||
/// 但当前 get_by_project 仅返回最近 N 条倒序,时间范围筛选用例可后续按需加 Repo 方法;
|
||||
/// 单用户桌面应用事件量小,最近 N 条 + event_type 过滤已覆盖主要场景)。
|
||||
#[tauri::command]
|
||||
pub async fn get_project_timeline(
|
||||
state: State<'_, AppState>,
|
||||
input: GetTimelineInput,
|
||||
) -> Result<TimelineResult, String> {
|
||||
let project_id = input.project_id.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
// event_type 过滤值规范化(None/空串 = 不过滤)
|
||||
let event_filter = input
|
||||
.event_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Repo 取最近 limit 条(内部钳制 ≤ 200),时间倒序。
|
||||
let mut events = state
|
||||
.project_events
|
||||
.get_by_project(&project_id, input.limit)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
// 应用层按 event_type 过滤(SQL 下推不值得——单用户事件量小,保持 Repo 方法精简)。
|
||||
if let Some(et) = &event_filter {
|
||||
events.retain(|e| e.event_type == *et);
|
||||
}
|
||||
|
||||
let total = events.len();
|
||||
Ok(TimelineResult {
|
||||
items: events,
|
||||
total,
|
||||
project_id,
|
||||
})
|
||||
}
|
||||
+112
-13
@@ -9,7 +9,7 @@ use df_ai::provider::LlmProvider;
|
||||
use df_types::types::{new_id, Priority};
|
||||
use df_ideas::capture::Idea;
|
||||
use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
|
||||
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectRecord};
|
||||
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, ProjectRecord};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -73,6 +73,15 @@ pub async fn create_idea(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateIdeaInput,
|
||||
) -> Result<IdeaRecord, String> {
|
||||
// IDEA-FIX-03: priority 值域校验 ∈ 0..=3 (对标 tasks B-260615-15)。
|
||||
// priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值,
|
||||
// 防 LLM/前端传 99 等被静默吞为 Critical。
|
||||
if !(0..=3).contains(&input.priority) {
|
||||
return Err(format!(
|
||||
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low,对齐前端约定 api/types.ts:141),收到 {}",
|
||||
input.priority
|
||||
));
|
||||
}
|
||||
let now = now_millis();
|
||||
let record = IdeaRecord {
|
||||
id: new_id(),
|
||||
@@ -95,6 +104,12 @@ pub async fn create_idea(
|
||||
.insert(record.clone())
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 知识图谱 Phase 2(对标设计 §2.4):idea_created 事件**暂不埋点**。
|
||||
// 原因:project_events.project_id 是 NOT NULL + FK(PRAGMA foreign_keys=ON),
|
||||
// 而 idea 无 project_id(立项前不属于任何项目)。设计 §2.5 计划用系统初始化创建的
|
||||
// Inbox 项目作为无主 idea 的归属,但 Inbox 项目尚未实现(独立任务)。
|
||||
// 写 NULL 会违反 NOT NULL,写不存在的 project_id 会违反 FK——两者都会让 best-effort
|
||||
// 退化成「写失败 warn」,无实际价值且噪音。Inbox 项目落地后此处补 idea_created 埋点。
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -113,10 +128,37 @@ pub async fn update_idea(
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 删除灵感
|
||||
/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
|
||||
#[tauri::command]
|
||||
pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.ideas.delete(&id).await.map_err(err_str)
|
||||
state.ideas.soft_delete(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 恢复灵感(从回收站还原,清 deleted_at)。对标 restore_task / restore_project。
|
||||
#[tauri::command]
|
||||
pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.ideas.restore(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
|
||||
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
|
||||
#[tauri::command]
|
||||
pub async fn relate_ideas(
|
||||
state: State<'_, AppState>,
|
||||
subject_id: String,
|
||||
target_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.ideas
|
||||
.sync_related_ids(&subject_id, &target_ids)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
|
||||
#[tauri::command]
|
||||
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
|
||||
state.ideas.list_deleted().await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 将灵感晋升为项目 — 复用 df-project 领域逻辑创建项目,回写灵感 status=promoted/promoted_to
|
||||
@@ -172,14 +214,39 @@ pub async fn promote_idea(
|
||||
..record
|
||||
};
|
||||
if let Err(e) = state.ideas.update_full(&updated).await {
|
||||
// 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项)
|
||||
// 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
|
||||
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
|
||||
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await {
|
||||
tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}");
|
||||
if let Err(del_err) = state.projects.soft_delete(&project_id).await {
|
||||
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
|
||||
}
|
||||
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
|
||||
}
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
|
||||
// 灵感此时已立项为新项目(project_id 存在,FK 满足),事件挂在 project_id 下,
|
||||
// entity 指向 idea,from_state=draft→to_state=promoted。
|
||||
let event = ProjectEventRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.clone(),
|
||||
event_type: "idea_promoted".to_string(),
|
||||
entity_type: Some("idea".to_string()),
|
||||
entity_id: Some(id.clone()),
|
||||
from_state: Some("draft".to_string()),
|
||||
to_state: Some("promoted".to_string()),
|
||||
context_json: None,
|
||||
source: Some("human".to_string()),
|
||||
conversation_id: None,
|
||||
created_at: now_millis(),
|
||||
};
|
||||
if let Err(e) = state.project_events.insert(event).await {
|
||||
tracing::warn!(
|
||||
idea_id = %id,
|
||||
project_id = %project_id,
|
||||
error = %e,
|
||||
"[事件流] idea_promoted 埋点写入失败(不阻断立项)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(df_ideas::promotion::PromotionResult {
|
||||
idea_id: id,
|
||||
project_id: project_id,
|
||||
@@ -377,28 +444,60 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
// IDEA-FIX-05: 读真实 related_ids(原硬编码 Vec::new() 丢关联上下文)。解析同 tags 模式。
|
||||
let related_ids: Vec<String> = match record.related_ids.as_deref() {
|
||||
Some(r) => match serde_json::from_str::<Vec<String>>(r) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, idea_id = %record.id, "[ideas] related_ids JSON 解析失败,降级空");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
Idea {
|
||||
id: record.id.clone(),
|
||||
title: record.title.clone(),
|
||||
description: record.description.clone(),
|
||||
status: df_types::types::IdeaStatus::Draft,
|
||||
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
|
||||
status: status_from_str(&record.status),
|
||||
priority: priority_from_i32(record.priority),
|
||||
scores: None,
|
||||
tags,
|
||||
source: record.source.clone(),
|
||||
related_ids: Vec::new(),
|
||||
related_ids,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// i32 优先级 → Priority 枚举(与 df-types 枚举值一致:Low=0/Medium=1/High=2/Critical=3)
|
||||
/// i32 优先级 → Priority 枚举(对齐**前端约定** 0=critical/1=high/2=medium/3=low,
|
||||
/// 见 api/types.ts:141 + Tasks.vue option + constants/project.ts PRIORITY_CLASS)。
|
||||
/// 注:df-types Priority 枚举 discriminant 是 Low=0/Critical=3(历史定义,与前端相反),
|
||||
/// 故本函数手动映射对齐前端语义,不靠 discriminant。Priority as_str 序列化("low"/"critical")
|
||||
/// 不受影响。verify agent 发现的预存跨层 bug(父⑤⑤.2 收尾揪出):原 0=>Low 致 Ideas
|
||||
/// 表单选 Critical(P0) 存 Low,标签与实际存储/评分相反。
|
||||
fn priority_from_i32(p: i32) -> Priority {
|
||||
match p {
|
||||
0 => Priority::Low,
|
||||
2 => Priority::High,
|
||||
x if x >= 3 => Priority::Critical,
|
||||
_ => Priority::Medium,
|
||||
0 => Priority::Critical,
|
||||
1 => Priority::High,
|
||||
2 => Priority::Medium,
|
||||
_ => Priority::Low,
|
||||
}
|
||||
}
|
||||
|
||||
/// IDEA-FIX-05: idea status 字符串(DB snake_case)→ IdeaStatus 枚举。
|
||||
/// record_to_idea 读真实 status 用(原硬编码 Draft 丢真实状态)。未知值 fallback Draft(同原行为)。
|
||||
fn status_from_str(s: &str) -> df_types::types::IdeaStatus {
|
||||
use df_types::types::IdeaStatus;
|
||||
match s {
|
||||
"draft" => IdeaStatus::Draft,
|
||||
"pending_review" => IdeaStatus::PendingReview,
|
||||
"approved" => IdeaStatus::Approved,
|
||||
"rejected" => IdeaStatus::Rejected,
|
||||
"promoted" => IdeaStatus::Promoted,
|
||||
"archived" => IdeaStatus::Archived,
|
||||
_ => IdeaStatus::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
||||
|
||||
pub mod ai;
|
||||
pub mod events;
|
||||
pub mod idea;
|
||||
pub mod knowledge;
|
||||
pub mod knowledge_timeline;
|
||||
pub mod project;
|
||||
pub mod services;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
pub mod workflow;
|
||||
|
||||
@@ -18,12 +18,49 @@ use df_project::scan::{
|
||||
normalize_path, DiscoveredProject,
|
||||
};
|
||||
use df_storage::crud::ProjectQuery;
|
||||
use df_storage::models::ProjectRecord;
|
||||
use df_storage::models::{ProjectEventRecord, ProjectRecord};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{err_str, now_millis};
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
|
||||
// ============================================================
|
||||
|
||||
/// 追加一条项目事件到 project_events(best-effort)。失败仅 tracing::warn 不阻断主 IPC。
|
||||
///
|
||||
/// `source` 标 `"human"`(IPC 层)。AI 工具路径在 tool_registry 内自行标 `"ai"`。
|
||||
async fn emit_project_event(
|
||||
state: &AppState,
|
||||
project_id: &str,
|
||||
event_type: &str,
|
||||
entity_type: Option<&str>,
|
||||
entity_id: Option<&str>,
|
||||
) {
|
||||
let record = ProjectEventRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.to_string(),
|
||||
event_type: event_type.to_string(),
|
||||
entity_type: entity_type.map(|s| s.to_string()),
|
||||
entity_id: entity_id.map(|s| s.to_string()),
|
||||
from_state: None,
|
||||
to_state: Some("planning".to_string()),
|
||||
context_json: None,
|
||||
source: Some("human".to_string()),
|
||||
conversation_id: None,
|
||||
created_at: now_millis(),
|
||||
};
|
||||
if let Err(e) = state.project_events.insert(record).await {
|
||||
tracing::warn!(
|
||||
event_type = event_type,
|
||||
project_id = project_id,
|
||||
error = %e,
|
||||
"[事件流] 埋点写入失败(不阻断主操作)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建项目入参
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProjectInput {
|
||||
@@ -132,6 +169,22 @@ async fn create_with_binding(
|
||||
if record.path.is_some() {
|
||||
state.reload_allowed_dirs().await;
|
||||
}
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):项目创建事件。best-effort 不阻断。
|
||||
//
|
||||
// 设计 §2.4 event_type 枚举未列 project_created(枚举非穷尽,Repo 不做白名单校验——
|
||||
// 见 project_event_repo.rs 注释「埋点层是唯一生产者,字符串字面量自带约束」)。此处沿用
|
||||
// task_created/idea_created 命名约定新增 project_created,语义自洽。entity_type=project。
|
||||
//
|
||||
// 注:灵感晋升(create_from_idea)在 promote_idea 单独记 idea_promoted 事件(更精确),
|
||||
// 此处一律记 project_created(项目实体自身视角),两事件互补不冲突。
|
||||
emit_project_event(
|
||||
state,
|
||||
&record.id,
|
||||
"project_created",
|
||||
Some("project"),
|
||||
Some(&record.id),
|
||||
)
|
||||
.await;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! 项目基础设施配置相关命令(知识图谱 Phase 3 V31,对标设计 §2.3 + §五 + D10 安全边界)
|
||||
//!
|
||||
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
|
||||
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
|
||||
//!
|
||||
//! 关键设计(对标 events.rs Phase 2 模式 + project.rs IPC 模式):
|
||||
//! - **service_type 白名单**:mysql/postgresql/sqlite/redis/mongodb/mq/api/other,
|
||||
//! 校验下沉 ProjectServiceRepo::insert_validated / update_full_validated(单一真相源)。
|
||||
//! - **D10 安全边界**:不存敏感凭证。ProjectServiceRepo 在 insert/update 时审查
|
||||
//! config_json / endpoint / remark 是否含 password/secret/token/api_key 子串,命中即拒。
|
||||
//! IPC 入参 schema 在此注明,config_json 不含密码 —— 凭证走环境变量。
|
||||
//! - create/update IPC 返回完整记录(对标 create_project),前端拿回 id 直接用。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use df_storage::crud::PROJECT_SERVICE_TYPES;
|
||||
use df_storage::models::ProjectServiceRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{err_str, now_millis};
|
||||
|
||||
// ============================================================
|
||||
// 入参 / 出参结构
|
||||
// ============================================================
|
||||
|
||||
/// 新增基础设施配置入参(对标设计 §五 add_project_service + D10 边界)。
|
||||
///
|
||||
/// ⚠️ **D10 安全边界**:不存敏感凭证(密码/密钥/Token)。config_json / endpoint / remark
|
||||
/// 含 password/secret/token/api_key 子串会被 Repo 层审查拒绝。凭证走环境变量,此处只存
|
||||
/// 连接信息(host/port/数据库名/连接池配置等元数据)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddProjectServiceInput {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
/// 基础设施类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other
|
||||
pub service_type: String,
|
||||
/// 连接地址(localhost:3306 / URL),可选。不含凭证(query 带 token= 会被拒)
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
/// 类型相关配置 JSON 字符串(如 {"pool_size":10}),可选。不含密码字段
|
||||
#[serde(default)]
|
||||
pub config_json: Option<String>,
|
||||
/// 环境 development/staging/production,默认 development
|
||||
#[serde(default = "default_environment")]
|
||||
pub environment: String,
|
||||
#[serde(default)]
|
||||
pub remark: Option<String>,
|
||||
}
|
||||
|
||||
/// 整体更新入参(对标 Repo::update_full_validated,保留 id 与 created_at)。
|
||||
/// 全字段必填(整体替换语义,与 IdeaRepo::update_full 一致)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProjectServiceInput {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub service_type: String,
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub config_json: Option<String>,
|
||||
#[serde(default = "default_environment")]
|
||||
pub environment: String,
|
||||
#[serde(default)]
|
||||
pub remark: Option<String>,
|
||||
}
|
||||
|
||||
/// 列表查询入参(对标设计 §五 list_project_services:按项目过滤,可选按环境过滤)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListProjectServicesInput {
|
||||
pub project_id: String,
|
||||
/// 可选,按环境过滤(development/staging/production)。空/None = 返回所有环境。
|
||||
#[serde(default)]
|
||||
pub environment: Option<String>,
|
||||
}
|
||||
|
||||
/// 列表返回(对标 events.rs TimelineResult,外层补 total 便于前端计数)。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListProjectServicesResult {
|
||||
pub items: Vec<ProjectServiceRecord>,
|
||||
pub total: usize,
|
||||
pub project_id: String,
|
||||
}
|
||||
|
||||
fn default_environment() -> String {
|
||||
"development".to_string()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令
|
||||
// ============================================================
|
||||
|
||||
/// 新增基础设施配置(对标设计 §五 add_project_service)。
|
||||
///
|
||||
/// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 AI 工具
|
||||
/// add_project_service 同源)。返回完整记录。
|
||||
#[tauri::command]
|
||||
pub async fn add_project_service(
|
||||
state: State<'_, AppState>,
|
||||
input: AddProjectServiceInput,
|
||||
) -> Result<ProjectServiceRecord, String> {
|
||||
let project_id = input.project_id.trim().to_string();
|
||||
let name = input.name.trim().to_string();
|
||||
let service_type = input.service_type.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err("name 不能为空".to_string());
|
||||
}
|
||||
if service_type.is_empty() {
|
||||
return Err("service_type 不能为空".to_string());
|
||||
}
|
||||
// service_type 白名单 fail-fast(给清晰错误,虽 Repo 层也会校验)
|
||||
if !PROJECT_SERVICE_TYPES.contains(&service_type.as_str()) {
|
||||
return Err(format!(
|
||||
"非法 service_type: {service_type},合法值: {:?}",
|
||||
PROJECT_SERVICE_TYPES
|
||||
));
|
||||
}
|
||||
|
||||
let record = ProjectServiceRecord {
|
||||
id: new_id(),
|
||||
project_id,
|
||||
name,
|
||||
service_type,
|
||||
endpoint: input
|
||||
.endpoint
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
config_json: input
|
||||
.config_json
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
environment: input.environment.trim().to_string(),
|
||||
remark: input
|
||||
.remark
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
||||
created_at: now_millis(),
|
||||
updated_at: now_millis(),
|
||||
};
|
||||
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
|
||||
let id = state
|
||||
.project_services
|
||||
.insert_validated(record)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
state
|
||||
.project_services
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| "插入后回读失败".to_string())
|
||||
}
|
||||
|
||||
/// 整体更新基础设施配置(保留 id 与 created_at,updated_at 由 Repo 内部覆盖)。
|
||||
///
|
||||
/// service_type 变更须走此整体更新路径(对标 Repo 注释:update_field 白名单不含 service_type,
|
||||
/// 防旁路 update_field 改类型)。D10 凭证审查下沉 update_full_validated。
|
||||
#[tauri::command]
|
||||
pub async fn update_project_service(
|
||||
state: State<'_, AppState>,
|
||||
input: UpdateProjectServiceInput,
|
||||
) -> Result<ProjectServiceRecord, String> {
|
||||
let id = input.id.trim().to_string();
|
||||
let project_id = input.project_id.trim().to_string();
|
||||
let name = input.name.trim().to_string();
|
||||
let service_type = input.service_type.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
if project_id.is_empty() || name.is_empty() || service_type.is_empty() {
|
||||
return Err("project_id / name / service_type 不能为空".to_string());
|
||||
}
|
||||
if !PROJECT_SERVICE_TYPES.contains(&service_type.as_str()) {
|
||||
return Err(format!(
|
||||
"非法 service_type: {service_type},合法值: {:?}",
|
||||
PROJECT_SERVICE_TYPES
|
||||
));
|
||||
}
|
||||
|
||||
// 先校验存在(404 友好错误),再整体更新
|
||||
let existing = state
|
||||
.project_services
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("基础设施配置 {id} 不存在"))?;
|
||||
|
||||
let record = ProjectServiceRecord {
|
||||
id: id.clone(),
|
||||
project_id,
|
||||
name,
|
||||
service_type,
|
||||
endpoint: input
|
||||
.endpoint
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
config_json: input
|
||||
.config_json
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
environment: input.environment.trim().to_string(),
|
||||
remark: input
|
||||
.remark
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// 保留原 created_at(update_full_validated 不覆盖 created_at)
|
||||
created_at: existing.created_at,
|
||||
updated_at: now_millis(),
|
||||
};
|
||||
let hit = state
|
||||
.project_services
|
||||
.update_full_validated(&record)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !hit {
|
||||
return Err(format!("基础设施配置 {id} 不存在(更新未命中)"));
|
||||
}
|
||||
state
|
||||
.project_services
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| "更新后回读失败".to_string())
|
||||
}
|
||||
|
||||
/// 删除基础设施配置(按 id 物理删,对标 Repo::delete)。
|
||||
#[tauri::command]
|
||||
pub async fn remove_project_service(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let id = id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
state.project_services.delete(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 查询项目基础设施配置(对标设计 §五 list_project_services)。
|
||||
///
|
||||
/// 按项目过滤,可选按环境过滤(production 部署任务只取 production 服务,开发调试取 development,
|
||||
/// 环境隔离避免误连生产库)。返回时间倒序列表。
|
||||
#[tauri::command]
|
||||
pub async fn list_project_services(
|
||||
state: State<'_, AppState>,
|
||||
input: ListProjectServicesInput,
|
||||
) -> Result<ListProjectServicesResult, String> {
|
||||
let project_id = input.project_id.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
let env_filter = input
|
||||
.environment
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let items = if let Some(env) = &env_filter {
|
||||
state
|
||||
.project_services
|
||||
.list_by_project_env(&project_id, env)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
} else {
|
||||
state
|
||||
.project_services
|
||||
.list_by_project(&project_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
};
|
||||
let total = items.len();
|
||||
Ok(ListProjectServicesResult {
|
||||
items,
|
||||
total,
|
||||
project_id,
|
||||
})
|
||||
}
|
||||
@@ -47,3 +47,9 @@ pub async fn settings_delete(
|
||||
) -> Result<bool, String> {
|
||||
state.settings.delete(&key).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 获取 DevFlow 数据目录路径(运行期确定,跨平台)
|
||||
#[tauri::command]
|
||||
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
|
||||
Ok(state.data_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
//! 任务相关命令
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use df_types::types::{new_id, TaskStatus};
|
||||
use df_storage::crud::TaskQuery;
|
||||
use df_storage::models::TaskRecord;
|
||||
use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{err_str, now_millis};
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
|
||||
// ============================================================
|
||||
|
||||
/// 追加一条项目事件到 project_events(best-effort)。
|
||||
///
|
||||
/// 埋点策略(设计 §2.4):事件写入失败**不阻断主操作**,仅 `tracing::warn` 记录。
|
||||
/// 设计 §10.1「事件流写入失败 — 影响事件完整性 — 事件写入失败不阻断主操作(best-effort)」。
|
||||
///
|
||||
/// `source` 语义:本辅助仅由 IPC 层调用,标 `"human"`(AI 工具路径在 tool_registry 内自行标
|
||||
/// `"ai"`,不经本 IPC)。`project_id` / `entity_type` / `entity_id` / `event_type` 由调用方
|
||||
/// 提供。`from_state` / `to_state` / `context_json` 可选。
|
||||
async fn emit_event(
|
||||
state: &State<'_, AppState>,
|
||||
project_id: &str,
|
||||
event_type: &str,
|
||||
entity_type: Option<&str>,
|
||||
entity_id: Option<&str>,
|
||||
from_state: Option<&str>,
|
||||
to_state: Option<&str>,
|
||||
) {
|
||||
let record = ProjectEventRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.to_string(),
|
||||
event_type: event_type.to_string(),
|
||||
entity_type: entity_type.map(|s| s.to_string()),
|
||||
entity_id: entity_id.map(|s| s.to_string()),
|
||||
from_state: from_state.map(|s| s.to_string()),
|
||||
to_state: to_state.map(|s| s.to_string()),
|
||||
context_json: None,
|
||||
source: Some("human".to_string()),
|
||||
conversation_id: None,
|
||||
created_at: now_millis(),
|
||||
};
|
||||
if let Err(e) = state.project_events.insert(record).await {
|
||||
tracing::warn!(
|
||||
event_type = event_type,
|
||||
project_id = project_id,
|
||||
error = %e,
|
||||
"[事件流] 埋点写入失败(不阻断主操作)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建任务入参
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTaskInput {
|
||||
@@ -25,12 +69,94 @@ pub struct CreateTaskInput {
|
||||
/// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。
|
||||
/// 空字符串视为不关联(与 AI 工具层一致)。
|
||||
pub idea_id: Option<String>,
|
||||
/// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。
|
||||
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
|
||||
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
|
||||
#[serde(default = "default_queue")]
|
||||
pub queue: String,
|
||||
/// 父任务 ID(知识图谱 Phase 1 V29)。默认 None = 叶子任务。
|
||||
/// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。
|
||||
/// 空字符串视为 None(向后兼容)。
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)。
|
||||
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
|
||||
/// None = 无结构化规格(纯文本 description)。
|
||||
#[serde(default)]
|
||||
pub content_json: Option<String>,
|
||||
}
|
||||
|
||||
fn default_priority() -> i32 {
|
||||
2 // medium — 新任务默认中优先级(非 high),符合常识
|
||||
}
|
||||
|
||||
/// queue 默认值(serde default,对标 DB DEFAULT 'todo')
|
||||
fn default_queue() -> String {
|
||||
"todo".to_string()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:queue 白名单 + queue/status 一致性约束(IPC 层校验,对标设计 §2.1)
|
||||
// ============================================================
|
||||
|
||||
/// queue 合法值白名单(对标设计 §2.1 queue 字段语义)。
|
||||
/// 不走 TaskStatus 枚举(queue 是独立的管理维度,与 status 执行维度正交)。
|
||||
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
|
||||
|
||||
/// queue 执行中池(active 时 status 必须属于执行中三态之一,对标设计 §2.1 一致性约束)
|
||||
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
|
||||
|
||||
/// 校验 queue 值在白名单内,否则返回 Err(防拼写漂移/非法值进库)。
|
||||
fn validate_queue(queue: &str) -> Result<(), String> {
|
||||
if TASK_QUEUE_VALUES.contains(&queue) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"非法 queue 值 {:?},合法值: {:?}",
|
||||
queue, TASK_QUEUE_VALUES
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// queue/status 一致性约束校验(对标设计 §2.1,IPC 层校验不进状态机)。
|
||||
///
|
||||
/// 规则(设计 §2.1「queue 与 status 的关系」一致性约束):
|
||||
/// - queue=done 时 status 必须=done
|
||||
/// - queue=backlog 时 status 必须=todo
|
||||
/// - queue=active 时 status ∈ {in_progress, in_review, testing}
|
||||
/// - status=blocked 时 queue 可为 decision 或 active(本规则约束 queue 赋值场景,不在此单独拦)
|
||||
/// - queue=todo 时 status=todo(默认);queue=decision 时无 status 强约束(待决策池可任意 status)
|
||||
///
|
||||
/// 注:create_task 仅校验 queue(新建任务 status 恒 todo),完整约束在 move_task_queue 落实。
|
||||
fn assert_queue_status_consistent(queue: &str, status: &str) -> Result<(), String> {
|
||||
match queue {
|
||||
"done" => {
|
||||
if status != "done" {
|
||||
return Err(format!(
|
||||
"一致性约束违反:queue=done 要求 status=done,当前 status={status:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
"backlog" => {
|
||||
if status != "todo" {
|
||||
return Err(format!(
|
||||
"一致性约束违反:queue=backlog 要求 status=todo,当前 status={status:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
"active" => {
|
||||
if !ACTIVE_OK_STATUSES.contains(&status) {
|
||||
return Err(format!(
|
||||
"一致性约束违反:queue=active 要求 status ∈ {:?},当前 status={status:?}",
|
||||
ACTIVE_OK_STATUSES
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {} // todo / decision 无 status 强约束
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出未删除任务(deleted_at IS NULL)。
|
||||
///
|
||||
/// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id)
|
||||
@@ -92,11 +218,66 @@ pub async fn get_task_by_id(
|
||||
}
|
||||
|
||||
/// 创建任务,返回完整记录
|
||||
///
|
||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1):扩展 queue/parent_id/content_json 可选参数(向后兼容,
|
||||
/// 旧调用方不传等价改造前行为)。三个新参数的 IPC 层校验:
|
||||
/// - `queue`:白名单校验(validate_queue)+ queue/status 一致性(create_task 时 status 恒 todo,
|
||||
/// 仅 backlog/todo/decision 合法;active/done 需经 move_task_queue 或 advance_task 流转)。
|
||||
/// - `parent_id`:1 级嵌套铁律(对标设计 §2.1 D2)。父任务自身不能有 parent_id(拒绝创建孙任务);
|
||||
/// 父任务不存在则拒(防悬空 parent_id)。空字符串视为 None(向后兼容)。
|
||||
/// - `content_json`:仅做轻量 JSON 合法性校验(非空时必须是合法 JSON),结构细节由 AI 消费层负责。
|
||||
#[tauri::command]
|
||||
pub async fn create_task(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateTaskInput,
|
||||
) -> Result<TaskRecord, String> {
|
||||
// ── queue 校验(白名单 + 空串默认 todo)──
|
||||
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
|
||||
let queue = if input.queue.trim().is_empty() {
|
||||
"todo".to_string()
|
||||
} else {
|
||||
let q = input.queue.trim();
|
||||
validate_queue(q)?;
|
||||
q.to_string()
|
||||
};
|
||||
// queue/status 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法。
|
||||
// active/done 必须经 move_task_queue 流转,新建直接落 active/done 违反一致性约束。
|
||||
assert_queue_status_consistent(&queue, "todo")?;
|
||||
|
||||
// ── parent_id 校验(1 级嵌套铁律,对标设计 §2.1 D2)──
|
||||
// 空字符串/纯空白视为 None(向后兼容)
|
||||
let parent_id = input
|
||||
.parent_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(pid) = &parent_id {
|
||||
let parent = state
|
||||
.tasks
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
|
||||
// 1 级嵌套铁律:父任务自身有 parent_id → 它是子任务 → 拒绝在其下创建孙任务。
|
||||
if parent.parent_id.is_some() {
|
||||
return Err(format!(
|
||||
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
|
||||
parent.parent_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── content_json 轻量校验(非空时须是合法 JSON)──
|
||||
let content_json = match &input.content_json {
|
||||
Some(c) if !c.trim().is_empty() => {
|
||||
// 校验合法 JSON(防脏数据/截断串进库);结构细节由 AI 消费层负责
|
||||
serde_json::from_str::<serde_json::Value>(c)
|
||||
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
|
||||
Some(c.clone())
|
||||
}
|
||||
_ => None, // 空串/None 视为无结构化规格
|
||||
};
|
||||
|
||||
let now = now_millis();
|
||||
let record = TaskRecord {
|
||||
id: new_id(),
|
||||
@@ -113,6 +294,10 @@ pub async fn create_task(
|
||||
output_json: None,
|
||||
// F-260619-01:空字符串视为不关联(与 AI 工具层一致)
|
||||
idea_id: input.idea_id.filter(|s| !s.is_empty()),
|
||||
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
|
||||
queue,
|
||||
parent_id,
|
||||
content_json,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -121,6 +306,17 @@ pub async fn create_task(
|
||||
.insert(record.clone())
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_created 事件。best-effort 不阻断。
|
||||
emit_event(
|
||||
&state,
|
||||
&record.project_id,
|
||||
"task_created",
|
||||
Some("task"),
|
||||
Some(&record.id),
|
||||
None,
|
||||
Some(&record.status),
|
||||
)
|
||||
.await;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -168,6 +364,30 @@ pub async fn update_task(
|
||||
return Err(format!("非法 project_id 值 {:?},目标项目不存在", value));
|
||||
}
|
||||
}
|
||||
// parent_id 1 级嵌套铁律(对标 create_task L203-221,verify agent 发现的 P1:update_task 绕过)。
|
||||
// parent_id 在 tasks 白名单(设计 §五 move/update 路径),但 1 级铁律须 IPC 层校验,否则
|
||||
// update_task(id,"parent_id",X) 可创建孙任务(X 自身有 parent_id)或悬空(X 不存在)或自环。
|
||||
if field == "parent_id" {
|
||||
let new_pid = value.trim();
|
||||
if !new_pid.is_empty() {
|
||||
if new_pid == id {
|
||||
return Err("非法 parent_id:不能将任务设为自身的父任务(自环)".to_string());
|
||||
}
|
||||
let parent = state
|
||||
.tasks
|
||||
.get_by_id(new_pid)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("非法 parent_id 值 {:?},父任务不存在", value))?;
|
||||
if parent.parent_id.is_some() {
|
||||
return Err(format!(
|
||||
"违反 1 级嵌套铁律:父任务 {new_pid} 自身是子任务(parent_id={:?}),不允许挂孙任务",
|
||||
parent.parent_id
|
||||
));
|
||||
}
|
||||
}
|
||||
// 空字符串 = None(解除父),合法放行
|
||||
}
|
||||
state
|
||||
.tasks
|
||||
.update_field(&id, &field, &value)
|
||||
@@ -195,6 +415,11 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
|
||||
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
|
||||
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
|
||||
///
|
||||
/// **知识图谱 Phase 1 V29 父聚合(对标设计 §2.1)**:推进完成后,若推进的任务有 parent_id,
|
||||
/// 触发父任务 status 重算(recompute_parent_status)。父任务=容器模型,status 不走状态机,
|
||||
/// 由子任务聚合计算(聚合规则见 recompute_parent_status)。聚合失败不阻断推进(best-effort,
|
||||
/// 对标设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
|
||||
///
|
||||
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
||||
#[tauri::command]
|
||||
pub async fn advance_task(
|
||||
@@ -202,7 +427,351 @@ pub async fn advance_task(
|
||||
id: String,
|
||||
target_status: String,
|
||||
) -> Result<TaskRecord, String> {
|
||||
df_nodes::task_advance_node::advance_task_atomic(&state.tasks, &id, &target_status)
|
||||
// 知识图谱 Phase 2:推进前读当前态,作 task_advanced 事件 from_state(仅一次轻量读,
|
||||
// advance 低频无压力)。失败(任务不存在)不阻断——后续 atomic 会用 NotFound 拒绝,from 留空。
|
||||
let from_state = state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.map(|t| t.status);
|
||||
|
||||
let updated = df_nodes::task_advance_node::advance_task_atomic(
|
||||
&state.tasks,
|
||||
&id,
|
||||
&target_status,
|
||||
)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
// 父聚合(知识图谱 Phase 1 V29):推进的子任务有 parent_id → 重算父 status。
|
||||
// best-effort:聚合失败不阻断推进(子任务已成功推进是主结果,父 status 漂移可后续修正),
|
||||
// 仅 warn 日志记录。父任务 status 不走状态机,经专用方法 set_status_for_aggregation
|
||||
// 直接写(绕过 D-260616-04 status 收口:父任务=容器模型,聚合规则是唯一非状态机写入路径)。
|
||||
if let Some(pid) = &updated.parent_id {
|
||||
if let Err(e) = recompute_parent_status(&state, pid).await {
|
||||
tracing::warn!(
|
||||
task_id = %id,
|
||||
parent_id = %pid,
|
||||
error = %e,
|
||||
"[父聚合] 重算父任务 status 失败(不阻断子任务推进)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
|
||||
emit_event(
|
||||
&state,
|
||||
&updated.project_id,
|
||||
"task_advanced",
|
||||
Some("task"),
|
||||
Some(&updated.id),
|
||||
from_state.as_deref(),
|
||||
Some(&updated.status),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:父任务聚合(对标设计 §2.1 父聚合规则)
|
||||
// ============================================================
|
||||
|
||||
/// 父任务 status 重算(对标设计 §2.1 聚合规则,D3 父任务=容器模型)。
|
||||
///
|
||||
/// 聚合规则(设计 §2.1「父任务推导 status」表,优先级从高到低):
|
||||
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
|
||||
/// 2. 任一子 in_progress → 父 in_progress(执行中)
|
||||
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
|
||||
/// 4. 全子 todo → 父 todo(尚未开始)
|
||||
/// 5. 其他混合态(如 todo+done) → 父 in_progress(进行中,有进展未全完)
|
||||
///
|
||||
/// 触发时机:advance_task 子任务推进成功后,若子任务有 parent_id 则调本函数。
|
||||
/// 数据源:count_children_by_status(一次 GROUP BY 查询,数据量小无压力)。
|
||||
/// 写入:set_status_for_aggregation(父任务 status 唯一非状态机写入路径)。
|
||||
///
|
||||
/// 返回:重算后的父任务最新 status(若与当前相同则不写,返当前值)。
|
||||
async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -> Result<String, String> {
|
||||
let counts = state
|
||||
.tasks
|
||||
.count_children_by_status(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status
|
||||
if counts.is_empty() {
|
||||
return state
|
||||
.tasks
|
||||
.get_by_id(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.map(|t| t.status)
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"));
|
||||
}
|
||||
|
||||
// 转 HashMap<status, count> 便于按规则判定
|
||||
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
|
||||
let total: i64 = map.values().sum();
|
||||
let blocked = map.get("blocked").copied().unwrap_or(0);
|
||||
let in_progress = map.get("in_progress").copied().unwrap_or(0);
|
||||
let todo = map.get("todo").copied().unwrap_or(0);
|
||||
let done = map.get("done").copied().unwrap_or(0);
|
||||
let cancelled = map.get("cancelled").copied().unwrap_or(0);
|
||||
|
||||
// 聚合规则判定(优先级从高到低,首个命中即定)
|
||||
let new_status = if blocked > 0 {
|
||||
"blocked".to_string()
|
||||
} else if in_progress > 0 {
|
||||
"in_progress".to_string()
|
||||
} else if (done + cancelled) == total {
|
||||
// 全 done/cancelled → done(终端态聚合为 done)
|
||||
"done".to_string()
|
||||
} else if todo == total {
|
||||
// 全 todo → todo(尚未开始)
|
||||
"todo".to_string()
|
||||
} else {
|
||||
// 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中)
|
||||
"in_progress".to_string()
|
||||
};
|
||||
|
||||
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
|
||||
if current.status == new_status {
|
||||
return Ok(new_status);
|
||||
}
|
||||
state
|
||||
.tasks
|
||||
.set_status_for_aggregation(parent_id, &new_status)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(new_status)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
|
||||
// ============================================================
|
||||
|
||||
/// 创建任务横向关联(对标设计 §2.2,AI 拓扑排序编排调度的基础)。
|
||||
///
|
||||
/// 调 TaskLinkRepo::create_link(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。
|
||||
/// 循环依赖 / 自环 / 非法 link_type 在 repo 层拒绝(对标 D8)。
|
||||
///
|
||||
/// 参数:
|
||||
/// - `task_id`:source 任务 ID(关联发起方)
|
||||
/// - `target_id`:target 任务 ID(关联指向方)
|
||||
/// - `link_type`:depends_on / blocks / relates_to(白名单校验)
|
||||
/// - `remark`:可选备注
|
||||
///
|
||||
/// 返回:插入的 link id。
|
||||
#[tauri::command]
|
||||
pub async fn create_task_link(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
target_id: String,
|
||||
link_type: String,
|
||||
remark: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let id = new_id();
|
||||
state
|
||||
.task_links
|
||||
.create_link(&id, &task_id, &target_id, &link_type, remark.as_deref())
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 删除任务关联(按 link id,对标设计 §2.2)。返回是否命中。
|
||||
#[tauri::command]
|
||||
pub async fn remove_task_link(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.task_links.delete(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 任务关联查询入参方向(对标设计 §2.2 + §五 list_task_links)。
|
||||
///
|
||||
/// - `Outgoing`:task_id 作为 source 查其声明的全部关联(我依赖谁/我阻塞谁)
|
||||
/// - `Incoming`:task_id 作为 target 查谁指向它(谁依赖我/谁被我阻塞,反向查询)
|
||||
/// - `Both`:双向合并(去重),全量关联视图
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LinkDirection {
|
||||
Outgoing,
|
||||
Incoming,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl Default for LinkDirection {
|
||||
fn default() -> Self {
|
||||
LinkDirection::Both
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询任务关联(AI 编排调度用,对标设计 §2.2 + §五 list_task_links)。
|
||||
///
|
||||
/// 参数:
|
||||
/// - `task_id`:目标任务 ID
|
||||
/// - `direction`:查询方向(Outgoing=作为 source / Incoming=作为 target / Both=双向合并),
|
||||
/// 默认 Both(全量关联视图)。空字符串/未传走默认 Both(向后兼容)。
|
||||
///
|
||||
/// 返回:关联记录列表(TaskLinkRecord,按 created_at 升序)。
|
||||
#[tauri::command]
|
||||
pub async fn list_task_links(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
direction: Option<LinkDirection>,
|
||||
) -> Result<Vec<TaskLinkRecord>, String> {
|
||||
let dir = direction.unwrap_or_default();
|
||||
match dir {
|
||||
LinkDirection::Outgoing => state.task_links.get_by_source(&task_id).await.map_err(err_str),
|
||||
LinkDirection::Incoming => state.task_links.get_by_target(&task_id).await.map_err(err_str),
|
||||
// 双向合并:取 Outgoing + Incoming 后按 id 去重(同一 link 在双向查询中可能出现两次)
|
||||
LinkDirection::Both => {
|
||||
let mut outgoing = state.task_links.get_by_source(&task_id).await.map_err(err_str)?;
|
||||
let incoming = state.task_links.get_by_target(&task_id).await.map_err(err_str)?;
|
||||
// 按 id 去重(Outgoing 优先保留,incoming 中 id 未出现的才追加)
|
||||
let mut seen: std::collections::HashSet<String> =
|
||||
outgoing.iter().map(|l| l.id.clone()).collect();
|
||||
for l in incoming {
|
||||
if seen.insert(l.id.clone()) {
|
||||
outgoing.push(l);
|
||||
}
|
||||
}
|
||||
// 按 created_at 升序(合并后重排,与单向查询排序一致)
|
||||
outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Ok(outgoing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:move_task_queue(跨池移动 + 一致性约束,对标设计 §2.1 + §五)
|
||||
// ============================================================
|
||||
|
||||
/// 跨池移动任务(对标设计 §2.1 queue 字段语义 + §五 move_task_queue)。
|
||||
///
|
||||
/// queue 是管理维度池(backlog/todo/decision/active/done),与 status(执行维度)正交。
|
||||
/// move_task_queue 改 queue,同时按一致性约束联动调整 status(对标设计 §2.1):
|
||||
/// - queue=done → status 强制=done(池完成即任务完成)
|
||||
/// - queue=backlog → status 强制=todo(需求池任务尚未开始)
|
||||
/// - queue=active → status 若不在 {in_progress,in_review,testing} 则强制=in_progress
|
||||
/// - queue=todo → status 若非 todo 则强制=todo(待办池任务尚未开始)
|
||||
/// - queue=decision → status 不变(待决策池可保留任意执行态,暂停推进但执行态保留)
|
||||
///
|
||||
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
|
||||
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
|
||||
///
|
||||
/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口,
|
||||
/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。
|
||||
#[tauri::command]
|
||||
pub async fn move_task_queue(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
new_queue: String,
|
||||
) -> Result<TaskRecord, String> {
|
||||
let new_queue = new_queue.trim().to_string();
|
||||
validate_queue(&new_queue)?;
|
||||
|
||||
// 读当前任务(取当前 status 做一致性联动决策)
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 {id} 不存在"))?;
|
||||
|
||||
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
|
||||
let new_status = match new_queue.as_str() {
|
||||
"done" => "done".to_string(),
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.clone() // 已在执行中三态,保留
|
||||
} else {
|
||||
"in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态)
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
|
||||
"decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态)
|
||||
_ => unreachable!("validate_queue 已收口"),
|
||||
};
|
||||
|
||||
// 写 queue:走通用 update_field(queue 已在 tasks 白名单登记,知识图谱 Phase 1 V29 新增)。
|
||||
// 与 current.queue 不同才写(避免无谓 updated_at 抖动)。
|
||||
if current.queue != new_queue {
|
||||
state
|
||||
.tasks
|
||||
.update_field(&id, "queue", &new_queue)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
|
||||
if current.status != new_status {
|
||||
state
|
||||
.tasks
|
||||
.set_status_for_aggregation(&id, &new_status)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
}
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
||||
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。from/to 用 queue 值。
|
||||
if current.queue != new_queue {
|
||||
emit_event(
|
||||
&state,
|
||||
¤t.project_id,
|
||||
"task_advanced",
|
||||
Some("task"),
|
||||
Some(¤t.id),
|
||||
Some(¤t.queue),
|
||||
Some(&new_queue),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 回读最新记录返回
|
||||
state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)"))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 1:get_task_tree(父子任务树,对标设计 §2.1 + §五)
|
||||
// ============================================================
|
||||
|
||||
/// 任务树节点(父 + 子任务列表,对标设计 §2.1 限 1 级嵌套)。
|
||||
///
|
||||
/// 设计限 1 级嵌套(无孙任务,D2),故树结构是扁平的「父 + 直接子任务列表」,无需递归。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TaskTreeNode {
|
||||
/// 父任务(根节点,可能是叶子任务无子)
|
||||
pub parent: TaskRecord,
|
||||
/// 直接子任务列表(parent_id 指向 parent 的任务,按 created_at 升序)。叶子任务时为空。
|
||||
pub children: Vec<TaskRecord>,
|
||||
}
|
||||
|
||||
/// 获取任务父子树(对标设计 §2.1 + §五 get_task_tree)。
|
||||
///
|
||||
/// 限 1 级嵌套(设计 D2:无孙任务),故本方法取「指定任务 + 其直接子任务」,不递归。
|
||||
///
|
||||
/// 参数 `parent_id`:任务 ID(无论它是叶子还是父任务,都返回其自身 + 子任务列表)。
|
||||
/// AI 用途:查看需求分解结构、子任务进度聚合(设计 §六 ⑥⑦ AI 自检 + 父聚合)。
|
||||
#[tauri::command]
|
||||
pub async fn get_task_tree(
|
||||
state: State<'_, AppState>,
|
||||
parent_id: String,
|
||||
) -> Result<TaskTreeNode, String> {
|
||||
let parent = state
|
||||
.tasks
|
||||
.get_by_id(&parent_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("任务 {parent_id} 不存在"))?;
|
||||
let children = state.tasks.get_children(&parent_id).await.map_err(err_str)?;
|
||||
Ok(TaskTreeNode { parent, children })
|
||||
}
|
||||
|
||||
+32
-5
@@ -23,8 +23,8 @@ pub fn run() {
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let db_path = data_dir.join(db_name);
|
||||
|
||||
// 初始化全局状态(打开数据库 + 执行迁移)
|
||||
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?;
|
||||
// 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
|
||||
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
|
||||
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
|
||||
if let Err(e) = tauri::async_runtime::block_on(
|
||||
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
|
||||
@@ -45,17 +45,27 @@ pub fn run() {
|
||||
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
|
||||
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
|
||||
// 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。
|
||||
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原
|
||||
// generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。
|
||||
let dirty_convs: Vec<String> = session
|
||||
.per_conv
|
||||
.iter()
|
||||
.filter(|(_, c)| c.generating)
|
||||
.filter(|(_, c)| c.conv_state.is_active())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
let was_generating = !dirty_convs.is_empty();
|
||||
// 复位每个残留 generating 的 conv(批8 多 conv 全覆盖)。
|
||||
// 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。
|
||||
for cid in &dirty_convs {
|
||||
if let Some(conv) = session.per_conv.get_mut(cid) {
|
||||
conv.generating = false;
|
||||
match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
|
||||
@@ -245,11 +255,27 @@ pub fn run() {
|
||||
commands::task::restore_task,
|
||||
commands::task::get_task_by_id,
|
||||
commands::task::advance_task,
|
||||
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
|
||||
commands::task::create_task_link,
|
||||
commands::task::remove_task_link,
|
||||
commands::task::list_task_links,
|
||||
commands::task::move_task_queue,
|
||||
commands::task::get_task_tree,
|
||||
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
|
||||
commands::events::get_project_timeline,
|
||||
// 知识图谱 Phase 3(对标设计 §五 + §2.3 + D10):项目基础设施配置 CRUD
|
||||
commands::services::add_project_service,
|
||||
commands::services::update_project_service,
|
||||
commands::services::remove_project_service,
|
||||
commands::services::list_project_services,
|
||||
// 灵感
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
commands::idea::update_idea,
|
||||
commands::idea::relate_ideas,
|
||||
commands::idea::delete_idea,
|
||||
commands::idea::restore_idea,
|
||||
commands::idea::list_deleted_ideas,
|
||||
commands::idea::evaluate_idea,
|
||||
commands::idea::list_idea_evaluations,
|
||||
commands::idea::promote_idea,
|
||||
@@ -327,6 +353,7 @@ pub fn run() {
|
||||
commands::settings::settings_set,
|
||||
commands::settings::settings_get_all,
|
||||
commands::settings::settings_delete,
|
||||
commands::settings::get_data_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+127
-46
@@ -12,8 +12,8 @@ use tokio::sync::{Mutex, RwLock, Semaphore};
|
||||
use df_ai::ai_tools::AiToolRegistry;
|
||||
use df_storage::crud::{
|
||||
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo,
|
||||
SettingsRepo, TaskRepo, WorkflowRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
|
||||
ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
||||
};
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
@@ -337,6 +337,16 @@ pub struct AppState {
|
||||
pub projects: ProjectRepo,
|
||||
/// 任务表 Repo
|
||||
pub tasks: TaskRepo,
|
||||
/// 任务横向关联表 Repo(知识图谱 Phase 1 V29,AI 拓扑排序编排调度的基础)
|
||||
pub task_links: TaskLinkRepo,
|
||||
/// 项目统一事件流表 Repo(知识图谱 Phase 2 V30,project_events 追加型审计表)。
|
||||
/// 埋点 best-effort:事件写入失败不阻断主操作(设计 §10.1),commands 层 hook/after 追加。
|
||||
pub project_events: ProjectEventRepo,
|
||||
/// 项目基础设施配置表 Repo(知识图谱 Phase 3 V31,project_services)。
|
||||
/// 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文
|
||||
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
|
||||
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
|
||||
pub project_services: ProjectServiceRepo,
|
||||
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||||
#[allow(dead_code)]
|
||||
pub releases: ReleaseRepo,
|
||||
@@ -383,6 +393,8 @@ pub struct AppState {
|
||||
pub knowledge_events: KnowledgeEventsRepo,
|
||||
/// 知识库行为配置(提取 + 注入)
|
||||
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
|
||||
/// 应用数据目录(运行期确定:Windows $APPDATA/devflow, macOS ~/Library/Application Support/devflow)
|
||||
pub data_dir: PathBuf,
|
||||
/// 通用应用设置 KV Repo(前端 localStorage 迁移目标)
|
||||
pub settings: SettingsRepo,
|
||||
// ── LLM 并发控制 ──
|
||||
@@ -436,29 +448,39 @@ pub struct AllowedDirs {
|
||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||
pub persistent: HashSet<PathBuf>,
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。
|
||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||
pub session: HashSet<PathBuf>,
|
||||
/// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs
|
||||
/// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。
|
||||
/// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。
|
||||
pub once: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl AllowedDirs {
|
||||
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
|
||||
pub const SETTINGS_KEY: &'static str = "allowed_dirs";
|
||||
|
||||
/// 仅含 workspace_root 的默认白名单(向后兼容:无 allowed_dirs 时行为不变)。
|
||||
/// 用于 mod.rs trust_key_for 等无白名单上下文的旧路径(零回归)。
|
||||
/// 空白名单(方案①弱化 workspace_root:不再编译期硬塞开发机 CARGO_MANIFEST_DIR)。
|
||||
/// 分发后该路径指向编译机不存在的目录,硬塞反成脏白名单;用户首次访问任意目录
|
||||
/// 走弹窗三档授权(once/session/always),对齐产品定位(不预设源码目录)。仅作 init
|
||||
/// 占位(reload_allowed_dirs 从 KV 覆盖)+ 测试构造基线。
|
||||
pub fn default_with_root() -> Self {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
Self { persistent: set, session: HashSet::new() }
|
||||
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
|
||||
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
|
||||
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
|
||||
self.persistent.iter().next()
|
||||
}
|
||||
|
||||
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **workspace_root 默认在 persistent**(default_with_root 初始插入,reload_allowed_dirs
|
||||
/// KV 未配时保持),故首次访问工程根免授权(向后兼容)。用户从白名单删除工程根后,
|
||||
/// 工程根也需授权(动态白名单完整语义,F-260619-03 收尾:去掉代码硬编码固定放行)。
|
||||
/// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
|
||||
/// KV 未配时也不塞),首次访问工程根走弹窗三档授权。用户授权(always/session/once)后
|
||||
/// 落白名单,后续命中放行(动态白名单完整语义,F-260619-03 收尾)。
|
||||
///
|
||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||
@@ -484,6 +506,7 @@ impl AllowedDirs {
|
||||
let cand = strip_verbatim(best_effort_canonicalize(candidate));
|
||||
self.persistent.iter().any(|d| cand.starts_with(d))
|
||||
|| self.session.iter().any(|d| cand.starts_with(d))
|
||||
|| self.once.iter().any(|d| cand.starts_with(d))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,11 +585,16 @@ pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
|
||||
// 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else if let Some(root) = allowed.first_persistent_dir() {
|
||||
root.join(raw_path)
|
||||
} else {
|
||||
workspace_root_path().join(raw_path)
|
||||
// 无授权目录时直接返 NeedsAuthorization(让引导流程处理),避免锚定到编译机路径。
|
||||
return PathAuthDecision::NeedsAuthorization {
|
||||
dir: PathBuf::from("."),
|
||||
};
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
@@ -582,7 +610,7 @@ pub fn check_path_authorization(
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| workspace_root_path());
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
@@ -623,11 +651,15 @@ fn best_effort_canonicalize(p: &Path) -> PathBuf {
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
|
||||
/// workspace 根目录(当前:编译期 CARGO_MANIFEST_DIR 上两级,仅开发机有效)。
|
||||
///
|
||||
/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器
|
||||
/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md
|
||||
/// (workspace_root 分发适配)。当前仅开发机自用有效。
|
||||
/// ⚠️ 分发限制:env!("CARGO_MANIFEST_DIR") 是编译期常量,打包后指向编译机路径,用户机器无效。
|
||||
/// 目标方案(D 混合策略):workspace_root 改为运行期动态确定,从已绑定项目的
|
||||
/// `projects.path` 并集 + `AllowedDirs` 推导。无绑定项目时 workspace_root 为空,
|
||||
/// 文件操作拒绝,引导用户绑定项目。当前保留编译期常量作为开发期兼容。
|
||||
/// 已修复:
|
||||
/// - authz_debug → std::env::temp_dir() (chat.rs)
|
||||
/// - workspace_drive → std::env::current_dir() 运行时确定 (chat.rs)
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
@@ -636,15 +668,25 @@ fn workspace_root_path() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 应用数据目录(运行期确定,跨平台)。
|
||||
/// 不依赖编译期常量,打包分发后仍有效。
|
||||
/// 用于 DevFlow 自身数据存储(.trash / logs 等),非用户项目目录。
|
||||
/// 取 AppState.data_dir,不持 state 时回退 workspace_root_path()(开发期兼容)。
|
||||
pub fn data_dir_path(state: &AppState) -> &Path {
|
||||
&state.data_dir
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
|
||||
pub async fn init(db_path: &Path) -> Result<Self> {
|
||||
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
|
||||
let db = Arc::new(Database::open(db_path).await?);
|
||||
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
|
||||
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
|
||||
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs));
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default()));
|
||||
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(
|
||||
&db, &allowed_dirs, data_dir.clone(),
|
||||
));
|
||||
// Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
|
||||
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
|
||||
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
|
||||
@@ -657,6 +699,9 @@ impl AppState {
|
||||
idea_evaluations: IdeaEvalRepo::new(&db),
|
||||
projects: ProjectRepo::new(&db),
|
||||
tasks: TaskRepo::new(&db),
|
||||
task_links: TaskLinkRepo::new(&db),
|
||||
project_events: ProjectEventRepo::new(&db),
|
||||
project_services: ProjectServiceRepo::new(&db),
|
||||
releases: ReleaseRepo::new(&db),
|
||||
workflows: WorkflowRepo::new(&db),
|
||||
node_executions: NodeExecutionRepo::new(&db),
|
||||
@@ -672,6 +717,7 @@ impl AppState {
|
||||
knowledge: KnowledgeRepo::new(&db),
|
||||
knowledge_events: KnowledgeEventsRepo::new(&db),
|
||||
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
|
||||
data_dir: data_dir.clone(),
|
||||
settings: SettingsRepo::new(&db),
|
||||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||||
@@ -700,10 +746,23 @@ impl AppState {
|
||||
// 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。
|
||||
state.reload_provider_caps().await;
|
||||
// F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。
|
||||
// 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(仅 workspace_root)。
|
||||
// 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(方案①后为空)。
|
||||
state.reload_allowed_dirs().await;
|
||||
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
|
||||
state.reload_knowledge_config().await;
|
||||
|
||||
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
|
||||
let old_trash = workspace_root_path().join(".trash");
|
||||
let new_trash = data_dir.join(".trash");
|
||||
if old_trash.exists() && !new_trash.exists() {
|
||||
if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
|
||||
tracing::warn!(
|
||||
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
|
||||
old_trash, new_trash, e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -750,7 +809,7 @@ impl AppState {
|
||||
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
|
||||
///
|
||||
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
|
||||
/// default_with_root(仅 workspace_root,向后兼容:is_authorized 始终放行 workspace_root)。
|
||||
/// default_with_root(方案①后为空,首次访问走弹窗授权)。
|
||||
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
|
||||
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
|
||||
pub async fn reload_allowed_dirs(&self) {
|
||||
@@ -784,10 +843,8 @@ impl AppState {
|
||||
all_dirs.sort();
|
||||
all_dirs.dedup();
|
||||
let mut set = HashSet::new();
|
||||
// KV 未配(空)时保留 workspace_root(向后兼容:默认根免授权)
|
||||
if all_dirs.is_empty() {
|
||||
set.insert(workspace_root_path());
|
||||
}
|
||||
// 方案①:KV + 项目绑定均空时不再硬塞 workspace_root(分发后该路径无效)。
|
||||
// 用户首次访问任意目录走弹窗三档授权,授权后落 KV 持久。
|
||||
for d in all_dirs {
|
||||
let d = d.trim();
|
||||
if d.is_empty() {
|
||||
@@ -806,7 +863,8 @@ impl AppState {
|
||||
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
||||
let mut guard = self.allowed_dirs.write().await;
|
||||
let preserved_session = std::mem::take(&mut guard.session);
|
||||
*guard = AllowedDirs { persistent: set, session: preserved_session };
|
||||
let preserved_once = std::mem::take(&mut guard.once);
|
||||
*guard = AllowedDirs { persistent: set, session: preserved_session, once: preserved_once };
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
||||
@@ -836,14 +894,10 @@ impl AppState {
|
||||
|
||||
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
|
||||
pub async fn get_allowed_dirs(&self) -> Vec<String> {
|
||||
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
|
||||
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
|
||||
let root = workspace_root_path();
|
||||
let guard = self.allowed_dirs.read().await;
|
||||
let mut out: Vec<String> = guard
|
||||
.persistent
|
||||
.iter()
|
||||
.filter(|p| **p != root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -890,6 +944,28 @@ impl AppState {
|
||||
pub async fn clear_session_allowed_dirs(&self) {
|
||||
self.allowed_dirs.write().await.session.clear();
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加本次单次授权目录(弹窗"本次"选项)。
|
||||
///
|
||||
/// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock
|
||||
/// 取快照读 once 放行,执行后由 clear_once_allowed_dirs 清空(本次放行即失效,
|
||||
/// 下次同路径仍弹窗)。规范化与 add_session_allowed_dir 一致(canonicalize 失败回退 trim)。
|
||||
pub async fn add_once_allowed_dir(&self, dir: String) {
|
||||
let d = dir.trim();
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
}));
|
||||
self.allowed_dirs.write().await.once.insert(normalized);
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 清空本次单次授权(工具执行后调,本次放行即失效)。
|
||||
pub async fn clear_once_allowed_dirs(&self) {
|
||||
self.allowed_dirs.write().await.once.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||
@@ -950,12 +1026,14 @@ mod tests {
|
||||
// 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。
|
||||
// ============================================================
|
||||
|
||||
/// workspace_root 路径在 default_with_root 白名单内授权通过
|
||||
/// 显式授权 workspace_root 后命中(方案①:default_with_root 不再预设,需显式授权)
|
||||
#[test]
|
||||
fn test_allowed_dirs_workspace_root_authorized() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let root = workspace_root_path();
|
||||
assert!(allowed.is_authorized(&root), "workspace_root 应被授权");
|
||||
assert!(allowed.is_authorized(&root), "显式授权的 workspace_root 应命中");
|
||||
// workspace 内子路径也应授权(starts_with workspace_root)
|
||||
let child = root.join("src").join("main.rs");
|
||||
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
|
||||
@@ -967,7 +1045,7 @@ mod tests {
|
||||
let mut set = HashSet::new();
|
||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||
set.insert(custom.clone());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
// 精确命中 + 子路径 starts_with 命中
|
||||
assert!(allowed.is_authorized(&custom));
|
||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||
@@ -978,7 +1056,7 @@ mod tests {
|
||||
fn test_allowed_dirs_unauthorized_rejected() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||
}
|
||||
@@ -1007,7 +1085,7 @@ mod tests {
|
||||
fn test_allowed_dirs_session_authorized() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||
}
|
||||
|
||||
@@ -1018,7 +1096,7 @@ mod tests {
|
||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent, session };
|
||||
let allowed = AllowedDirs { persistent, session, once: HashSet::new() };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||
@@ -1034,7 +1112,7 @@ mod tests {
|
||||
let mut persistent = HashSet::new();
|
||||
// 用户误把整个 C:\ 加入白名单
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||
}
|
||||
@@ -1044,7 +1122,7 @@ mod tests {
|
||||
fn test_blacklist_windows_program_files() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||
}
|
||||
|
||||
@@ -1072,10 +1150,12 @@ mod tests {
|
||||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B/C: workspace_root 路径 → Authorized
|
||||
/// Phase B/C: 显式授权 workspace_root 后,内路径 → Authorized(方案①:不再默认)
|
||||
#[test]
|
||||
fn test_check_path_authorized_workspace() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let rel = "src/main.rs";
|
||||
match check_path_authorization(rel, &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
@@ -1100,7 +1180,7 @@ mod tests {
|
||||
fn test_check_path_session_hit() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||
@@ -1127,7 +1207,9 @@ mod tests {
|
||||
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
|
||||
#[test]
|
||||
fn test_is_authorized_strips_verbatim_prefix() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||
let child = workspace_root_path().join("src").join("main.rs");
|
||||
// candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单
|
||||
let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy()));
|
||||
@@ -1291,4 +1373,3 @@ mod tests {
|
||||
assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -77,10 +77,10 @@ export const aiApi = {
|
||||
|
||||
/**
|
||||
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。
|
||||
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
||||
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||
*/
|
||||
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> {
|
||||
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
|
||||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
||||
},
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ export const ideaApi = {
|
||||
return invoke('promote_idea', { id })
|
||||
},
|
||||
|
||||
/** 双向同步关联关系:原子地设置主体灵感的目标关联列表,自动添加/移除反向关联。 */
|
||||
relateIdeas(subjectId: string, targetIds: string[]): Promise<void> {
|
||||
return invoke('relate_ideas', { subjectId, targetIds })
|
||||
},
|
||||
|
||||
/** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */
|
||||
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
|
||||
return invoke('list_idea_evaluations', { ideaId })
|
||||
|
||||
@@ -16,6 +16,11 @@ export const settingsApi = {
|
||||
return invoke<boolean>('settings_set', { key, value })
|
||||
},
|
||||
|
||||
/** 获取 DevFlow 数据目录路径(运行期确定,跨平台) */
|
||||
getDataDir(): Promise<string> {
|
||||
return invoke<string>('get_data_dir')
|
||||
},
|
||||
|
||||
/** 取全部 key/value(启动时一次性拉回恢复偏好) */
|
||||
getAll(): Promise<Record<string, string>> {
|
||||
return invoke<Record<string, string>>('settings_get_all')
|
||||
|
||||
+12
-9
@@ -339,9 +339,11 @@ export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | '
|
||||
* - error:出错(用户可重试回 generating)
|
||||
* - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态)
|
||||
*
|
||||
* 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发
|
||||
* AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为
|
||||
* 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。
|
||||
* 批4 双轨收口后(2026-06-25):convStates(enum Map)是「会话是否生成中」的唯一真相源,
|
||||
* 旧 generatingConvs bool 轨已退役。CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged 时,
|
||||
* useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为 enum 写入口
|
||||
* (替代旧 bool 轨 add),消费方不再回退 bool 判断。streaming 单值是另一条过渡态
|
||||
* (F-09 per-conv streaming 收口前保留),不在本状态机口径内。
|
||||
*/
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
@@ -358,7 +360,7 @@ export type AiChatEvent = ({
|
||||
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
|
||||
} | {
|
||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
||||
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
||||
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
||||
@@ -387,7 +389,7 @@ export type AiChatEvent = ({
|
||||
type: 'AiMaxRoundsReached'
|
||||
} | {
|
||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
// 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||
} | {
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
@@ -400,8 +402,9 @@ export type AiChatEvent = ({
|
||||
// L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
|
||||
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
|
||||
// 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识
|
||||
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。CONV_STATE_ENABLED 门控:off 时
|
||||
// 后端不 emit,前端 conv_state 收不到 → 消费方须回退旧 streaming/generating bool 判断(双轨过渡)。
|
||||
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。批4 双轨收口后:CONV_STATE_ENABLED=off
|
||||
// 或老后端不发本事件时,useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为
|
||||
// enum 写入口(替代旧 bool 轨 add),消费方不再回退 streaming/generating bool 判断。
|
||||
type: 'AiConvStateChanged'; conv_state: ConvState
|
||||
} | {
|
||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||
@@ -544,8 +547,8 @@ export interface AiToolCallInfo {
|
||||
diff?: string
|
||||
/**
|
||||
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 三选项,
|
||||
* 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。
|
||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项,
|
||||
* 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
|
||||
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
||||
* 调 aiApi.approve(一次性)。
|
||||
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
||||
|
||||
@@ -597,6 +597,8 @@ async function initContextEventListeners(): Promise<void> {
|
||||
void store.switchConversation(store.state.activeConversationId)
|
||||
}
|
||||
},
|
||||
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
|
||||
// 自动压缩(AiAutoCompressed)静默不进此回调,避免每次发送误弹 toast + 误刷新打断流。
|
||||
onCompressed: (_convId, _summary) => {
|
||||
showToast(t('aiChat.compressSuccess'), 'info')
|
||||
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
|
||||
|
||||
@@ -313,9 +313,11 @@ let _unlistenPending: UnlistenFn | null = null
|
||||
let _disposed = false
|
||||
onMounted(async () => {
|
||||
const fn = await listen('ai-pending-arrived', () => {
|
||||
// nextTick 等 pending_approval 类挂到 DOM 后再查(scrollToFirstPending 查 .ai-tool-card--pending_approval)。
|
||||
// 多个 ToolCardList 实例(每条 AI 消息一个)各自 listen,querySelector 只命中自身 root 内的
|
||||
// pending 卡,非含 pending 卡的实例查 null no-op,天然过滤不串扰。
|
||||
// BUG-260624-01 残留优化:本实例无 pending 卡时短路返回,避免大列表 N 个 ToolCardList 实例
|
||||
// 每次广播都跑 querySelector(N 实例 × M 次 emit = N×M 次 DOM 查询)。原靠 scrollToFirstPending
|
||||
// 内 querySelector 返 null no-op 天然过滤(功能正确),此处显式 props 过滤把无 pending 实例挡在
|
||||
// 进入函数前。含 pending 的实例再走 nextTick + scrollToFirstPending(展开折叠组 + scroll)。
|
||||
if (!props.toolCalls.some(tc => tc.status === 'pending_approval')) return
|
||||
nextTick(() => scrollToFirstPending())
|
||||
})
|
||||
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
|
||||
|
||||
@@ -2,19 +2,34 @@
|
||||
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 -->
|
||||
|
||||
<!-- read_file 结果:代码预览 -->
|
||||
<!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
|
||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
|
||||
<span class="ai-tool-file-meta">{{ parsed?.has_more
|
||||
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
||||
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
||||
<span v-if="parsed?.search" class="ai-tool-file-meta">「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
|
||||
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
|
||||
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
|
||||
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
|
||||
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
|
||||
<button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
|
||||
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
|
||||
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
|
||||
<!-- BUG-260626-01:行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
|
||||
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 默认模式:全文预览 -->
|
||||
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- list_directory 结果:文件树 -->
|
||||
@@ -82,14 +97,15 @@
|
||||
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- content 模式:命中行(文件:行:内容),支持上下文折叠 -->
|
||||
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
|
||||
<!-- BUG-260626-01:文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
|
||||
<div v-else class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-head">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
<span class="ai-tool-grep-file">{{ m.file }}</span>
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">:{{ m.line }}</span>
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,26 +352,37 @@ watch(() => props.tc.status, (s) => {
|
||||
.ai-tool-grep-match:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ai-tool-grep-match-head {
|
||||
/* BUG-260626-01:命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
|
||||
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
|
||||
.ai-tool-grep-match-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-grep-file {
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ai-tool-grep-line {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
min-width: 2em;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
user-select: none;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ai-tool-grep-content {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
||||
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
||||
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
每项独立三按钮:点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
|
||||
点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||
每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。
|
||||
点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。
|
||||
点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
@@ -19,6 +20,11 @@
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'once')"
|
||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--session"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'session')"
|
||||
>{{ $t('aiChat.dirAuthSession') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@@ -74,7 +80,7 @@ const actingIds = reactive(new Set<string>())
|
||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> {
|
||||
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
|
||||
if (actingIds.has(id)) return
|
||||
actingIds.add(id)
|
||||
try {
|
||||
@@ -148,6 +154,12 @@ watch(() => pendingDirAuths.value, (items) => {
|
||||
color: #fff;
|
||||
}
|
||||
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--session {
|
||||
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
||||
color: var(--df-accent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--always {
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
|
||||
@@ -503,10 +503,12 @@ function collapseAllToolLists(active: Set<string>): void {
|
||||
function scrollToFirstPending(): void {
|
||||
const tcl = toolCardListRef.value as unknown
|
||||
const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : [])
|
||||
// BUG-260624-01 残留修复:遍历所有实例调 scrollToFirstPending,各实例内部 querySelector 无
|
||||
// pending 卡则 no-op,含 pending 的实例滚到其首张待审批卡。原 `return` 首个有方法的实例即停,
|
||||
// pending 若落在后续实例(头部徽标点击场景)滚不到。移除 return 让所有实例尝试,no-op 实例零成本。
|
||||
for (const r of refs) {
|
||||
if (typeof (r as any)?.scrollToFirstPending === 'function') {
|
||||
;(r as any).scrollToFirstPending()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -720,20 +722,6 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── UX-02: 重新生成最后一条 AI 回复 ──
|
||||
// 复用 store.regenerate(后端 ai_regenerate:弹末尾 AI 回复→重跑 loop)。
|
||||
// 失败 toast 提示,不影响其他状态(后端已复位 generating)。
|
||||
async function handleRegenerate(): Promise<void> {
|
||||
try {
|
||||
await store.regenerate()
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.regenerateFailed', { msg }), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── UX-03: 错误气泡操作入口 ──
|
||||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||||
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
||||
@@ -1057,33 +1045,8 @@ defineExpose({
|
||||
<span class="ai-cursor"></span>
|
||||
</div>
|
||||
|
||||
<!-- UX-02:AI 消息操作栏(hover 显示:重新生成)。
|
||||
复制走气泡内右下角图标(ai-copy-btn--ai),本栏仅留重新生成,故只在
|
||||
末条 AI 消息(重生成语义)、非错误、有内容、非流式中显示。 -->
|
||||
<div
|
||||
v-if="isLastAi(item.msg) && item.msg.content && !item.msg.isError && !store.state.streaming"
|
||||
class="ai-msg-actions"
|
||||
>
|
||||
<button
|
||||
class="ai-msg-act"
|
||||
:disabled="store.state.streaming"
|
||||
:title="$t('aiChat.regenerate')"
|
||||
@click.stop="handleRegenerate"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
<span>{{ $t('aiChat.regenerate') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置)。
|
||||
仅 isError 消息显示;「重试」仅可重试错误(network/timeout/unknown)显示,
|
||||
Fatal 类(auth/provider_config — 鉴权/4xx/参数错)重试必败故隐藏;
|
||||
「去设置」仅 error_type ∈ {auth, provider_config} 显示;
|
||||
Fatal 时附 notRetryable 提示,说明为何无重试按钮。 -->
|
||||
<div
|
||||
v-if="item.msg.isError && !store.state.streaming"
|
||||
class="ai-msg-actions ai-msg-actions--error"
|
||||
>
|
||||
<!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置) -->
|
||||
<div v-if="item.msg.isError" class="ai-msg-actions ai-msg-actions--error">
|
||||
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
|
||||
<button
|
||||
v-if="canRetry(item.msg)"
|
||||
|
||||
@@ -162,16 +162,16 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 传统评分雷达图 -->
|
||||
<!-- 传统评分条(score-bar) -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
|
||||
<div class="radar-chart" v-if="parseScores(idea.scores).length > 0">
|
||||
<div class="radar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
|
||||
<span class="radar-label">{{ dim.name }}</span>
|
||||
<div class="radar-bar-track">
|
||||
<div class="radar-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
|
||||
<div class="score-bar-chart" v-if="parseScores(idea.scores).length > 0">
|
||||
<div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
|
||||
<span class="score-bar-label">{{ dim.name }}</span>
|
||||
<div class="score-bar-track">
|
||||
<div class="score-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
|
||||
</div>
|
||||
<span class="radar-value">{{ dim.score }}</span>
|
||||
<span class="score-bar-value">{{ dim.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div>
|
||||
@@ -774,31 +774,31 @@ watch(() => props.idea.id, loadHistory)
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
/* ===== 雷达图(div 模拟) ===== */
|
||||
.radar-chart {
|
||||
/* ===== 多维评分条(div 模拟) ===== */
|
||||
.score-bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.radar-row {
|
||||
.score-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.radar-label {
|
||||
.score-bar-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
.radar-bar-track {
|
||||
.score-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
.radar-bar-fill {
|
||||
.score-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--df-radius-xs);
|
||||
transition: width 0.4s;
|
||||
@@ -806,7 +806,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.radar-value {
|
||||
.score-bar-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-width: 28px;
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<h2>{{ $t('settings.sectionAdvanced') }}</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<!-- ═══ 应用数据目录(运行期确定,跨平台) ═══ -->
|
||||
<SettingRow :label="$t('settings.labelDataDir')" :desc="$t('settings.descDataDir')">
|
||||
<span class="data-dir-path" :title="dataDir">{{ dataDir || $t('settings.loading') }}</span>
|
||||
</SettingRow>
|
||||
|
||||
<!-- F-#97:三档 low/medium/all。low=仅低风险自动(等价旧 autoExecute=false);
|
||||
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
|
||||
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
|
||||
@@ -40,10 +45,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, useTemplateRef } from 'vue'
|
||||
import { ref, reactive, watch, useTemplateRef, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { settingsApi } from '@/api'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
// ============================================================
|
||||
@@ -56,6 +62,14 @@ import SettingRow from './SettingRow.vue'
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 应用数据目录(运行期确定,跨平台)
|
||||
const dataDir = ref('')
|
||||
onMounted(async () => {
|
||||
try {
|
||||
dataDir.value = await settingsApi.getDataDir()
|
||||
} catch { /* 静默 */ }
|
||||
})
|
||||
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) }
|
||||
// confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText
|
||||
// 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。
|
||||
@@ -125,6 +139,11 @@ function markSaved() {
|
||||
|
||||
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
|
||||
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
|
||||
.data-dir-path {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import type { AiToolCallInfo, ConvState } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
@@ -124,3 +125,141 @@ export function clearAllApprovalTimers(): void {
|
||||
for (const timer of _approvalTimers.values()) clearTimeout(timer)
|
||||
_approvalTimers.clear()
|
||||
}
|
||||
|
||||
// ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ──
|
||||
//
|
||||
// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/
|
||||
// useAiStream.ts(onStreamTimeout stillGenerating)/useAiConversations.ts(switchConversation 重算)
|
||||
// 等读点均需 import getConvState。若 getConvState 留在 useAiEvents,这些模块反向 import
|
||||
// useAiEvents 会与 useAiEvents→useAiStream/useAiEvents←stores 的现有依赖构成环。
|
||||
// 故将 convStates Map + getConvState + setConvState 全部下沉到本共享层(与 nextMsgId/findToolCall/
|
||||
// 审批计时器同位置,沿用既有破环模式)。useAiEvents 改为从本模块 import 它们。
|
||||
|
||||
/**
|
||||
* per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。
|
||||
*
|
||||
* 模块级 reactive Map(convId → ConvState),不进 store.state(与生成生命周期语义独立于
|
||||
* 消息/会话列表,同 pendingMaxRounds/pendingHelp 模式)。批4 收口后,本 Map 是「会话是否生成中」
|
||||
* 的唯一真相源,generatingConvs bool 轨已退役。
|
||||
*/
|
||||
export const convStates = reactive(new Map<string, ConvState>())
|
||||
|
||||
/**
|
||||
* 取某 conv 的 conv_state;未追踪过返回 null。
|
||||
*
|
||||
* 桥接语义(批4 收口):bool 轨 generatingConvs.has(id) 等价于本函数返回值 ∈
|
||||
* {'generating','stopping','compressed'}(非终止三态);idle/error/null 对应 bool 轨 has()=false。
|
||||
* 依据:handleConvStateEvent 原同步逻辑将三态都 add,终止态 delete。
|
||||
*/
|
||||
export function getConvState(convId: string | null | undefined): ConvState | null {
|
||||
if (!convId) return null
|
||||
return convStates.get(convId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 conv_state(useAiEvents.handleConvStateEvent 的 AiConvStateChanged case 唯一写入口,
|
||||
* 及活跃事件兜底写 generating 态)。
|
||||
*
|
||||
* idle 为终态收敛:写后删 Map 项(与后端 generating=false 语义对齐,Map 不持陈旧 idle 项)。
|
||||
*/
|
||||
export function setConvState(convId: string | null | undefined, s: ConvState): void {
|
||||
if (!convId) return
|
||||
if (s === 'idle') {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
convStates.set(convId, s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── F-09 B 路线 per-conv 流式态(streaming/currentText) ──
|
||||
//
|
||||
// 背景(DEC-07a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例,
|
||||
// F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming &&
|
||||
// currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话
|
||||
// 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。
|
||||
//
|
||||
// 铁律:单会话回归零变化。per-conv Map 在单会话场景只有 activeConversationId 一项,等价原全局
|
||||
// 单例。state.streaming/currentText 改为委派本 Map 的 accessor(stores/ai.ts),所有现有消费方
|
||||
// (streamingGuard/useAiEvents/useAiSend/useAiConversations/useAiWindow/useAiPanel/MessageList/
|
||||
// ChatInput/TopBar/AiChat)读写 `store.state.streaming` / `store.state.currentText` 零改动透明继承。
|
||||
//
|
||||
// 下沉到 aiShared.ts(与 convStates 同位置)的原因:stores/ai.ts 已 import aiShared(getConvState),
|
||||
// 把 per-conv Map 放这里不新增依赖环;且与 convStates 同款 per-conv 模式集中管理,语义聚合。
|
||||
//
|
||||
// 类型:streaming=boolean(该会话是否正在流式输出),currentText=string(该会话累积的流式文本)。
|
||||
export interface ConvStreamState {
|
||||
streaming: boolean
|
||||
currentText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* per-conv 流式态真相源:convId → {streaming, currentText}。
|
||||
*
|
||||
* 模块级 reactive Map(对齐 convStates 模式)。stores/ai.ts 的 state.streaming/currentText
|
||||
* accessor 委派本 Map 按 activeConversationId 索引读写,响应式依赖经 Vue reactive Map proxy 跟踪。
|
||||
*
|
||||
* 收敛:streaming=false 时删 Map 项(对齐 convStates idle 收敛,Map 不持陈旧 false 项);
|
||||
* 写 true 时按需 set。currentText 写空串等同收敛(下次写非空重新 set)。
|
||||
*/
|
||||
export const convStreamStates = reactive(new Map<string, ConvStreamState>())
|
||||
|
||||
/**
|
||||
* 取某 conv 的 stream state;未追踪过返回 null。
|
||||
*
|
||||
* 单会话场景:activeConversationId 恒定,Map 仅此一项,等价原全局单例。
|
||||
*/
|
||||
export function getConvStreamState(convId: string | null | undefined): ConvStreamState | null {
|
||||
if (!convId) return null
|
||||
return convStreamStates.get(convId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某 conv 的 stream state,不存在则惰性创建(供 streaming/currentText 写入路径建项)。
|
||||
* 惰性建对齐 per-conv「写时建」语义——读路径(get)不建,仅写入路径(set)建。
|
||||
*/
|
||||
function ensureConvStreamState(convId: string): ConvStreamState {
|
||||
let s = convStreamStates.get(convId)
|
||||
if (!s) {
|
||||
s = { streaming: false, currentText: '' }
|
||||
convStreamStates.set(convId, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 写某 conv 的 streaming 态(false 时收敛删 Map 项,对齐 convStates idle 语义)。
|
||||
*
|
||||
* @param convId 目标会话 id
|
||||
* @param value streaming 目标值
|
||||
*/
|
||||
export function setConvStreaming(convId: string | null | undefined, value: boolean): void {
|
||||
if (!convId) return
|
||||
if (value) {
|
||||
ensureConvStreamState(convId).streaming = value
|
||||
} else {
|
||||
// false 收敛:若该 conv 已无任何活跃态(streaming=false 且 currentText 空),删 Map 项避免陈旧。
|
||||
const cur = convStreamStates.get(convId)
|
||||
if (cur) {
|
||||
cur.streaming = false
|
||||
if (!cur.currentText) convStreamStates.delete(convId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写某 conv 的 currentText(空串时仅清字段,streaming 仍 true 则保留项)。
|
||||
*
|
||||
* @param convId 目标会话 id
|
||||
* @param value currentText 目标值(空串=清流式累积,但会话可能仍在 streaming)
|
||||
*/
|
||||
export function setConvCurrentText(convId: string | null | undefined, value: string): void {
|
||||
if (!convId) return
|
||||
ensureConvStreamState(convId).currentText = value
|
||||
}
|
||||
|
||||
/** 清某 conv 的 stream state(会话删除/收尾彻底清,删整个 Map 项)。
|
||||
* 注:日常 streaming=false/currentText='' 收敛走 setConvStreaming 内联删,本函数用于会话级删除。 */
|
||||
export function clearConvStreamState(convId: string | null | undefined): void {
|
||||
if (!convId) return
|
||||
convStreamStates.delete(convId)
|
||||
}
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
//! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 →
|
||||
//! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。
|
||||
//!
|
||||
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做三件事:
|
||||
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做两件事:
|
||||
//! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝——
|
||||
//! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗)
|
||||
//! 记 warn 日志供排查卡死。
|
||||
//! 2. 联动 generatingConvs:value=true 且带 convId → add(convId);value=false 且带 convId
|
||||
//! → delete(convId)。不带 convId 的复位(全局兜底)不动 generatingConvs,因其由
|
||||
//! AiCompleted/AiError 按 conversation_id 精确管理(避免误删并发会话生成态)。
|
||||
//! 3. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
|
||||
//! 不校验不联动,回退原散布语义(灰度/回退开关)。
|
||||
//! 2. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
|
||||
//! 不校验,回退原散布语义(灰度/回退开关)。
|
||||
//!
|
||||
//! 注:本 guard 是「写收敛 + 可观测 + 联动」三合一,不是拒绝式状态机——
|
||||
//! 批4 双轨收口后:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs
|
||||
//! (bool 轨已退役)。streaming 单值(state.streaming)是全局当前视图流式态,另一条过渡态,
|
||||
//! 归 F-09 B 路线 per-conv streaming 收口,本批暂留。doSend setStreaming(true,{convId}) 后,
|
||||
//! 生成态由 useAiEvents handleEvent 活跃事件兜底写 setConvState('generating') 标记。
|
||||
//!
|
||||
//! 注:本 guard 是「写收敛 + 可观测」二合一,不是拒绝式状态机——
|
||||
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
@@ -29,8 +31,7 @@ const appSettings = useAppSettingsStore()
|
||||
/// guard 是否启用(读 appSettings,默认开=true)。
|
||||
///
|
||||
/// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更,
|
||||
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现联动 generatingConvs 引入回归,
|
||||
/// 用户可在设置里关掉回退原散布语义)。
|
||||
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现回归,用户可在设置里关掉回退原散布语义)。
|
||||
function isGuardEnabled(): boolean {
|
||||
return appSettings.get<boolean>(STREAMING_GUARD_FLAG, true)
|
||||
}
|
||||
@@ -38,14 +39,13 @@ function isGuardEnabled(): boolean {
|
||||
/**
|
||||
* streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。
|
||||
* @param value 目标流式态
|
||||
* @param opts.convId 关联会话 id。value=true 时 add 进 generatingConvs;
|
||||
* value=false 时 delete 出 generatingConvs。省略则不动 generatingConvs
|
||||
* (全局兜底复位场景,generatingConvs 由 AiCompleted/AiError 精确管理)。
|
||||
* @param opts.convId 关联会话 id(批4 收口后仅用于日志可观测,不再联动 generatingConvs;
|
||||
* 生成态真相归 convStates,由 useAiEvents 兜底写 setConvState 标记)。
|
||||
* @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。
|
||||
*
|
||||
* 行为:
|
||||
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不联动不校验)。
|
||||
* - flag 开 → 幂等检测(value===当前值记 debug)、联动 generatingConvs、写日志。
|
||||
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不校验)。
|
||||
* - flag 开 → 幂等检测(value===当前值记 debug)、写日志。
|
||||
*/
|
||||
export function setStreaming(
|
||||
value: boolean,
|
||||
@@ -73,18 +73,8 @@ export function setStreaming(
|
||||
)
|
||||
}
|
||||
|
||||
// 联动 generatingConvs:仅在有 convId 时联动。
|
||||
// value=true:convId 入 Set(标记该会话生成中,侧栏/分离窗口据此显示)。
|
||||
// value=false:convId 出 Set(该会话收尾)。无 convId 的全局复位不动 Set
|
||||
// (并发会话生成态由各自 AiCompleted/AiError 精确 delete 管理,全局清会误杀)。
|
||||
if (convId) {
|
||||
if (value) {
|
||||
state.generatingConvs.add(convId)
|
||||
} else {
|
||||
state.generatingConvs.delete(convId)
|
||||
}
|
||||
}
|
||||
|
||||
// 批4 双轨收口:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs
|
||||
// (bool 轨已退役)。streaming 单值是另一条过渡态(F-09 per-conv streaming 收口前保留)。
|
||||
state.streaming = value
|
||||
}
|
||||
|
||||
@@ -94,9 +84,8 @@ export function setStreaming(
|
||||
* 与 setStreaming(false) 区别:本函数额外清 currentText + queue,
|
||||
* 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。
|
||||
*
|
||||
* TD-260621-01 per-conv:看门狗超时携带 convId 时,仅清该 conv 的 generatingConvs
|
||||
* (经 setStreaming 联动 delete),不再全清误杀并发会话生成态。不带 convId(全局兜底,
|
||||
* 如 stopListener 卸载场景)时 generatingConvs 不动,由各自会话收尾事件精确管理。
|
||||
* TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态;
|
||||
* 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。
|
||||
*
|
||||
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
|
||||
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
|
||||
|
||||
@@ -31,8 +31,8 @@ const _pendingApprovalIds = new Set<string>()
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
||||
*
|
||||
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
|
||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。
|
||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。
|
||||
* once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。
|
||||
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
|
||||
* approved=true→放行执行,approved=false→拒绝。
|
||||
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
|
||||
@@ -40,16 +40,16 @@ const _pendingApprovalIds = new Set<string>()
|
||||
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
|
||||
*
|
||||
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
|
||||
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
||||
* @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
||||
*/
|
||||
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') {
|
||||
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
||||
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||
if (_pendingApprovalIds.has(toolCallId)) return
|
||||
_pendingApprovalIds.add(toolCallId)
|
||||
try {
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||
const tc = findToolCall(toolCallId)
|
||||
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/always/deny);
|
||||
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny);
|
||||
// 其余(risk/缺省)走 approve(approve/reject)。
|
||||
const isPathKind = !!tc && tc.kind === 'path'
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
|
||||
@@ -95,7 +95,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
*
|
||||
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
|
||||
* 批量操作语义:
|
||||
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权仅本次,
|
||||
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
|
||||
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
|
||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||
@@ -106,7 +106,7 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
||||
for (const p of items) {
|
||||
try {
|
||||
if (p.kind === 'path') {
|
||||
// path 类:approve→once(仅本次,不批量写持久白名单), reject→deny
|
||||
// path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
|
||||
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
|
||||
} else {
|
||||
// risk 类:approve/reject boolean
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase,
|
||||
//! 对齐 useAiEvents.ts 既有 9 事件):
|
||||
//! AiCompressing → isCompressing=true
|
||||
//! AiCompressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里)
|
||||
//! AiManualCompressed → isCompressing=false + (手动 IPC,成功 toast 由调用方弹,摘要已在事件里)
|
||||
//! AiAutoCompressed → isCompressing=false + (loop 自动压缩,静默仅复位,治 Task#1 不弹 toast)
|
||||
//! AiContextCleared → (刷新提示由调用方弹)
|
||||
//! AiError → isCompressing=false + (错误 toast 由调用方弹)
|
||||
//!
|
||||
//! 设计:
|
||||
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
|
||||
//! AiCompressing/AiCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
|
||||
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。
|
||||
//! AiCompressing/AiManualCompressed|AiAutoCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
|
||||
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 5 类事件。
|
||||
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
|
||||
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
|
||||
//! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用,
|
||||
@@ -32,7 +33,7 @@ import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { resolveAiLang } from './aiShared'
|
||||
|
||||
/// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false)
|
||||
/// 压缩中 loading(防重复点击;AiCompressing→true / AiManualCompressed|AiAutoCompressed|AiError→false)
|
||||
const isCompressing = ref(false)
|
||||
|
||||
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
|
||||
@@ -43,7 +44,8 @@ let _unlistenContext: UnlistenFn | null = null
|
||||
interface ContextCallbacks {
|
||||
/** 清空完成(AiContextCleared):toast + 刷新消息列表 */
|
||||
onCleared?: (conversationId: string) => void
|
||||
/** 压缩成功(AiCompressed):toast + 渲染摘要 system 消息 */
|
||||
/** 手动压缩成功(AiManualCompressed):toast + 渲染摘要 system 消息。
|
||||
* 注:仅 AiManualCompressed 触发;AiAutoCompressed(loop 自动)静默不进此回调(治 Task#1)。 */
|
||||
onCompressed?: (conversationId: string, summary: string) => void
|
||||
/** 压缩失败 / 错误(AiError with conversation_id):toast */
|
||||
onError?: (conversationId: string, message: string) => void
|
||||
@@ -76,10 +78,18 @@ async function initContextListener(callbacks: ContextCallbacks = {}): Promise<vo
|
||||
case 'AiCompressing':
|
||||
isCompressing.value = true
|
||||
break
|
||||
case 'AiCompressed':
|
||||
case 'AiManualCompressed':
|
||||
// 手动 IPC 压缩(用户点压缩按钮):复位 isCompressing + 弹 toast + 刷新视图回填摘要。
|
||||
isCompressing.value = false
|
||||
_callbacks.onCompressed?.(convId || '', payload.summary || '')
|
||||
break
|
||||
case 'AiAutoCompressed':
|
||||
// 治 Task#1:loop 自动压缩对桌面静默——仅复位 isCompressing(冗余兜底,Auto 路径
|
||||
// 前端 isCompressing 本就为 false,只有手动 compressContext 才置 true),不调
|
||||
// onCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。
|
||||
// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
||||
isCompressing.value = false
|
||||
break
|
||||
case 'AiContextCleared':
|
||||
_callbacks.onCleared?.(convId || '')
|
||||
break
|
||||
@@ -122,7 +132,7 @@ async function compressContext(): Promise<void> {
|
||||
try {
|
||||
await aiApi.compressContext(convId, resolveAiLang())
|
||||
// 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing;
|
||||
// case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
|
||||
// case 'AiManualCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
|
||||
isCompressing.value = false
|
||||
} catch (e) {
|
||||
isCompressing.value = false
|
||||
|
||||
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, getConvState, clearConvStreamState } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -101,8 +101,11 @@ export async function switchConversation(id: string) {
|
||||
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
|
||||
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
|
||||
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
|
||||
// 走 setStreaming guard 收敛写入口(convId=id 联动幂等——has(id) 决定值,add/delete 幂等无副作用)。
|
||||
setStreaming(state.generatingConvs.has(id), { convId: id, reason: 'switchConversation-recompute' })
|
||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(id)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
const targetCs = getConvState(id)
|
||||
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
@@ -238,6 +241,9 @@ export async function switchConversation(id: string) {
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
|
||||
async function deleteConversation(id: string) {
|
||||
await aiApi.deleteConversation(id)
|
||||
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
|
||||
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
|
||||
clearConvStreamState(id)
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = null
|
||||
void appSettings.remove('df-ai-active-conv')
|
||||
|
||||
@@ -20,16 +20,19 @@
|
||||
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState } from './aiShared'
|
||||
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
|
||||
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
|
||||
export { getConvState } from './aiShared'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
|
||||
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
@@ -138,35 +141,12 @@ export interface PendingHelp {
|
||||
}
|
||||
export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
// L2 统一状态机(批2 1c):per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。
|
||||
//
|
||||
// 模块级 reactive Map(convId → ConvState)直接 import 读(同 pendingMaxRounds/pendingHelp 模式,
|
||||
// 不进 store.state,与生成生命周期语义独立于消息/会话列表)。停止按钮/MaxRoundsCard 读之
|
||||
// 派生三态(generating/stopping/error),旧 streaming/generatingConvs bool 双轨过渡保留——
|
||||
// conv_state 未收到时(CONV_STATE_ENABLED=off 或后端老版)消费方回退旧 bool 判断(兜底)。
|
||||
//
|
||||
// 写收敛:仅本模块 handleEvent 的 AiConvStateChanged case 写(setConvState 辅助),其他地方只读。
|
||||
// 兜底同步:idle/error/compressed 在 case 内同步做旧 generatingConvs 收敛(AiCompleted/AiError 同义),
|
||||
// 保持双轨真相一致——后端 emit 可能与 AiCompleted/AiError 不严格按序到达,任一来源触发收敛均可。
|
||||
export const convStates = reactive(new Map<string, ConvState>())
|
||||
|
||||
/** 取某 conv 的 conv_state;未追踪过返回 null(消费方据此回退旧 bool 判断) */
|
||||
export function getConvState(convId: string | null | undefined): ConvState | null {
|
||||
if (!convId) return null
|
||||
return convStates.get(convId) ?? null
|
||||
}
|
||||
|
||||
/** 写入 conv_state(本模块内 AiConvStateChanged case 唯一写入口) */
|
||||
function setConvState(convId: string | null | undefined, s: ConvState): void {
|
||||
if (!convId) return
|
||||
// idle 为终态收敛:写后可保留也可删,这里删以让 getConvState 回退旧 bool(语义对齐:
|
||||
// 后端 generating=false 即 Idle,前端旧 bool 也是 false,删 Map 项后消费方回退 bool 即得正确态)。
|
||||
if (s === 'idle') {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
convStates.set(convId, s)
|
||||
}
|
||||
}
|
||||
// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
|
||||
// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
|
||||
// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
|
||||
// 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役,
|
||||
// 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed
|
||||
// add 的兜底分支已删,enum 单写 setConvState 即收敛)。
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
@@ -521,31 +501,17 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** L2 状态机域:消费 AiConvStateChanged,维护 per-conv conv_state 真相源。
|
||||
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
|
||||
*
|
||||
* 非终止态(generating/stopping/compressed):仅写 convStates,不动看门狗/旧 bool
|
||||
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。
|
||||
* 终止态(idle/error):
|
||||
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
|
||||
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
|
||||
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
|
||||
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
|
||||
* 批4 双轨收口后:enum(convStates)是唯一真相源,setConvState 单写即收敛。
|
||||
* 原 idle/error 同步 delete generatingConvs、非终止态同步 add 的双轨兜底分支已删
|
||||
* (bool 轨 generatingConvs 已退役)。看门狗由活跃 delta/工具事件重置,这里不干预。
|
||||
* 返回值:true=已处理(命中 case);false=未命中。 */
|
||||
function handleConvStateEvent(event: AiChatEvent): boolean {
|
||||
if (event.type !== 'AiConvStateChanged') return false
|
||||
const convId = event.conversation_id || state.activeConversationId
|
||||
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
|
||||
const cs = event.conv_state
|
||||
setConvState(convId, cs)
|
||||
// 终止态同步收敛旧 generatingConvs(双轨过渡,与 AiCompleted/AiError 同义)。
|
||||
// compressed 非终止(派生态,压缩完回原态),不收敛。
|
||||
if (cs === 'idle' || cs === 'error') {
|
||||
state.generatingConvs.delete(convId)
|
||||
} else if (cs === 'generating' || cs === 'stopping' || cs === 'compressed') {
|
||||
// 非终止态同步加 generatingConvs(确保旧 bool 真相与 conv_state 一致,
|
||||
// 防 AiConvStateChanged{generating} 先于首个 delta 到达时旧 bool 漏加致停止按钮/卡片错态)。
|
||||
state.generatingConvs.add(convId)
|
||||
}
|
||||
setConvState(convId, event.conv_state)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -585,10 +551,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
|
||||
// 批4 双轨收口:AiCompleted 收尾收敛 convStates→idle(删 Map 项,enum 单一真相源)。
|
||||
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
|
||||
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)。
|
||||
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。
|
||||
convStates.delete(event.conversation_id || '')
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
|
||||
@@ -653,10 +618,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
|
||||
// 批4 双轨收口:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
|
||||
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
|
||||
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。
|
||||
// error 态在新会话发送(首活跃事件到达兜底写 generating)或下次 AiCompleted 时自然收敛。
|
||||
if (event.conversation_id) convStates.set(event.conversation_id, 'error')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
|
||||
@@ -706,7 +670,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
clearAllToolSlowTimers()
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// 批4 双轨收口:求助即终止 loop,收敛 convStates(删 Map 项回 null/不在生成)。
|
||||
// HelpRequiredCard 守卫仅判 pendingHelp.conversationId===active,不读 conv_state,删项安全。
|
||||
convStates.delete(event.conversation_id || '')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0
|
||||
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
|
||||
@@ -756,32 +722,35 @@ export function handleEvent(event: AiChatEvent) {
|
||||
}
|
||||
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
|
||||
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
|
||||
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
|
||||
// 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。
|
||||
if (handleConvStateEvent(event)) return
|
||||
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
|
||||
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/
|
||||
// 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/
|
||||
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
|
||||
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
|
||||
// 不影响其他态(handleUserMessageEvent 去重幂等)。
|
||||
if (handleUserMessageEvent(event)) return
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
|
||||
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
|
||||
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需收敛生成态。
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
||||
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
|
||||
state.generatingConvs.delete(convId || '')
|
||||
// 批4 双轨收口:非当前会话终止事件,收敛 convStates(删 Map 项回 idle/不在生成),
|
||||
// 对齐 :592/:660/:711 当前会话分支语义。非当前会话的 error 态无消费方(MaxRoundsCard/
|
||||
// ChatInput 均读 activeConversationId),回退 null 与停止按钮 streaming 回退行为等价。
|
||||
convStates.delete(convId || '')
|
||||
void loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
|
||||
// 批4 双轨收口:活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中。
|
||||
// 完成后端 CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged{generating} 时,此处作为
|
||||
// enum 兜底写入口(替代原 generatingConvs.add + 清残留 error 双操作,合并为 setConvState('generating') 一步)。
|
||||
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已先到(handleConvStateEvent 在
|
||||
// isCurrent 守卫前处理),此处 setConvState 同值 set 幂等覆盖,无副作用。完成/错误/求助事件除外
|
||||
// (三者后端均已终止 loop,在各自 case 内由 convStates.delete/set('error') 收敛)。
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
|
||||
state.generatingConvs.add(convId)
|
||||
// L2 双轨过渡:会话进入生成(活跃事件到达)时,清 convStates 中可能残留的 error 项,
|
||||
// 避免上一轮错误态 conv_state 未收敛致停止按钮错显"重试"(此时实际已在生成)。
|
||||
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已更新;此处兜底 off/老后端。
|
||||
convStates.delete(convId)
|
||||
setConvState(convId, 'generating')
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, getConvState } from './aiShared'
|
||||
import { forceResetStreaming } from './streamingGuard'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
@@ -42,9 +42,10 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位,
|
||||
* 对齐 [[devflow-generating-statemachine]])。
|
||||
*
|
||||
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的
|
||||
* generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空
|
||||
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
|
||||
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming
|
||||
* 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话
|
||||
* 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空
|
||||
* (legacy fallback 路径)时全局兜底,行为对齐旧版。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
|
||||
@@ -54,14 +55,37 @@ export function onStreamTimeout(convId?: string) {
|
||||
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
|
||||
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
|
||||
const stillGenerating = convId
|
||||
? state.generatingConvs.has(convId)
|
||||
? (() => {
|
||||
// 批4 双轨收口:读 convStates(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
const cs = getConvState(convId)
|
||||
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
|
||||
})()
|
||||
: state.streaming
|
||||
if (!stillGenerating) {
|
||||
clearStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// 兜底守卫(BUG-260624-03):工具执行中(running 卡)不计整流超时。
|
||||
// 后端 execute_with_heartbeat(audit/mod.rs)已为工具执行补 30s AiHeartbeat,正常执行
|
||||
// 不会到 130s 超时;到此且仍有 running 卡 = 心跳也丢失(老后端/provider 双重异常),
|
||||
// 静默续等(reset 重计 130s)给工具完成/心跳恢复机会,不抛"工具已执行完成后续中断"误报。
|
||||
// 注:真死锁(running 永驻)时续等会持续,但优于误报流中断(forceReset 致状态不一致);
|
||||
// running 状态由后端 AiToolCallCompleted 驱动(非 ToolCard approving 计时器,那仅复位按钮 loading),
|
||||
// 用户可手动 stop,且 A 心跳已覆盖正常执行,真死锁罕见。
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
if (toolCalls[j].status === 'running') {
|
||||
resetStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
|
||||
// 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由
|
||||
// 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
clearStreamWatchdog(convId)
|
||||
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { state } from '@/stores/ai'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, getConvState, convStates } from './aiShared'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
|
||||
@@ -52,18 +52,24 @@ async function detachPanel(convId?: string) {
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
// F-09:用 per-conv key 写入(多会话各自快照互不覆盖)
|
||||
if (state.streaming && targetConv && state.generatingConvs.has(targetConv)) {
|
||||
localStorage.setItem(genKey(targetConv), targetConv)
|
||||
localStorage.setItem(textKey(targetConv), state.currentText)
|
||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
if (state.streaming && targetConv) {
|
||||
const cs = getConvState(targetConv)
|
||||
const gen = cs === 'generating' || cs === 'stopping' || cs === 'compressed'
|
||||
if (gen) {
|
||||
localStorage.setItem(genKey(targetConv), targetConv)
|
||||
localStorage.setItem(textKey(targetConv), state.currentText)
|
||||
}
|
||||
}
|
||||
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
|
||||
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
|
||||
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
|
||||
// F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// F-09:仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
|
||||
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
|
||||
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
|
||||
if (targetConv) state.generatingConvs.delete(targetConv)
|
||||
if (targetConv) convStates.delete(targetConv)
|
||||
const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow(label, {
|
||||
@@ -135,7 +141,7 @@ function cleanupAllDetachedSnapshots(): void {
|
||||
* 1. state.currentText = df-ai-text-${convId}(已生成文本)
|
||||
* 2. push 占位 assistant 气泡(content='')
|
||||
* 3. state.streaming = true
|
||||
* 4. state.generatingConvs.add(df-ai-gen)
|
||||
* 4. convStates.set(df-ai-gen, 'generating')(批4 双轨收口:替代旧 generatingConvs.add)
|
||||
*
|
||||
* 幂等:若 state.streaming 已是 true(已恢复过/正在生成),直接返回不重复 push。
|
||||
* 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false
|
||||
@@ -194,7 +200,9 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
|
||||
state.generatingConvs.add(gen)
|
||||
// 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。
|
||||
// setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。
|
||||
convStates.set(gen, 'generating')
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface ToolResult {
|
||||
body?: string
|
||||
body_bytes?: number
|
||||
elapsed_ms?: number
|
||||
// read_file search 模式(BUG-260625-02):传 search 参数返命中行 matches[{line,content}] 非全文 content
|
||||
search?: string
|
||||
// grep 跨文件内容搜索结果字段(F-260621):
|
||||
// - output_mode: content(默认)/ files_with_matches / count
|
||||
// - matches: content 模式命中行 [{file,line,content,context?}]
|
||||
|
||||
@@ -155,7 +155,8 @@ export default {
|
||||
dirAuthRequired: 'Path authorization required',
|
||||
dirAuthHint: '{tool} wants to access: {path}',
|
||||
dirAuthOnce: 'This time',
|
||||
dirAuthAlways: 'Always allow',
|
||||
dirAuthSession: 'This session',
|
||||
dirAuthAlways: 'Always',
|
||||
dirAuthDeny: 'Deny',
|
||||
dirAuthFailed: 'Authorization failed: {msg}',
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ export default {
|
||||
deleting: 'Deleting…',
|
||||
promoting: 'Promoting…',
|
||||
|
||||
// Errors
|
||||
err: {
|
||||
loadFailed: 'Failed to load ideas',
|
||||
createFailed: 'Failed to create idea',
|
||||
deleteFailed: 'Failed to delete idea',
|
||||
relateFailed: 'Failed to update relations',
|
||||
},
|
||||
|
||||
// Adversarial evaluation
|
||||
adversarialTitle: '⚖️ Adversarial Evaluation',
|
||||
evalModeHeuristic: 'Heuristic',
|
||||
@@ -112,8 +120,17 @@ export default {
|
||||
captureTitle: '✨ Capture New Idea',
|
||||
fieldTitle: 'Title',
|
||||
fieldDesc: 'Description',
|
||||
fieldTags: 'Tags',
|
||||
fieldPriority: 'Priority',
|
||||
fieldSource: 'Source',
|
||||
titlePlaceholder: 'Describe your idea in one sentence...',
|
||||
descPlaceholder: 'More details (optional)...',
|
||||
tagsPlaceholder: 'Comma-separated, e.g. tools, efficiency, automation',
|
||||
sourcePlaceholder: 'Source (optional, e.g. user feedback, competitor analysis)',
|
||||
priorityCritical: 'Critical',
|
||||
priorityHigh: 'High',
|
||||
priorityMedium: 'Medium',
|
||||
priorityLow: 'Low',
|
||||
|
||||
// Deep link not found
|
||||
notFound: 'Idea not found or deleted',
|
||||
|
||||
@@ -111,6 +111,9 @@ export default {
|
||||
aiLanguageZh: '🇨🇳 简体中文',
|
||||
aiLanguageEn: '🇺🇸 English',
|
||||
|
||||
labelDataDir: 'App Data Directory',
|
||||
descDataDir: 'Where DevFlow stores .trash (recycle bin), database, logs and other internal data',
|
||||
|
||||
labelAutoExecute: 'AI auto-execute scope',
|
||||
descAutoExecute: 'Control risk level AI auto-executes (low/medium/all-takeover)',
|
||||
autoModeLow: 'Low risk (default)',
|
||||
|
||||
@@ -155,8 +155,9 @@ export default {
|
||||
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
||||
dirAuthRequired: '需要路径授权',
|
||||
dirAuthHint: '{tool} 想访问:{path}',
|
||||
dirAuthOnce: '仅本次',
|
||||
dirAuthAlways: '未来都允许',
|
||||
dirAuthOnce: '本次',
|
||||
dirAuthSession: '当前会话',
|
||||
dirAuthAlways: '始终放行',
|
||||
dirAuthDeny: '拒绝',
|
||||
dirAuthFailed: '授权失败:{msg}',
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ export default {
|
||||
deleting: '删除中…',
|
||||
promoting: '立项中…',
|
||||
|
||||
// 错误
|
||||
err: {
|
||||
loadFailed: '加载灵感失败',
|
||||
createFailed: '创建灵感失败',
|
||||
deleteFailed: '删除灵感失败',
|
||||
relateFailed: '更新关联关系失败',
|
||||
},
|
||||
|
||||
// 对抗式评估
|
||||
adversarialTitle: '⚖️ 对抗式评估',
|
||||
evalModeHeuristic: '启发式',
|
||||
@@ -112,8 +120,17 @@ export default {
|
||||
captureTitle: '✨ 捕捉新灵感',
|
||||
fieldTitle: '标题',
|
||||
fieldDesc: '描述',
|
||||
fieldTags: '标签',
|
||||
fieldPriority: '优先级',
|
||||
fieldSource: '来源',
|
||||
titlePlaceholder: '一句话描述你的灵感...',
|
||||
descPlaceholder: '详细说明(可选)...',
|
||||
tagsPlaceholder: '用逗号分隔,如: 工具,效率,自动化',
|
||||
sourcePlaceholder: '来源(可选,如:用户反馈、竞品分析)',
|
||||
priorityCritical: '紧急',
|
||||
priorityHigh: '高',
|
||||
priorityMedium: '中',
|
||||
priorityLow: '低',
|
||||
|
||||
// 深链不存在提示
|
||||
notFound: '灵感不存在或已删除',
|
||||
|
||||
@@ -111,6 +111,9 @@ export default {
|
||||
aiLanguageZh: '🇨🇳 简体中文',
|
||||
aiLanguageEn: '🇺🇸 English',
|
||||
|
||||
labelDataDir: '应用数据目录',
|
||||
descDataDir: 'DevFlow 存储 .trash(回收站)、数据库、日志等内部数据的目录路径',
|
||||
|
||||
labelAutoExecute: 'AI 自动执行范围',
|
||||
descAutoExecute: '控制 AI 自动执行操作的风险等级(低/中/全部接管)',
|
||||
autoModeLow: '低风险(默认)',
|
||||
|
||||
+120
-33
@@ -50,6 +50,9 @@ const MESSAGE_CAP = 200
|
||||
const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计)
|
||||
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||
import { useAiStream } from '@/composables/ai/useAiStream'
|
||||
// 批4 双轨收口:getConvState 从 aiShared 取(下沉破环,避免 stores/ai↔useAiEvents 反向环)。
|
||||
// F-09 per-conv streaming/currentText(DEC-07a 父④):convStreamStates Map + 辅助函数亦从 aiShared 取。
|
||||
import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText } from '@/composables/ai/aiShared'
|
||||
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
|
||||
import { useAiApproval } from '@/composables/ai/useAiApproval'
|
||||
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
||||
@@ -58,24 +61,67 @@ import { useAiPanel } from '@/composables/ai/useAiPanel'
|
||||
import { useAiContext } from '@/composables/ai/useAiContext'
|
||||
|
||||
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
|
||||
export const state = reactive({
|
||||
messages: [] as AiMessage[],
|
||||
streaming: false,
|
||||
currentText: '',
|
||||
pendingApprovals: [] as AiToolCallInfo[],
|
||||
// 正在生成的会话集合(F-09 多会话并发):后端 per-conv 已支持多会话同时生成,
|
||||
// 前端用 Set 表达"多会话同时生成"态。生成中切走时用于路由,后台事件不污染当前视图。
|
||||
// isGenerating(convId)/addGenerating/removeGenerating helper 经 useAiStore 暴露。
|
||||
// 单会话场景行为不变(Set 含 0 或 1 元素)。
|
||||
generatingConvs: new Set<string>(),
|
||||
providers: [] as AiProviderConfig[],
|
||||
activeProvider: null as string | null,
|
||||
//
|
||||
// F-09 B 路线 per-conv streaming/currentText(DEC-07a 父④):
|
||||
// `streaming`/`currentText` 不再是全局单例字段,改为 accessor 委派 aiShared.convStreamStates
|
||||
// (per-conv Map,定义于 composables/ai/aiShared.ts)按 activeConversationId 索引读写。
|
||||
//
|
||||
// 单会话回归铁律:单会话场景 activeConversationId 恒定,Map 仅此一项,accessor 行为等价原全局
|
||||
// 单例(读返回该项值,写改该项值)。所有现有消费方 `store.state.streaming` / `store.state.currentText`
|
||||
// 零改动透明继承(read = get active 项,write = set active 项)。
|
||||
//
|
||||
// 多会话收益:切会话(activeConversationId 变)即切到该会话的 stream state,A 后台生成中切到 B,
|
||||
// B 的 streaming/currentText 读各自 Map 项,A 残留 currentText 不串到 B 视图(BUG-260624-01 根因清除)。
|
||||
//
|
||||
// accessor 实现要点:
|
||||
// - get:读 activeConversationId → 取 convStreamStates 项;无 active/无项 → streaming 返回 false、
|
||||
// currentText 返回 ''(与原全局单例初值一致,空安全)。
|
||||
// - set:读 activeConversationId → 写 convStreamStates 对应项;无 active 则 no-op(无目标 conv
|
||||
// 无法路由,原全局单例此场景也写无意义的全局值,no-op 更干净)。
|
||||
// - reactive 追踪:Vue 3 reactive 包装带 accessor 描述符的对象时,会保留 get/set 并经
|
||||
// Reflect.get/set 调用,track/trigger 正常工作。`state.streaming` 读取建立对 activeConversationId
|
||||
// + 对应 Map 项的响应式依赖,任一变化触发更新。
|
||||
// - 闭包引用 state 本身:get/set 需读 state.activeConversationId,用下方 `stateRef` 闭包变量指向
|
||||
// reactive 包装后的对象(先声明后赋值,accessor 闭包延迟读 stateRef,避免 TDZ)。
|
||||
const _stateBase: {
|
||||
messages: AiMessage[]
|
||||
// F-09 per-conv:streaming/currentText 是 accessor(下方 Object.defineProperty 定义),
|
||||
// 委派 aiShared.convStreamStates 按 activeConversationId 索引读写。streaming/currentText
|
||||
// 不在字面量显式赋值(由 accessor 提供),故 _stateBase 类型不含此两键;
|
||||
// 通过下方 StateWithStream 接口扩展导出 state 的完整类型(消费方编译可见)。
|
||||
pendingApprovals: AiToolCallInfo[]
|
||||
providers: AiProviderConfig[]
|
||||
activeProvider: string | null
|
||||
panelOpen: boolean
|
||||
maximized: boolean
|
||||
conversations: AiConversationSummary[]
|
||||
activeConversationId: string | null
|
||||
sidebarOpen: boolean
|
||||
sidebarWidth: number
|
||||
detached: boolean
|
||||
docked: boolean
|
||||
skills: SkillInfo[]
|
||||
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[]
|
||||
agentRound: number
|
||||
archivedCollapsed: boolean
|
||||
foldedGroups: Record<string, boolean>
|
||||
searchQuery: string
|
||||
lastTokenUsage: { prompt: number; completion: number; total: number } | null
|
||||
convTokenTotal: { prompt: number; completion: number; total: number } | null
|
||||
} = {
|
||||
messages: [],
|
||||
pendingApprovals: [],
|
||||
// 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates
|
||||
// (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。
|
||||
// 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。
|
||||
providers: [],
|
||||
activeProvider: null,
|
||||
panelOpen: true,
|
||||
// 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
|
||||
maximized: true,
|
||||
// 对话管理
|
||||
conversations: [] as AiConversationSummary[],
|
||||
activeConversationId: null as string | null,
|
||||
conversations: [],
|
||||
activeConversationId: null,
|
||||
sidebarOpen: false,
|
||||
// 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16)
|
||||
sidebarWidth: 200,
|
||||
@@ -84,24 +130,66 @@ export const state = reactive({
|
||||
// 吸附跟随中
|
||||
docked: false,
|
||||
// 本机技能(`/` 联想)
|
||||
skills: [] as SkillInfo[],
|
||||
skills: [],
|
||||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测)
|
||||
// F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图)
|
||||
queue: [] as { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[],
|
||||
queue: [],
|
||||
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
||||
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
|
||||
agentRound: 0,
|
||||
// 归档分组折叠态(默认折叠)
|
||||
archivedCollapsed: true,
|
||||
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
|
||||
foldedGroups: { today: false, yesterday: false, earlier: true } as Record<string, boolean>,
|
||||
foldedGroups: { today: false, yesterday: false, earlier: true },
|
||||
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
|
||||
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
|
||||
searchQuery: '',
|
||||
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
|
||||
lastTokenUsage: null as { prompt: number; completion: number; total: number } | null,
|
||||
convTokenTotal: null as { prompt: number; completion: number; total: number } | null,
|
||||
lastTokenUsage: null,
|
||||
convTokenTotal: null,
|
||||
}
|
||||
|
||||
// F-09 per-conv streaming/currentText accessor 委派层:先声明 stateRef 占位,
|
||||
// reactive 包装后回填,accessor 闭包经 stateRef 读 activeConversationId。
|
||||
let stateRef: typeof _stateBase | null = null
|
||||
|
||||
// 在 _stateBase 上定义 streaming/currentText accessor(委派 convStreamStates per-conv Map)。
|
||||
// 注:用 Object.defineProperty 而非字面量 getter,避免与上方类型声明冲突;reactive 会保留此描述符。
|
||||
Object.defineProperty(_stateBase, 'streaming', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get(): boolean {
|
||||
const s = stateRef
|
||||
if (!s) return false
|
||||
return getConvStreamState(s.activeConversationId)?.streaming ?? false
|
||||
},
|
||||
set(value: boolean) {
|
||||
const s = stateRef
|
||||
if (!s) return
|
||||
setConvStreaming(s.activeConversationId, value)
|
||||
},
|
||||
})
|
||||
Object.defineProperty(_stateBase, 'currentText', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get(): string {
|
||||
const s = stateRef
|
||||
if (!s) return ''
|
||||
return getConvStreamState(s.activeConversationId)?.currentText ?? ''
|
||||
},
|
||||
set(value: string) {
|
||||
const s = stateRef
|
||||
if (!s) return
|
||||
setConvCurrentText(s.activeConversationId, value)
|
||||
},
|
||||
})
|
||||
|
||||
export const state = reactive(_stateBase) as typeof _stateBase & {
|
||||
streaming: boolean
|
||||
currentText: string
|
||||
}
|
||||
// 回填 stateRef:accessor 闭包读此引用(经 reactive 包装后的代理对象,activeConversationId 变化可追踪)。
|
||||
stateRef = state
|
||||
|
||||
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
|
||||
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
|
||||
@@ -189,20 +277,20 @@ const filteredConversations = computed<AiConversationSummary[] | null>(() => {
|
||||
})
|
||||
|
||||
/**
|
||||
* F-09 多会话并发:生成态 Set helper。
|
||||
* F-09 多会话并发:生成态 helper(批4 双轨收口后改为派生)。
|
||||
*
|
||||
* Vue 3 reactive 对 Set 的 has/add/delete 均有 proxy 跟踪,在 computed/template
|
||||
* 内调用 isGenerating(convId) 会建立响应式依赖,Set 变更时自动触发更新。
|
||||
* 单会话场景:Set 含 0 或 1 元素,行为与原单值 generatingConvId 等价。
|
||||
* 批4 收口:generatingConvs(Set,bool 轨)已退役,生成态真相归 convStates(enum Map,定义于
|
||||
* aiShared.ts)。本 helper 改派生自 getConvState:conv_state∈{generating,stopping,compressed}
|
||||
* (非终止三态)视为生成中,idle/error/null 视为不在生成。桥接语义对齐原 bool 轨 has() 判定。
|
||||
*
|
||||
* Vue 3 reactive 对 Map 的 get 有 proxy 跟踪,在 computed/template 内调用 isGenerating(convId)
|
||||
* 仍建立响应式依赖,convStates 变更时自动触发更新(等价原 Set 行为)。
|
||||
* addGenerating/removeGenerating 写 helper 已删(写收敛到 useAiEvents setConvState 唯一入口)。
|
||||
*/
|
||||
function addGenerating(convId: string): void {
|
||||
state.generatingConvs.add(convId)
|
||||
}
|
||||
function removeGenerating(convId: string): void {
|
||||
state.generatingConvs.delete(convId)
|
||||
}
|
||||
function isGenerating(convId: string | null | undefined): boolean {
|
||||
return !!convId && state.generatingConvs.has(convId)
|
||||
if (!convId) return false
|
||||
const cs = getConvState(convId)
|
||||
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,9 +305,8 @@ export function useAiStore() {
|
||||
return {
|
||||
state,
|
||||
filteredConversations,
|
||||
// F-09: 生成态 Set helper(替代单值 generatingConvId 的读写)
|
||||
addGenerating,
|
||||
removeGenerating,
|
||||
// F-09: 生成态 helper(批4 双轨收口:仅读 isGenerating 派生自 getConvState;
|
||||
// addGenerating/removeGenerating 写 helper 已删,写收敛到 useAiEvents setConvState)
|
||||
isGenerating,
|
||||
...useAiEvents(),
|
||||
...useAiStream(),
|
||||
|
||||
@@ -61,6 +61,15 @@ export function createIdeasStore() {
|
||||
return res
|
||||
}
|
||||
|
||||
async function relateIdeas(subjectId: string, targetIds: string[]) {
|
||||
try {
|
||||
await ideaApi.relateIdeas(subjectId, targetIds)
|
||||
await loadIdeas() // 后端已原子更新全部受影响灵感,刷新列表保持 store 一致
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? t('ideas.err.relateFailed')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadIdeas,
|
||||
createIdea,
|
||||
@@ -68,5 +77,6 @@ export function createIdeasStore() {
|
||||
deleteIdea,
|
||||
evaluateIdea,
|
||||
promoteIdea,
|
||||
relateIdeas,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 仅提取 ≥2 处逐字相同的规则(已 grep 比对一致)
|
||||
- scoped 故意 override 全局值的(如 .empty-hint padding 24px vs 全局 12px)
|
||||
不提取,保留各组件 scoped 覆盖(零视觉变化)
|
||||
- global.css 已有的 .btn/.modal-*/.page-header/.empty-hint/.empty-state
|
||||
- global.css 已有的 .btn / .modal 系列 / .page-header / .empty-hint / .empty-state
|
||||
不在此重复,仅补充尚未全局化的类
|
||||
|
||||
落地批:DRY 收口(B-260619)
|
||||
|
||||
+25
-2
@@ -112,6 +112,17 @@
|
||||
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
|
||||
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTags') }}</label>
|
||||
<input v-model="newIdeaTags" :placeholder="$t('ideas.tagsPlaceholder')" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldPriority') }}</label>
|
||||
<select v-model.number="newIdeaPriority" class="modal-select">
|
||||
<option :value="0">{{ $t('ideas.priorityCritical') }} (P0)</option>
|
||||
<option :value="1">{{ $t('ideas.priorityHigh') }} (P1)</option>
|
||||
<option :value="2">{{ $t('ideas.priorityMedium') }} (P2)</option>
|
||||
<option :value="3">{{ $t('ideas.priorityLow') }} (P3)</option>
|
||||
</select>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldSource') }}</label>
|
||||
<input v-model="newIdeaSource" :placeholder="$t('ideas.sourcePlaceholder')" />
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn-confirm" :disabled="creating" @click="confirmCapture">
|
||||
@@ -164,6 +175,9 @@ const pageSize = ref(0)
|
||||
const showCaptureModal = ref(false)
|
||||
const newIdeaTitle = ref('')
|
||||
const newIdeaDesc = ref('')
|
||||
const newIdeaTags = ref('') // 逗号分隔输入(对标 KnowledgeDetail.vue tagsInput)
|
||||
const newIdeaPriority = ref(2) // 默认 medium,对标 CreateIdeaInput 0-3
|
||||
const newIdeaSource = ref('')
|
||||
|
||||
// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ──
|
||||
const creating = ref(false)
|
||||
@@ -274,6 +288,9 @@ function scoreClass(score: number | null) {
|
||||
function openCaptureModal() {
|
||||
newIdeaTitle.value = ''
|
||||
newIdeaDesc.value = ''
|
||||
newIdeaTags.value = ''
|
||||
newIdeaPriority.value = 2
|
||||
newIdeaSource.value = ''
|
||||
showCaptureModal.value = true
|
||||
}
|
||||
|
||||
@@ -281,9 +298,14 @@ async function confirmCapture() {
|
||||
if (!newIdeaTitle.value.trim() || creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
// tags 逗号分隔 → JSON 字符串(对标 KnowledgeDetail.vue saveEdit split+trim+filter 模式)
|
||||
const tags = newIdeaTags.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await store.createIdea({
|
||||
title: newIdeaTitle.value.trim(),
|
||||
description: newIdeaDesc.value.trim() || undefined,
|
||||
priority: newIdeaPriority.value,
|
||||
tags: JSON.stringify(tags),
|
||||
source: newIdeaSource.value.trim() || undefined,
|
||||
})
|
||||
showCaptureModal.value = false
|
||||
} finally {
|
||||
@@ -351,10 +373,10 @@ async function onUpdateDesc(desc: string) {
|
||||
await store.updateIdea(currentIdea.value.id, 'description', desc)
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存)
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存,双向同步)
|
||||
async function onUpdateRelated(ids: string[]) {
|
||||
if (!currentIdea.value) return
|
||||
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids))
|
||||
await store.relateIdeas(currentIdea.value.id, ids)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -566,6 +588,7 @@ watch(() => route.params.id, (id) => {
|
||||
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
|
||||
.modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); }
|
||||
.modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
|
||||
.modal-box .modal-select { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
|
||||
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
|
||||
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
|
||||
|
||||
+11
-11
@@ -154,16 +154,16 @@
|
||||
<p v-if="sourceAiAnalysis.summary" class="source-summary">{{ sourceAiAnalysis.summary }}</p>
|
||||
<!-- 多维评分条(借鉴 IdeaDetail parseScores 渲染) -->
|
||||
<div v-if="sourceScores.length" class="source-scores">
|
||||
<div class="radar-row" v-for="dim in sourceScores" :key="dim.name">
|
||||
<span class="radar-label">{{ dim.name }}</span>
|
||||
<div class="radar-bar-track">
|
||||
<div class="score-bar-row" v-for="dim in sourceScores" :key="dim.name">
|
||||
<span class="score-bar-label">{{ dim.name }}</span>
|
||||
<div class="score-bar-track">
|
||||
<div
|
||||
class="radar-bar-fill"
|
||||
class="score-bar-fill"
|
||||
:class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"
|
||||
:style="{ width: dim.score + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="radar-value">{{ dim.score }}</span>
|
||||
<span class="score-bar-value">{{ dim.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -634,24 +634,24 @@ onUnmounted(() => {
|
||||
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
/* 多维评分条(复用 IdeaDetail radar-* 样式定义) */
|
||||
/* 多维评分条(复用 IdeaDetail score-bar-* 样式定义) */
|
||||
.source-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.radar-row { display: flex; align-items: center; gap: 10px; }
|
||||
.radar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; }
|
||||
.radar-bar-track {
|
||||
.score-bar-row { display: flex; align-items: center; gap: 10px; }
|
||||
.score-bar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; }
|
||||
.score-bar-track {
|
||||
flex: 1; height: 8px; background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs); overflow: hidden;
|
||||
}
|
||||
.radar-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; }
|
||||
.score-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; }
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.radar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }
|
||||
.score-bar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# DevFlow 待办
|
||||
|
||||
## 灵感模块功能完善(合并推进)
|
||||
|
||||
> 诊断发现的 P0-P2 问题合并为单次任务,逐项推进。
|
||||
> 排除项:#3 status 无状态机(暂保留状态可跳跃迁移,不处理)。
|
||||
|
||||
### P0 — 数据安全 / 正确性
|
||||
|
||||
- [x] **#1 软删除机制**:ideas 表加 `deleted_at` 列 + 软删除(对标 tasks/projects 的 `delete` + `restore` 全套模板)。当前 `delete_idea` 是硬删除,误删不可恢复。
|
||||
- [x] **#2 白名单收紧**:`settings.rs` ideas 白名单移除 `id` / `created_at`,防止主键和创建时间被篡改。(对标 B-260616-16 tasks 白名单修复)
|
||||
- [x] **#4 priority 值域校验**:`create_idea` / `update_idea` 加 priority 校验(`parse::<i32>() ∈ 0..=3`,非法返 Err)。(对标 B-260615-15 tasks priority 修复)
|
||||
|
||||
### P1 — 架构 / 一致性
|
||||
|
||||
- [x] **#5 有损转换修复**:`record_to_idea()` 不再硬编码 `status=Draft` / `related_ids=空`,从 IdeaRecord 读取真实值,评估时不丢失上下文。
|
||||
- [x] **#6 评分关键词拆文件**:将 `scoring.rs` 硬编码关键词拆到独立 const 文件(最小改动)。启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。**不做 DB 表配置化**(过度工程)。
|
||||
- [x] **#7 promote 补偿改软删除**:`promote_idea` 回写失败时的补偿删除从 `purge_with_descendants`(不可恢复)改为 `soft_delete`(软删除,可恢复)。
|
||||
- [x] **#8 关联双向同步**:A 关联 B 时自动在 B 的 `related_ids` 中补入 A,解绑时同步移除,保持关联关系对称。
|
||||
- [x] **#9 创建表单补 tags 输入**:捕捉灵感表单增加 tags 输入(逗号分隔或 chip 组件),`confirmCapture` 传 tags JSON 字符串给后端。
|
||||
|
||||
### P2 — 前端 / UX
|
||||
|
||||
- [x] **#10 创建表单补全 priority/source**:捕捉模态框增加 priority 下拉(低/中/高/紧急)和 source 输入框。
|
||||
- [~] **#12 前端 filter 改后端查询**:`hot`(score≥80)和 `pending`(多状态 OR)仍保留前端 filter(团队决策:扩多值属过度设计),keyword/order_by 已下沉后端。
|
||||
- [x] **#14 雷达图正名/替换**:将 i18n key 和 CSS class 中的 "radar" 改为 "score-bar"(或引入真正的雷达图组件如 ECharts radar)。
|
||||
|
||||
## AI 对话目标丢失(诊断待办)
|
||||
|
||||
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
|
||||
> 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。
|
||||
|
||||
- [~] **G1 目标钉扎** ✅代码落地(编译过):`PerConvState.pinned_goal` + chat.rs 两处提取 + `run_agentic_loop` 入口拼 system_prompt。核心治 R1。**待实测**。
|
||||
- [~] **G2 探索熔断** ✅代码落地(编译过):`STALL_BREAKER_*` 四开关 + stall_count + `is_empty_tool_result`(对齐 serde_json 紧凑格式)。警示 `insert_at(0,system)` **经核验安全**(convert_request:101/117 把 system 抽顶层合并,无连续 System 1214 风险;G1 评审该论据不成立)。**待实测**。
|
||||
- [~] **G3 状态机收敛目标** ✅文档衔接:`conv_state.rs`/`mod.rs` 注释明示 pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum。
|
||||
- [~] **G4 话题标记修正** ✅代码落地(编译过):`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goal 存在时 take 后丢弃跳过 insert。**待实测**。
|
||||
- [x] **G5 附 bug** ✅已排查(不改代码):dump DB 确认 seq1(thinking 独白)+seq4(tool_result 内容)均 system+**早期 timestamp**(旧版本遗留脏数据),非当前 compress summary 机制问题(摘要四段式正常)。最新 system(seq0 topic marker)正常。**结论:历史脏数据,数据治理项**(可选清 DB),非当前 bug。anthropic_compat thinking 隔离你在进行中(M)。
|
||||
- [ ] **G1 目标刷新策略**(openQuestion):首版 None 才写,同对话改目标保留旧目标。待观察是否需 drift 刷新。
|
||||
|
||||
> 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。
|
||||
> 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。
|
||||
|
||||
## 对抗式评估接入(引擎已实现 → IPC + 前端打通)
|
||||
|
||||
> 引擎代码已就绪(`df-ideas/src/adversarial.rs` + `adversarial_helpers.rs`),
|
||||
> 但 IPC 层和前端零引用,用户无法使用。需补接入层。
|
||||
|
||||
### 后端 — IPC 接入
|
||||
|
||||
- [ ] **A1 evaluate_idea 命令**:新增 IPC command,按 Provider 配置构造 `AdversarialEngine`(有 Provider → `new(provider)`,无 → `heuristic()`),调 `evaluate(idea)` 返回 `AdversarialEval`。
|
||||
- [ ] **A2 批量评估命令**:`evaluate_ideas_batch`,遍历多个 idea 调同一 engine 实例(复用 provider 连接)。
|
||||
- [ ] **A3 评估结果持久化**:评估结果写回 `ideas` 表(scores + evaluated_by + evaluated_at),避免每次重新评估。
|
||||
- [ ] **A4 类型导出**:`AdversarialEval` / `EvaluatedBy` / `Recommendation` 等类型经 IPC serde 暴露给前端(检查 `df-types` 是否需补充)。
|
||||
|
||||
### 前端 — 结果展示
|
||||
|
||||
- [ ] **B1 灵感详情页评估面板**:展示正方论点 / 反方论点 / 分析师综合评分,`evaluated_by` 标签区分(LLM / 启发式兜底 / 纯启发式)。
|
||||
- [ ] **B2 评估触发按钮**:灵感详情页加「深度评估」按钮,调 `evaluate_idea` IPC,loading 状态 + 错误提示。
|
||||
- [ ] **B3 评分维度可视化**:可行性 / 影响力 / 紧急度三维度展示(复用现有 score-bar 或引入 radar)。
|
||||
Reference in New Issue
Block a user