Compare commits
10
Commits
40b74e11bf
...
9c3f27f4ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3f27f4ca | ||
|
|
a6d692270f | ||
|
|
9189877a59 | ||
|
|
bb2cfcbb62 | ||
|
|
80239500f1 | ||
|
|
4b754fe230 | ||
|
|
e33e8f86d3 | ||
|
|
0c348f2311 | ||
|
|
5a893680b7 | ||
|
|
744b68da5b |
@@ -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"
|
||||
|
||||
@@ -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..]
|
||||
@@ -1467,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", "截止", "先行", "阻塞",
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
# df-relay 跨端 WS 中继反向代理(miniapp ↔ 桌面端)
|
||||
# wss 在 nginx 终止(复用 *.1216.top 通配符证书),明文 ws 转 df-relay :9180
|
||||
|
||||
# HTTP → HTTPS 重定向
|
||||
server {
|
||||
listen 80;
|
||||
server_name u-work.1216.top;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS wss 反代
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name u-work.1216.top;
|
||||
|
||||
include /etc/nginx/snippets/ssl-1216.conf;
|
||||
|
||||
# df-relay 两个 WS 端点原样转发(/ws/device + /ws/miniapp)
|
||||
# proxy_pass 不带路径后缀 → 完整 URI 透传给 df-relay
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:9180;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,17 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
dot / (norm_a * norm_b + 1e-8)
|
||||
}
|
||||
|
||||
/// 解析 JSON 数组字符串为 Vec<String>。NULL / 空 / 非法 → 空 Vec。
|
||||
fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
|
||||
match raw {
|
||||
Some(s) if !s.is_empty() => match serde_json::from_str::<Vec<String>>(s) {
|
||||
Ok(v) => v,
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
@@ -385,6 +396,120 @@ impl IdeaRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
|
||||
///
|
||||
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
|
||||
/// 新增的关联目标追加 `subject_id` 到其 `related_ids`;
|
||||
/// 移除的关联目标从中删除 `subject_id`。
|
||||
/// 全部操作在同一 SQLite 事务中完成,保证原子性。
|
||||
pub async fn sync_related_ids(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
new_target_ids: &[String],
|
||||
) -> Result<()> {
|
||||
use rusqlite::Transaction;
|
||||
let conn = self.conn.clone();
|
||||
let subject_id = subject_id.to_owned();
|
||||
let new_target_ids = new_target_ids.to_vec();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx: Transaction = guard
|
||||
.transaction()
|
||||
.map_err(storage_err)?;
|
||||
|
||||
// 1. 读主体当前 related_ids
|
||||
let old_raw: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
||||
params![subject_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?
|
||||
.flatten();
|
||||
|
||||
// 2. 解析新旧集合
|
||||
let old_set: std::collections::HashSet<String> =
|
||||
parse_json_id_array(&old_raw).into_iter().collect();
|
||||
let new_set: std::collections::HashSet<String> =
|
||||
new_target_ids.iter().cloned().collect();
|
||||
|
||||
let added: Vec<&str> = new_set
|
||||
.difference(&old_set)
|
||||
.map(|s| s.as_str())
|
||||
.filter(|id| *id != subject_id) // 不自关联
|
||||
.collect();
|
||||
let removed: Vec<&str> = old_set
|
||||
.difference(&new_set)
|
||||
.map(|s| s.as_str())
|
||||
.filter(|id| *id != subject_id)
|
||||
.collect();
|
||||
|
||||
// 3. 更新 added 目标:追加 subject_id
|
||||
for target_id in &added {
|
||||
let cur: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
||||
params![target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?
|
||||
.flatten();
|
||||
let mut ids: Vec<String> = parse_json_id_array(&cur);
|
||||
if !ids.iter().any(|i| i == &subject_id) {
|
||||
ids.push(subject_id.clone());
|
||||
}
|
||||
let json = serde_json::to_string(&ids).map_err(|e| {
|
||||
storage_err::<df_types::error::Error>(e.into())
|
||||
})?;
|
||||
tx.execute(
|
||||
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![json, &now, target_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
|
||||
// 4. 更新 removed 目标:移除 subject_id
|
||||
for target_id in &removed {
|
||||
let cur: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT related_ids FROM ideas WHERE id = ?1",
|
||||
params![target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(storage_err)?
|
||||
.flatten();
|
||||
let mut ids: Vec<String> = parse_json_id_array(&cur);
|
||||
ids.retain(|i| i != &subject_id);
|
||||
let json = serde_json::to_string(&ids).map_err(|e| {
|
||||
storage_err::<df_types::error::Error>(e.into())
|
||||
})?;
|
||||
tx.execute(
|
||||
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![json, &now, target_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
|
||||
// 5. 更新主体
|
||||
let new_json = serde_json::to_string(&new_target_ids)
|
||||
.map_err(|e| storage_err::<df_types::error::Error>(e.into()))?;
|
||||
tx.execute(
|
||||
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![new_json, &now, &subject_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
|
||||
tx.commit().map_err(storage_err)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。
|
||||
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
|
||||
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
//! - [`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)
|
||||
@@ -19,7 +21,9 @@ 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;
|
||||
@@ -28,7 +32,9 @@ 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::*;
|
||||
@@ -269,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!(
|
||||
@@ -297,6 +303,8 @@ mod baseline_tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -199,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/
|
||||
// parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表
|
||||
// (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 29] = [
|
||||
// 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),
|
||||
@@ -67,6 +73,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(27, migrate_v27),
|
||||
(28, migrate_v28),
|
||||
(29, migrate_v29),
|
||||
(30, migrate_v30),
|
||||
(31, migrate_v31),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -773,6 +781,127 @@ fn migrate_v29(conn: &Connection) -> Result<()> {
|
||||
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)。
|
||||
|
||||
@@ -146,6 +146,74 @@ pub struct TaskLinkRecord {
|
||||
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,
|
||||
}
|
||||
|
||||
/// 分支记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchRecord {
|
||||
|
||||
@@ -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 的数据采集埋点方案
|
||||
@@ -75,14 +75,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 五、方向建议(仅诊断,未实施)
|
||||
## 五、方向建议(G1/G2/G4 已实施 commit 8ce18cb,2026-06-26)
|
||||
|
||||
最高杠杆:**把「用户当前目标」从消息流提升为一等结构化状态**。
|
||||
|
||||
1. **目标钉扎**:首轮提取用户目标为结构化字段(不依赖压缩摘要),每轮 agentic loop 顶部重注入 system。目标消息压缩出局后,结构化目标仍在。
|
||||
2. **探索熔断**:跟踪连续无进展 / 空结果次数,超阈值强制「回顾目标」或停止,防游荡死循环 + token 失控。
|
||||
3. **状态机收敛**:把目标 + 进度纳入 `conv_state.rs` 状态机(当前分支已在做),目标作为状态而非消息。
|
||||
4. **弱化 / 修正话题标记**:话题标记基于失真历史且可能反向误导,宜降级或改为「目标漂移检测」而非 topic 关键词切换。
|
||||
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)正常。**数据治理项,不改代码**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 限制(列宽限制,过度设计)
|
||||
+6
-6
@@ -39,9 +39,9 @@ graph TD
|
||||
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
|
||||
P2["父② 知识图谱Phase1<br/>✅Phase1(数据层+业务层)"]:::done
|
||||
P3["父③ AI对话体验<br/>📋待办"]:::todo
|
||||
P4["父④ F-09 per-conv<br/>📋待办"]:::todo
|
||||
P5["父⑤ 灵感模块<br/>🔨⑤.1/①.4✅·⑤.2待办"]:::doing
|
||||
P6["父⑥ Phase2-5<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
|
||||
@@ -57,9 +57,9 @@ graph TD
|
||||
| **父①** 小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 | 📋 待办 | ④.1 streaming/currentText per-conv | — |
|
||||
| **父⑤** 灵感模块 | 🔨 进行中 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05 record_to_idea✅ / #06 配置化·#09·#10 表单待办 / #07 promote补偿待定(soft_delete 污染回收站 vs purge 内部回滚干净?) | ⑤.2#07→②.1 |
|
||||
| **父⑥** Phase2-5 | 📋 待办 | ⑥.1事件流 / ⑥.2基础设施 / ⑥.3注入 / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 |
|
||||
| **父④** 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 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
|
||||
|
||||
@@ -463,9 +463,9 @@ pub async fn ai_chat_send(
|
||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,连续追问视为同目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入。
|
||||
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,支持中途换目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不刷新。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
@@ -751,12 +751,12 @@ pub async fn ai_approve(
|
||||
Ok("completed".to_string())
|
||||
}
|
||||
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,读 authz-debug.log 定位)。
|
||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
|
||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||
fn authz_debug(msg: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true)
|
||||
.open("E:/wk-lab/devflow/authz-debug.log")
|
||||
{
|
||||
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
@@ -774,17 +774,10 @@ fn drive_letter(path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// workspace_root 盘符(同 mod.rs workspace_root_str 派生:CARGO_MANIFEST_DIR 上两级)。
|
||||
///
|
||||
/// 与 tool_registry::workspace_root() 同源(编译期 CARGO_MANIFEST_DIR),用于跨盘对比基准。
|
||||
/// 失败回退 None(对比退化,不报跨盘提示)。
|
||||
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
|
||||
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
|
||||
fn workspace_drive() -> Option<String> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
drive_letter(&root.to_string_lossy())
|
||||
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
|
||||
@@ -1366,6 +1359,13 @@ pub async fn ai_chat_edit(
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
||||
conv.model_override = model_override.clone();
|
||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&new_message);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
@@ -1531,10 +1531,10 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,与 send 路径一致)。
|
||||
// G1 目标钉扎:push 后提取本次 user 目标刷新 pinned_goal(每次覆盖,与 send 路径一致,支持中途换目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 →
|
||||
// pinned_goal 已更新,续跑用新目标)。
|
||||
if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() {
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() {
|
||||
conv.pinned_goal = Some(goal);
|
||||
|
||||
@@ -402,7 +402,7 @@ pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
|
||||
"run_command" => {
|
||||
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => normalize_dir_key(d),
|
||||
None => workspace_root_str(),
|
||||
None => String::new(),
|
||||
};
|
||||
Some(TrustKey::Execute { dir })
|
||||
}
|
||||
@@ -423,7 +423,7 @@ fn dir_of_path_normalized(path: &str) -> String {
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
normalize_dir_key(&parent.to_string_lossy())
|
||||
}
|
||||
Err(_) => workspace_root_str(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,7 +783,7 @@ pub struct PerConvState {
|
||||
/// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send push 后提取(仅 None 时写首条);读:loop 入口拼接。
|
||||
/// 写:chat.rs send/force_send push 后提取(每次覆盖,支持中途换目标);读:loop 入口拼接。
|
||||
/// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goal: Option<String>,
|
||||
|
||||
@@ -9,7 +9,7 @@ use tokio::sync::{Mutex as TokioMutex, RwLock};
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_execute::shell::{execute, ShellRequest};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
|
||||
use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord};
|
||||
|
||||
use df_types::types::new_id;
|
||||
|
||||
@@ -286,15 +286,6 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String {
|
||||
format!("{}_{}", modified.unwrap_or(0), meta.len())
|
||||
}
|
||||
|
||||
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
|
||||
///
|
||||
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
|
||||
@@ -339,19 +330,15 @@ async fn rename_or_cross_volume_copy(
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
|
||||
/// 解析文件工具路径:相对路径锚定已授权目录的首个 persistent 目录,禁止越出项目目录。
|
||||
///
|
||||
/// 双层校验:
|
||||
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
|
||||
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
|
||||
/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
|
||||
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
|
||||
///
|
||||
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
|
||||
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
|
||||
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
|
||||
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
|
||||
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
|
||||
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
|
||||
/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
|
||||
/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
|
||||
fn resolve_workspace_path_with_allowed(
|
||||
path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
@@ -363,31 +350,31 @@ fn resolve_workspace_path_with_allowed(
|
||||
/// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数
|
||||
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
|
||||
///
|
||||
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
|
||||
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
|
||||
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
|
||||
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
|
||||
/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
|
||||
/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
|
||||
/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响。
|
||||
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
|
||||
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
|
||||
resolve_workspace_path_impl(path, &AllowedDirs::default())
|
||||
}
|
||||
|
||||
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
|
||||
validate_path(path)?;
|
||||
let root = workspace_root();
|
||||
let resolved = if Path::new(path).is_absolute() {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
// 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
|
||||
// 无授权目录时给出明确引导,避免指向编译期常量。
|
||||
let root = allowed.first_persistent_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
|
||||
))?;
|
||||
root.join(path)
|
||||
};
|
||||
// 词法层:防明显越界(不存在路径的兜底)。
|
||||
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
|
||||
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
|
||||
if !allowed.is_authorized(&resolved) {
|
||||
anyhow::bail!("路径不在授权目录内: {}", path);
|
||||
}
|
||||
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
|
||||
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
|
||||
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
|
||||
if resolved.exists() {
|
||||
let canon_resolved = resolved.canonicalize()?;
|
||||
if !allowed.is_authorized(&canon_resolved) {
|
||||
@@ -449,10 +436,11 @@ async fn bind_dir_to_project(
|
||||
pub fn build_ai_tool_registry(
|
||||
db: &Arc<Database>,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
data_dir: PathBuf,
|
||||
) -> AiToolRegistry {
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register_data_tools(&mut registry, db);
|
||||
register_file_tools(&mut registry, allowed_dirs);
|
||||
register_file_tools(&mut registry, allowed_dirs, data_dir);
|
||||
register_http_tools(&mut registry);
|
||||
registry
|
||||
}
|
||||
@@ -490,7 +478,7 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 数据层 AI 工具注册(24 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
|
||||
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
|
||||
///
|
||||
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
|
||||
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经
|
||||
@@ -498,6 +486,10 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
|
||||
///
|
||||
/// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD
|
||||
/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。
|
||||
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流查询),
|
||||
/// data 层 24→25。
|
||||
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
|
||||
/// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。
|
||||
///
|
||||
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
|
||||
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
|
||||
@@ -510,8 +502,12 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
|
||||
/// - register_trash_tools(1):list_trash
|
||||
/// 知识图谱 Phase 1(2026-06-26)新增第 6 子函数:
|
||||
/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新
|
||||
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增 6,
|
||||
/// 行为零变更(create_task 扩展参数 + 6 新工具,基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
|
||||
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流,只读),
|
||||
/// 该子函数 6→7 工具,data 层 24→25。
|
||||
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
|
||||
/// list_project_services(Low 只读) 项目基础设施配置(D10 不存凭证),该子函数 7→9 工具,data 层 25→27。
|
||||
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增,
|
||||
/// 行为零变更(create_task 扩展参数 + 7 新工具,基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
|
||||
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
register_project_tools(registry, db);
|
||||
register_task_tools(registry, db);
|
||||
@@ -881,8 +877,8 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
}
|
||||
|
||||
/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
|
||||
/// 知识图谱 Phase 1 AI 工具注册(6 个:task_link CRUD + 父子树 + 跨池移动 + content 更新)
|
||||
/// —— 持 db:Arc<Database>,对标设计 §五 Phase1 AI 工具表 + §2.1/§2.2。
|
||||
/// 知识图谱 Phase 1+2 AI 工具注册(7 个:task_link CRUD + 父子树 + 跨池移动 + content 更新
|
||||
/// + 项目事件流查询) —— 持 db:Arc<Database>,对标设计 §五 Phase1/Phase2 AI 工具表 + §2.1/§2.2/§2.4。
|
||||
///
|
||||
/// 工具直接调 df_storage::crud Repo(与 register_task_tools 同源模式,经 db Arc 重建 Repo),
|
||||
/// 复用 IPC 层(commands::task)的底层 Repo 方法,不重复实现业务校验:
|
||||
@@ -1094,6 +1090,186 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── get_project_timeline (Low 只读,设计 §五 + §2.4) ──
|
||||
// 查询项目事件流(project_events 统一事件流)。AI 精准检索基础:
|
||||
// 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。
|
||||
// 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。
|
||||
// source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。
|
||||
registry.register(
|
||||
"get_project_timeline", "查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
("event_type", "string", false),
|
||||
("limit", "integer", false),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let event_filter = args.get("event_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
// limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。
|
||||
let limit = args.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32)
|
||||
.unwrap_or(50);
|
||||
|
||||
let repo = df_storage::crud::ProjectEventRepo::new(&db);
|
||||
let mut events = repo.get_by_project(&project_id, limit).await?;
|
||||
// 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。
|
||||
if let Some(et) = &event_filter {
|
||||
events.retain(|e| e.event_type == *et);
|
||||
}
|
||||
let total = events.len();
|
||||
Ok(serde_json::json!({
|
||||
"items": events,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── add_project_service (Medium,设计 §2.3 + §五 + D10) ──
|
||||
// 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文:
|
||||
// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
|
||||
// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC
|
||||
// add_project_service 同源)。
|
||||
//
|
||||
// ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。
|
||||
// Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。
|
||||
registry.register(
|
||||
"add_project_service", "为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
("name", "string", true),
|
||||
("service_type", "string", true),
|
||||
("environment", "string", false),
|
||||
("endpoint", "string", false),
|
||||
("config_json", "string", false),
|
||||
("remark", "string", false),
|
||||
]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
let name = args["name"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
let service_type = args["service_type"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 service_type"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("name 不能为空");
|
||||
}
|
||||
if service_type.is_empty() {
|
||||
anyhow::bail!("service_type 不能为空");
|
||||
}
|
||||
let endpoint = args.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let config_json = args.get("config_json")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let environment = args.get("environment")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("development")
|
||||
.to_string();
|
||||
let remark = args.get("remark")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let record = ProjectServiceRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.clone(),
|
||||
name,
|
||||
service_type,
|
||||
endpoint,
|
||||
config_json,
|
||||
environment,
|
||||
remark,
|
||||
created_at: now_millis(),
|
||||
updated_at: now_millis(),
|
||||
};
|
||||
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
|
||||
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
|
||||
let id = repo.insert_validated(record).await?;
|
||||
let inserted = repo.get_by_id(&id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
|
||||
Ok(serde_json::to_value(&inserted)?)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── list_project_services (Low 只读,设计 §2.3 + §五) ──
|
||||
// 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文:
|
||||
// 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。
|
||||
registry.register(
|
||||
"list_project_services", "查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
("environment", "string", false),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let env_filter = args.get("environment")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
|
||||
let items = if let Some(env) = &env_filter {
|
||||
repo.list_by_project_env(&project_id, env).await?
|
||||
} else {
|
||||
repo.list_by_project(&project_id).await?
|
||||
};
|
||||
let total = items.len();
|
||||
Ok(serde_json::json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
})
|
||||
})},
|
||||
);
|
||||
}
|
||||
|
||||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||||
@@ -1224,6 +1400,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
fn register_file_tools(
|
||||
registry: &mut AiToolRegistry,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
data_dir: PathBuf,
|
||||
) {
|
||||
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
|
||||
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
|
||||
@@ -1779,8 +1956,9 @@ fn register_file_tools(
|
||||
("permanent", "boolean", false),
|
||||
]),
|
||||
RiskLevel::High,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
{ let allowed_dirs = allowed_dirs.clone(); let data_dir = data_dir.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
Box::pin(async move {
|
||||
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
@@ -1816,15 +1994,15 @@ fn register_file_tools(
|
||||
}))
|
||||
} else {
|
||||
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
|
||||
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
|
||||
let trash_dir = workspace_root().join(".trash");
|
||||
// .trash 放 app_data_dir 下,不依赖编译期 workspace_root
|
||||
let trash_dir = data_dir.join(".trash");
|
||||
tokio::fs::create_dir_all(&trash_dir).await
|
||||
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
|
||||
let backup_name = format!("{}-{}", new_id(), file_name);
|
||||
let backup_path = trash_dir.join(&backup_name);
|
||||
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||||
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
|
||||
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子,
|
||||
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
|
||||
// 失败回滚删 backup 保源完整)。
|
||||
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
|
||||
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
|
||||
@@ -2137,16 +2315,14 @@ fn register_file_tools(
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
||||
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
|
||||
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
|
||||
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
|
||||
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
|
||||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => {
|
||||
validate_path(d)?;
|
||||
d.to_string()
|
||||
}
|
||||
None => workspace_root().to_string_lossy().to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
||||
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
||||
@@ -2772,11 +2948,11 @@ mod tests {
|
||||
// 工具注册基线测试(SMELL-P0-2 拆分防护)
|
||||
//
|
||||
// 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
|
||||
// build_ai_tool_registry 经两层 register_* 组装:data(24 持 db) + file(13 不持 db) + http(1) = 38。
|
||||
// build_ai_tool_registry 经两层 register_* 组装:data(25 持 db) + file(13 不持 db) + http(1) = 39。
|
||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||
// ============================================================
|
||||
|
||||
/// build_ai_tool_registry 应注册恰好 38 个工具(24 data + 13 file + 1 http),且工具名集合稳定。
|
||||
/// build_ai_tool_registry 应注册恰好 41 个工具(27 data + 13 file + 1 http),且工具名集合稳定。
|
||||
///
|
||||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||
@@ -2789,24 +2965,28 @@ mod tests {
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
|
||||
// 总量基线:38(24 data + 13 file + 1 http)。拆分前后必须一致。
|
||||
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
|
||||
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
||||
// AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
|
||||
// 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具:
|
||||
// create_task_link/remove_task_link/list_task_links/get_task_tree/move_task_queue/update_content)。
|
||||
// 知识图谱 Phase 2(2026-06-26): data 层 24→25(新增 get_project_timeline 项目事件流查询,
|
||||
// register_task_graph_tools 6→7)。
|
||||
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
|
||||
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
38,
|
||||
"工具总数应为 38(24 data + 13 file + 1 http),实际 {}", registry.len()
|
||||
41,
|
||||
"工具总数应为 41(27 data + 13 file + 1 http),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
// data 层 24 个(持 db):CRUD/状态机/工作流/知识图谱任务关联
|
||||
// data 层 27 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置
|
||||
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
|
||||
// http 层 1 个(不持 db):http_request
|
||||
let mut expected: Vec<&str> = vec![
|
||||
// ── data 层 (24) ──
|
||||
// ── data 层 (27) ──
|
||||
"list_projects", "list_tasks", "list_ideas",
|
||||
"update_project", "create_project", "bind_directory",
|
||||
"create_task", "update_task", "advance_task",
|
||||
@@ -2816,6 +2996,10 @@ mod tests {
|
||||
// 知识图谱 Phase 1 任务关联工具(register_task_graph_tools 6 个)
|
||||
"create_task_link", "remove_task_link", "list_task_links",
|
||||
"get_task_tree", "move_task_queue", "update_content",
|
||||
// 知识图谱 Phase 2 项目事件流(register_task_graph_tools 7 个,本工具为第 7)
|
||||
"get_project_timeline",
|
||||
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
||||
"add_project_service", "list_project_services",
|
||||
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
|
||||
@@ -3507,4 +3691,132 @@ mod tests {
|
||||
// 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
|
||||
assert!(regex::Regex::new("").is_ok(), "regex 接受空串,handler 层显式 bail 空 pattern");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识图谱 Phase 3(2026-06-27):add_project_service / list_project_services
|
||||
// AI 工具 handler 端到端串联测试(经 registry.execute 验证参数装配 → Repo 落库 → 回读)。
|
||||
// service_type 白名单 + D10 凭证审查下沉 Repo 层(已在 project_service_repo.rs 单元测试覆盖),
|
||||
// 此处锁定 AI handler 的参数装配 / 调用路径正确(防 handler 拼参错位 / 漏调 insert_validated)。
|
||||
// ============================================================
|
||||
|
||||
/// 造占位项目(project_services.project_id FK 要求 projects 存在)+ registry fixture。
|
||||
async fn setup_services_registry() -> (Arc<Database>, AiToolRegistry) {
|
||||
let db = Database::open_in_memory().await.expect("in-memory db");
|
||||
let db = Arc::new(db);
|
||||
// 占位 project 满足 FK
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
repo.insert(ProjectRecord {
|
||||
id: "proj-svc-1".to_string(),
|
||||
name: "proj-svc-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "planning".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: now_millis(),
|
||||
updated_at: now_millis(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
(db, registry)
|
||||
}
|
||||
|
||||
/// add_project_service 正常路径:合法 service_type + 无凭证 → 落库 + 回读完整记录。
|
||||
#[tokio::test]
|
||||
async fn test_add_project_service_tool_inserts() {
|
||||
let (_db, registry) = setup_services_registry().await;
|
||||
let args = serde_json::json!({
|
||||
"project_id": "proj-svc-1",
|
||||
"name": "主库",
|
||||
"service_type": "mysql",
|
||||
"environment": "production",
|
||||
"endpoint": "localhost:3306",
|
||||
"config_json": "{\"pool_size\":10}",
|
||||
"remark": "主库"
|
||||
});
|
||||
let res = registry
|
||||
.execute("add_project_service", args)
|
||||
.await
|
||||
.expect("add_project_service 执行失败");
|
||||
assert_eq!(res["project_id"], "proj-svc-1");
|
||||
assert_eq!(res["name"], "主库");
|
||||
assert_eq!(res["service_type"], "mysql");
|
||||
assert_eq!(res["environment"], "production");
|
||||
assert_eq!(res["endpoint"], "localhost:3306");
|
||||
assert!(res["id"].as_str().is_some(), "应返回生成的 id");
|
||||
}
|
||||
|
||||
/// add_project_service D10 安全边界:config_json 含 password → handler 返 Err(不落库)。
|
||||
#[tokio::test]
|
||||
async fn test_add_project_service_tool_rejects_credential() {
|
||||
let (db, registry) = setup_services_registry().await;
|
||||
let args = serde_json::json!({
|
||||
"project_id": "proj-svc-1",
|
||||
"name": "主库",
|
||||
"service_type": "mysql",
|
||||
"config_json": "{\"password\":\"secret123\"}"
|
||||
});
|
||||
let err = registry.execute("add_project_service", args).await;
|
||||
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
|
||||
// 未落库
|
||||
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
|
||||
let all = repo.list_by_project("proj-svc-1").await.unwrap();
|
||||
assert!(all.is_empty(), "拒绝后不应有记录");
|
||||
}
|
||||
|
||||
/// list_project_services 查询:插 2 条(不同环境)→ 不带 environment 返回全部,
|
||||
/// 带 environment 仅返回匹配。
|
||||
#[tokio::test]
|
||||
async fn test_list_project_services_tool_filters_environment() {
|
||||
let (_db, registry) = setup_services_registry().await;
|
||||
// 插 development + production 各 1 条
|
||||
for (st, env) in [("mysql", "development"), ("redis", "production")] {
|
||||
registry
|
||||
.execute(
|
||||
"add_project_service",
|
||||
serde_json::json!({
|
||||
"project_id": "proj-svc-1",
|
||||
"name": format!("svc-{env}"),
|
||||
"service_type": st,
|
||||
"environment": env,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// 不带 environment:返回全部 2 条
|
||||
let all = registry
|
||||
.execute(
|
||||
"list_project_services",
|
||||
serde_json::json!({ "project_id": "proj-svc-1" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all["total"], 2);
|
||||
assert_eq!(all["items"].as_array().unwrap().len(), 2);
|
||||
|
||||
// 仅 development:1 条
|
||||
let dev = registry
|
||||
.execute(
|
||||
"list_project_services",
|
||||
serde_json::json!({ "project_id": "proj-svc-1", "environment": "development" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dev["total"], 1);
|
||||
assert_eq!(dev["items"][0]["environment"], "development");
|
||||
assert_eq!(dev["items"][0]["service_type"], "mysql");
|
||||
|
||||
// 不存在的环境:0 条
|
||||
let staging = registry
|
||||
.execute(
|
||||
"list_project_services",
|
||||
serde_json::json!({ "project_id": "proj-svc-1", "environment": "staging" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(staging["total"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -78,7 +78,7 @@ pub async fn create_idea(
|
||||
// 防 LLM/前端传 99 等被静默吞为 Critical。
|
||||
if !(0..=3).contains(&input.priority) {
|
||||
return Err(format!(
|
||||
"priority 必须在 0..=3 (Low=0/Medium=1/High=2/Critical=3),收到 {}",
|
||||
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low,对齐前端约定 api/types.ts:141),收到 {}",
|
||||
input.priority
|
||||
));
|
||||
}
|
||||
@@ -104,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)
|
||||
}
|
||||
|
||||
@@ -134,6 +140,21 @@ pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool
|
||||
state.ideas.restore(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
|
||||
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
|
||||
#[tauri::command]
|
||||
pub async fn relate_ideas(
|
||||
state: State<'_, AppState>,
|
||||
subject_id: String,
|
||||
target_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.ideas
|
||||
.sync_related_ids(&subject_id, &target_ids)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
|
||||
#[tauri::command]
|
||||
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
|
||||
@@ -193,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,
|
||||
@@ -425,13 +471,18 @@ fn record_to_idea(record: &IdeaRecord) -> Idea {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -5,12 +5,56 @@ use tauri::State;
|
||||
|
||||
use df_types::types::{new_id, TaskStatus};
|
||||
use df_storage::crud::TaskQuery;
|
||||
use df_storage::models::{TaskLinkRecord, 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 {
|
||||
@@ -262,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)
|
||||
}
|
||||
|
||||
@@ -372,6 +427,15 @@ pub async fn advance_task(
|
||||
id: String,
|
||||
target_status: String,
|
||||
) -> Result<TaskRecord, String> {
|
||||
// 知识图谱 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,
|
||||
@@ -394,6 +458,19 @@ pub async fn advance_task(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 知识图谱 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)
|
||||
}
|
||||
|
||||
@@ -639,6 +716,21 @@ pub async fn move_task_queue(
|
||||
.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
|
||||
|
||||
+11
-2
@@ -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),
|
||||
@@ -261,10 +261,18 @@ pub fn run() {
|
||||
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,
|
||||
@@ -345,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");
|
||||
|
||||
+63
-17
@@ -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, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
|
||||
ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
||||
};
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
@@ -339,6 +339,14 @@ pub struct AppState {
|
||||
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,
|
||||
@@ -385,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 并发控制 ──
|
||||
@@ -460,6 +470,12 @@ impl AllowedDirs {
|
||||
Self { persistent: HashSet::new(), session: HashSet::new(), once: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 取首个 persistent 授权目录,作为相对路径锚定基准。
|
||||
/// 无任何持久授权时返回 None,调用方应引导用户绑定项目。
|
||||
pub fn first_persistent_dir(&self) -> Option<&PathBuf> {
|
||||
self.persistent.iter().next()
|
||||
}
|
||||
|
||||
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **方案①弱化后 workspace_root 不再默认授权**(default_with_root 空,reload_allowed_dirs
|
||||
@@ -569,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
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
@@ -589,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 }
|
||||
}
|
||||
|
||||
@@ -630,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()
|
||||
@@ -643,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 同侧。
|
||||
@@ -665,6 +700,8 @@ impl AppState {
|
||||
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),
|
||||
@@ -680,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(
|
||||
@@ -712,6 +750,19 @@ impl AppState {
|
||||
state.reload_allowed_dirs().await;
|
||||
// P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。
|
||||
state.reload_knowledge_config().await;
|
||||
|
||||
// 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
|
||||
let old_trash = workspace_root_path().join(".trash");
|
||||
let new_trash = data_dir.join(".trash");
|
||||
if old_trash.exists() && !new_trash.exists() {
|
||||
if let Err(e) = std::fs::rename(&old_trash, &new_trash) {
|
||||
tracing::warn!(
|
||||
"迁移 .trash 失败(从 {:?} 到 {:?}): {} (原目录保留,新目录将自动创建)",
|
||||
old_trash, new_trash, e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -843,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();
|
||||
@@ -1326,4 +1373,3 @@ mod tests {
|
||||
assert_eq!(some_count2, n, "Drop 全部后应可再 acquire {n} 个");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ export const ideaApi = {
|
||||
return invoke('promote_idea', { id })
|
||||
},
|
||||
|
||||
/** 双向同步关联关系:原子地设置主体灵感的目标关联列表,自动添加/移除反向关联。 */
|
||||
relateIdeas(subjectId: string, targetIds: string[]): Promise<void> {
|
||||
return invoke('relate_ideas', { subjectId, targetIds })
|
||||
},
|
||||
|
||||
/** 列出灵感评估历史(version DESC)。Tauri 默认把 ideaId 映射到 idea_id。 */
|
||||
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
|
||||
return invoke('list_idea_evaluations', { ideaId })
|
||||
|
||||
@@ -16,6 +16,11 @@ export const settingsApi = {
|
||||
return invoke<boolean>('settings_set', { key, value })
|
||||
},
|
||||
|
||||
/** 获取 DevFlow 数据目录路径(运行期确定,跨平台) */
|
||||
getDataDir(): Promise<string> {
|
||||
return invoke<string>('get_data_dir')
|
||||
},
|
||||
|
||||
/** 取全部 key/value(启动时一次性拉回恢复偏好) */
|
||||
getAll(): Promise<Record<string, string>> {
|
||||
return invoke<Record<string, string>>('settings_get_all')
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
</button>
|
||||
</div>
|
||||
<!-- 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 v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-match-head">
|
||||
<span class="ai-tool-grep-line">:{{ m.line }}</span>
|
||||
<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>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 默认模式:全文预览 -->
|
||||
@@ -96,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>
|
||||
@@ -350,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -170,3 +170,96 @@ export function setConvState(convId: string | null | undefined, s: ConvState): v
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId, getConvState } from './aiShared'
|
||||
import { nextMsgId, getConvState, clearConvStreamState } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -241,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')
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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: '低风险(默认)',
|
||||
|
||||
+103
-15
@@ -51,7 +51,8 @@ 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 反向环)。
|
||||
import { getConvState } from '@/composables/ai/aiShared'
|
||||
// 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'
|
||||
@@ -60,22 +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 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: [] as AiProviderConfig[],
|
||||
activeProvider: null as string | null,
|
||||
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 兜底:
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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