Compare commits

..
2 Commits
Author SHA1 Message Date
lxy 9142e30714 新增: 项目规则文件投影进系统提示词
- 会话启动读 devflow.md 纪律与领单两分区,CLAUDE.md 同目录自动并入
- system prompt 稳定尾纯追加,主会话与子代理双路径同源
- 分区字符上限裁剪,会话级缓存同会话只读一次盘
- 文件缺失零回归,缓存前缀零击穿
2026-10-12 18:12:36 +08:00
lxy 9e26d6b19c 优化: 脉络卡归属置顶与源会话跳转
- 当前会话归属项目组置顶,focused 标记高亮
- source_ref 解析出可跳转会话 id,消息级引用反查补全
- 条目相对时间/空态引导/标题点击跳源,失败静默
2026-10-12 18:12:26 +08:00
12 changed files with 760 additions and 25 deletions
+1 -1
View File
@@ -664,7 +664,7 @@
- **推广路线**:devflow v1 验证 → 模板提炼进技能分发站 → u-claw 九实例各挂看板 → 多 bot 协作总线(N²→N+1) → 人-bot 协作(写板即派工)。
- **前置约束**:仅异步任务适用;hook 随模板强制安装;多实例并发领单=[→]原子改写+git push 冲突天然仲裁。
- [ ] 32 泛化落地:devflow v1 跑通后,模板三件套提炼进技能分发站(skill 形态),九实例试点 2-3 个(先 AI-Ops 自领+devflow,跑一周数据再扩)。
33. [ ] **devflow.md MVP(十四节多角色论证定稿)**:①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。
33. [x] **devflow.md MVP(十四节多角色论证定稿)** ✅已实施(2026-10-12:devflow_md.rs 投影模块+第七段稳定尾追加双路径(主会话 build_runtime_prompt+子代理 spawn_agent 同源)/分区裁剪(纪律4000+领单2000+CLAUDE 4000/总6000)/conv_id 键会话级缓存零缓存击穿/缺失零回归;881 测试绿含 9 新):①启动读取 devflow.md(规范纪律+领单两分区)+CLAUDE.md 并入 build_runtime_prompt 第七段,内容按相关性裁剪禁全量 ②警示区走 insert 消息范式 ③状态不文件化(DB 直注+H3 git 实测) ④知识投影补相关性元数据。砍除运行中文件刷新。v2+:agent 自主维护/知识投影动态化/跨会话投影。验收:启动双文件并入生效;警示事件下一轮可见;token 零缓存击穿。
### 看板完整性防护(2026-10-06 todo.md 截断事故:文件被重写为 12 行+脱离 git 跟踪,内容经 git HEAD+残版合并零丢失恢复)
- [ ] **看板防护三件**:①条目数骤降告警——看板条目数单次变化 >50% 时视为异常(防静默截断),提交/重写前自检 ②看板修改一律原子写(tmp+rename)+保留前版快照(todo-快照-*.md 滚动一份) ③git add 覆盖检查——提交流程确认看板在跟踪列表(防选择性 add 遗漏致脱离跟踪)。根因:agent 提交/整理流程对 todo.md 组合失误(重写+跟踪移除),细节在其 transcript 不可见——机制防护优先于行为归因。
+9 -1
View File
@@ -969,7 +969,7 @@ pub(crate) async fn run_agentic_loop(
// (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock,
// 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。
let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt(
&session_arc, &conv_id, system_prompt, "",
&session_arc, &conv_id, system_prompt, "", h3_watch_dir.as_deref(),
).await;
// ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ──
@@ -3238,11 +3238,13 @@ fn completed_event(
// 做 snapshot,消除原 L1157 第二次 lock 读。
//
// 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。
// 注:`work_dir` 供项目规则文件投影定位工作区(与被监视工作区同一目录),None=无授权目录。
async fn build_runtime_prompt(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
base_prompt: String,
_lang: &str,
work_dir: Option<&str>,
) -> (String, Vec<super::GoalEntry>) {
// 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。
// conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。
@@ -3368,6 +3370,12 @@ async fn build_runtime_prompt(
);
}
// 项目规则文件投影(devflow.md/CLAUDE.md):稳定尾段纯追加 —— 拼在全部既有段之后、
// 返回之前。内容经 devflow_md 模块按会话缓存(同会话只读一次盘),loop 内为不变量,
// 不击穿 provider prompt 缓存前缀;工作目录无规则文件时原样返回(缺失不影响既有组装)。
let system_prompt =
crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await;
(system_prompt, goals)
}
+225 -5
View File
@@ -6,6 +6,8 @@
use tauri::State;
use crate::commands::ai::knowledge_inject::resolve_source_project;
/// 单条脉络摘要(字段前端契约,snake_case)
#[derive(serde::Serialize, Clone, Debug)]
pub struct ContextBrief {
@@ -14,12 +16,19 @@ pub struct ContextBrief {
pub source_ref: Option<String>,
pub source_project: Option<String>,
pub reuse_count: i32,
/// 知识条目最近更新时间,原样透供前端展示「这条什么时候定的」
pub updated_at: String,
/// 可跳转的来源会话 id(自 source_ref 解析,消息级引用经 ai_messages 反查);
/// 解析不出(无源/引用失效)为 None,前端降级纯文本标题
pub source_conv: Option<String>,
}
/// 项目脉络分组
#[derive(serde::Serialize, Clone, Debug)]
pub struct ProjectBriefs {
pub source_project: String,
/// 是否为当前会话归属项目(仅传 focus_conv 且解析命中时为 true,前端做置顶高亮)
pub focused: bool,
pub briefs: Vec<ContextBrief>,
}
@@ -35,12 +44,91 @@ fn truncate_chars(s: &str, max: usize) -> String {
out
}
/// source_ref 溯源引用的三种形态:对话级 `conv:{id}` 直接可跳;
/// 消息级 `conv_msg:{msg_id}` 须查 ai_messages 反查会话;其余(空/异形)不可跳。
enum SourceConvRef {
Conv(String),
Msg(String),
None,
}
fn parse_source_conv(source_ref: Option<&str>) -> SourceConvRef {
let Some(r) = source_ref else {
return SourceConvRef::None;
};
if let Some(id) = r.strip_prefix("conv:") {
if id.is_empty() {
return SourceConvRef::None;
}
return SourceConvRef::Conv(id.to_string());
}
if let Some(id) = r.strip_prefix("conv_msg:") {
if id.is_empty() {
return SourceConvRef::None;
}
return SourceConvRef::Msg(id.to_string());
}
SourceConvRef::None
}
/// 回填 source_conv:对话级引用直接取 id,消息级引用批量反查 ai_messages(一条 IN 查询,
/// 引用失效/表查不到保持 None,前端降级纯文本)。
async fn fill_source_convs(db: &df_storage::db::Database, groups: &mut [ProjectBriefs]) {
for g in groups.iter_mut() {
for b in g.briefs.iter_mut() {
if let SourceConvRef::Conv(id) = parse_source_conv(b.source_ref.as_deref()) {
b.source_conv = Some(id);
}
}
}
let msg_ids: Vec<String> = groups
.iter()
.flat_map(|g| g.briefs.iter())
.filter_map(|b| match parse_source_conv(b.source_ref.as_deref()) {
SourceConvRef::Msg(id) => Some(id),
_ => None,
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
if msg_ids.is_empty() {
return;
}
let conn = db.conn();
let guard = conn.lock().await;
let Ok(mut stmt) = guard.prepare("SELECT conversation_id FROM ai_messages WHERE id = ?1")
else {
return;
};
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for id in &msg_ids {
if let Ok(rows) = stmt.query_map([id], |row| row.get::<_, String>(0)) {
for conv_id in rows.flatten() {
found.insert(id.clone(), conv_id);
}
}
}
drop(stmt);
for g in groups.iter_mut() {
for b in g.briefs.iter_mut() {
if b.source_conv.is_some() {
continue;
}
if let SourceConvRef::Msg(id) = parse_source_conv(b.source_ref.as_deref()) {
b.source_conv = found.get(&id).cloned();
}
}
}
}
/// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识
/// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一
/// 客观信号),组间按组内最高复用数降序(活跃项目排前)。
/// 客观信号),组间按组内最高复用数降序(活跃项目排前);`focus_project` 命中的组置顶
/// (当前会话归属项目优先展示),未命中/不传时顺序与置顶逻辑不存在时完全一致。
fn aggregate_briefs(
records: Vec<df_storage::models::KnowledgeRecord>,
per_project: usize,
focus_project: Option<&str>,
) -> Vec<ProjectBriefs> {
use std::collections::HashMap;
let mut groups: HashMap<String, Vec<ContextBrief>> = HashMap::new();
@@ -54,6 +142,8 @@ fn aggregate_briefs(
source_ref: r.source_ref,
source_project: Some(proj.clone()),
reuse_count: r.reuse_count,
updated_at: r.updated_at,
source_conv: None,
};
groups.entry(proj).or_default().push(brief);
}
@@ -62,29 +152,45 @@ fn aggregate_briefs(
.map(|(source_project, mut briefs)| {
briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count));
briefs.truncate(per_project);
ProjectBriefs { source_project, briefs }
let focused = focus_project == Some(source_project.as_str());
ProjectBriefs { source_project, focused, briefs }
})
.collect();
out.sort_by(|a, b| {
let top = |g: &ProjectBriefs| g.briefs.first().map(|b| b.reuse_count).unwrap_or(0);
top(b).cmp(&top(a))
});
if let Some(focus) = focus_project {
if let Some(pos) = out.iter().position(|g| g.source_project == focus) {
let focused_group = out.remove(pos);
out.insert(0, focused_group);
}
}
out
}
/// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。
/// `focus_conv` 传当前会话 id 时解析其归属项目,命中组置顶并带 `focused` 标记;解析失败/
/// 无归属时与不传该参数行为完全一致(软排序只调顺序,不报错不缩面)。
/// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。
#[tauri::command]
pub async fn ai_context_briefs(
state: State<'_, crate::AppState>,
per_project: Option<usize>,
focus_conv: Option<String>,
) -> Result<Vec<ProjectBriefs>, String> {
let records = state
.knowledge
.list_by_status("published")
.await
.map_err(|e| e.to_string())?;
Ok(aggregate_briefs(records, per_project.unwrap_or(5)))
let focus_project = match focus_conv.as_deref() {
Some(conv_id) => resolve_source_project(&state.db, conv_id, &[]).await,
None => None,
};
let mut out = aggregate_briefs(records, per_project.unwrap_or(5), focus_project.as_deref());
fill_source_convs(&state.db, &mut out).await;
Ok(out)
}
#[cfg(test)]
@@ -119,10 +225,11 @@ mod tests {
rec("乙项目", "c3", Some("proj-b"), 3),
rec("无归属不入卡", "c4", None, 99),
];
let out = aggregate_briefs(recs, 5);
let out = aggregate_briefs(recs, 5, None);
assert_eq!(out.len(), 2, "两项目分组,无归属剔除: {out:?}");
assert_eq!(out[0].source_project, "proj-a", "组间按最高复用降序");
assert_eq!(out[0].briefs[0].title, "甲项目高复用", "组内按复用降序");
assert!(out.iter().all(|g| !g.focused), "无 focus 时任何组不带置顶标记");
assert!(out.iter().all(|g| g.briefs.iter().all(|b| b.source_project.is_some())));
}
@@ -131,8 +238,121 @@ mod tests {
let recs: Vec<_> = (0..8)
.map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i))
.collect();
let out = aggregate_briefs(recs, 3);
let out = aggregate_briefs(recs, 3, None);
assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project");
assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1);
}
#[test]
fn focus_project_group_moves_to_front() {
let recs = vec![
rec("乙高复用", "c1", Some("proj-b"), 9),
rec("甲低复用", "c2", Some("proj-a"), 1),
];
let out = aggregate_briefs(recs, 5, Some("proj-a"));
assert_eq!(out[0].source_project, "proj-a", "会话归属项目组置顶(复用更低也排前): {out:?}");
assert!(out[0].focused, "命中组带 focused 标记");
assert!(!out[1].focused, "非命中组保持 focused=false");
}
fn group_order(groups: &[ProjectBriefs]) -> Vec<&str> {
groups.iter().map(|g| g.source_project.as_str()).collect()
}
#[test]
fn focus_unmatched_project_keeps_order() {
let recs = vec![
rec("乙高复用", "c1", Some("proj-b"), 9),
rec("甲低复用", "c2", Some("proj-a"), 1),
];
let plain = aggregate_briefs(recs.clone(), 5, None);
let unmatched = aggregate_briefs(recs, 5, Some("proj-x"));
assert_eq!(group_order(&plain), group_order(&unmatched), "解析无归属/未知项目时顺序不变");
assert!(unmatched.iter().all(|g| !g.focused), "无命中组不误标记");
}
#[test]
fn brief_carries_updated_at() {
let mut r = rec("条目", "c", Some("proj"), 1);
r.updated_at = "1777777777".into();
let out = aggregate_briefs(vec![r], 5, None);
assert_eq!(out[0].briefs[0].updated_at, "1777777777", "updated_at 原样透传");
}
#[test]
fn source_ref_parses_to_conv_msg_or_none() {
assert!(matches!(
parse_source_conv(Some("conv:abc-1")),
SourceConvRef::Conv(id) if id == "abc-1"
));
assert!(matches!(
parse_source_conv(Some("conv_msg:msg_123_4")),
SourceConvRef::Msg(id) if id == "msg_123_4"
));
assert!(matches!(parse_source_conv(Some("conv:")), SourceConvRef::None));
assert!(matches!(parse_source_conv(Some("weird:x")), SourceConvRef::None));
assert!(matches!(parse_source_conv(None), SourceConvRef::None));
}
#[tokio::test]
async fn fill_source_convs_resolves_both_ref_kinds() {
let db = df_storage::db::Database::open_in_memory()
.await
.expect("内存库");
{
let conn = db.conn();
let guard = conn.lock().await;
guard
.execute(
"CREATE TABLE IF NOT EXISTS ai_messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL)",
[],
)
.expect("建表");
guard
.execute(
"INSERT INTO ai_messages (id, conversation_id, seq, role, created_at) VALUES \
('msg_1_0', 'conv-a', 1, 'assistant', '0'), ('msg_2_0', 'conv-b', 2, 'assistant', '0')",
[],
)
.expect("造数");
}
let mut groups = vec![ProjectBriefs {
source_project: "proj".into(),
focused: false,
briefs: vec![
ContextBrief {
title: "对话级".into(),
summary: "s".into(),
source_ref: Some("conv:conv-a".into()),
source_project: Some("proj".into()),
reuse_count: 2,
updated_at: "1".into(),
source_conv: None,
},
ContextBrief {
title: "消息级".into(),
summary: "s".into(),
source_ref: Some("conv_msg:msg_2_0".into()),
source_project: Some("proj".into()),
reuse_count: 1,
updated_at: "1".into(),
source_conv: None,
},
ContextBrief {
title: "失效引用".into(),
summary: "s".into(),
source_ref: Some("conv_msg:msg_gone".into()),
source_project: Some("proj".into()),
reuse_count: 0,
updated_at: "1".into(),
source_conv: None,
},
],
}];
fill_source_convs(&db, &mut groups).await;
let briefs = &groups[0].briefs;
assert_eq!(briefs[0].source_conv.as_deref(), Some("conv-a"), "对话级直接取 id");
assert_eq!(briefs[1].source_conv.as_deref(), Some("conv-b"), "消息级反查回填");
assert_eq!(briefs[2].source_conv, None, "失效引用降级 None");
}
}
+420
View File
@@ -0,0 +1,420 @@
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。
//!
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
//!
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本,
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。
//!
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块
//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。
//!
//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。
//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::state::AllowedDirs;
// ── 裁剪上限(字符数,中文一字一符) ──
//
// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、
// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。
/// 规范纪律分区上限
const DISCIPLINE_MAX_CHARS: usize = 4000;
/// 领单分区上限
const CLAIM_MAX_CHARS: usize = 2000;
/// CLAUDE.md 并入段上限
const CLAUDE_MD_MAX_CHARS: usize = 4000;
/// 投影段总上限(各分区叠加后的二次硬闸)
const SECTION_MAX_CHARS: usize = 6000;
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
const SECTION_CACHE_MAX: usize = 64;
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
const CLAIM_HEADING_KEY: &str = "领单";
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn section_cache() -> &'static Mutex<HashMap<String, String>> {
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
///
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
/// HashSet 迭代序不定,必须排序后取首才是稳定值。
pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
let mut dirs: Vec<PathBuf> = allowed.persistent.iter().cloned().collect();
dirs.sort();
dirs.first().map(|p| p.to_string_lossy().to_string())
}
/// 把投影段纯追加到 system prompt 尾部(两条组装路径的统一入口)。
///
/// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续,
/// 调用方既有段内容不受任何影响(稳定尾段语义)。
pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String {
match projection_section(work_dir, conv_id).await {
Some(seg) => format!("{}\n\n{}", prompt, seg),
None => prompt,
}
}
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。
///
/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<String> {
{
let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if let Some(hit) = cache.get(conv_id) {
return (!hit.is_empty()).then(|| hit.clone());
}
}
let dir = work_dir.map(PathBuf::from);
// 文件读放阻塞线程池(约定与 system prompt 指纹查询一致),失败按缺失处理
let (devflow, claude) = tokio::task::spawn_blocking(move || read_pair(dir))
.await
.unwrap_or_else(|_| (None, None));
let (discipline, claiming) = devflow
.as_deref()
.map(extract_partitions)
.unwrap_or((String::new(), String::new()));
let section = build_section(&discipline, &claiming, claude.as_deref());
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SECTION_CACHE_MAX {
cache.clear();
}
cache.insert(conv_id.to_string(), section.clone().unwrap_or_default());
section
}
/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。
fn read_pair(dir: Option<PathBuf>) -> (Option<String>, Option<String>) {
let Some(dir) = dir else {
return (None, None);
};
(read_trimmed(&dir.join("devflow.md")), read_trimmed(&dir.join("CLAUDE.md")))
}
fn read_trimmed(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
/// 三路输入全空 → None(调用方零追加)。
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
if discipline.is_empty() && claiming.is_empty() && claude.is_none() {
return None;
}
let has_devflow = !discipline.is_empty() || !claiming.is_empty();
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
// 优先级只在双文件并存时注明,单文件无冲突可注明
if has_devflow && claude.is_some() {
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
}
sec.push(':');
if !discipline.is_empty() {
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS));
}
if !claiming.is_empty() {
sec.push_str("\n\n### 领单纪律(devflow.md)\n");
sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS));
}
if let Some(cla) = claude {
sec.push_str("\n\n### 项目规范(CLAUDE.md)\n");
sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS));
}
// 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次
if sec.chars().count() > SECTION_MAX_CHARS {
let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect();
return Some(format!("{}\n…(投影段超出总上限已截断)", cut));
}
Some(sec)
}
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
fn cap_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max).collect();
format!("{}\n…(超上限已截断)", cut)
}
// ── devflow.md 分区解析 ──
//
// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的
// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类
// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按
// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
#[derive(Clone, Copy, PartialEq)]
enum Partition {
Discipline,
Claim,
}
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
fn heading_of(line: &str) -> Option<(usize, &str)> {
let t = line.trim_start();
let level = t.len() - t.trim_start_matches('#').len();
if level == 0 || level > 6 {
return None;
}
let text = t[level..].trim();
if text.is_empty() {
return None;
}
Some((level, text))
}
fn classify_heading(text: &str) -> Option<Partition> {
if text.contains(CLAIM_HEADING_KEY) {
return Some(Partition::Claim);
}
if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) {
return Some(Partition::Discipline);
}
None
}
/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。
fn extract_partitions(content: &str) -> (String, String) {
let mut discipline: Vec<String> = Vec::new();
let mut claiming: Vec<String> = Vec::new();
// 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题)
let mut cur: Option<(Partition, usize, String)> = None;
for line in content.lines() {
let heading = heading_of(line);
// 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块
let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv);
if boundary {
let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在");
push_block(kind, body, &mut discipline, &mut claiming);
} else if let Some((_, _, body)) = cur.as_mut() {
// 更深层级标题属块内子结构,连行保留
body.push_str(line);
body.push('\n');
continue;
}
if let Some((lv, text)) = heading {
if let Some(kind) = classify_heading(text) {
cur = Some((kind, lv, String::new()));
}
}
}
if let Some((kind, _, body)) = cur.take() {
push_block(kind, body, &mut discipline, &mut claiming);
}
let dev = discipline.join("\n").trim().to_string();
let claim = claiming.join("\n").trim().to_string();
if dev.is_empty() && claim.is_empty() {
return (content.trim().to_string(), String::new());
}
(dev, claim)
}
fn push_block(kind: Partition, body: String, discipline: &mut Vec<String>, claiming: &mut Vec<String>) {
let trimmed = body.trim();
if trimmed.is_empty() {
return;
}
match kind {
Partition::Discipline => discipline.push(trimmed.to_string()),
Partition::Claim => claiming.push(trimmed.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// 测试临时目录计数器:进程内唯一,防并发测试互踩 + 防缓存键串扰
static TMP_SEQ: AtomicUsize = AtomicUsize::new(0);
fn unique_tag() -> String {
format!("{}-{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst))
}
fn temp_project_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("devflow-md-test-{}", unique_tag()));
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
fn write_file(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).expect("写测试文件");
}
// ── 零回归:无两文件 → prompt 逐字不变 ──
#[tokio::test]
async fn missing_files_leave_prompt_unchanged() {
let base = "BASE PROMPT 段落".to_string();
let dir = temp_project_dir();
let out = append_projection(base.clone(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert_eq!(out, base, "两文件缺失时 prompt 必须逐字不变");
let out_no_dir = append_projection(base.clone(), None, &format!("conv-{}", unique_tag())).await;
assert_eq!(out_no_dir, base, "无授权目录时 prompt 必须逐字不变");
std::fs::remove_dir_all(&dir).ok();
}
// ── 只有 CLAUDE.md → 并入生效 ──
#[tokio::test]
async fn claude_only_merged() {
let dir = temp_project_dir();
write_file(&dir, "CLAUDE.md", "# 项目约定\n- 提交信息用中文");
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert!(out.starts_with("BASE"), "既有段必须是前缀");
assert!(out.contains("提交信息用中文"), "CLAUDE.md 正文须并入");
assert!(out.contains("### 项目规范(CLAUDE.md)"), "并入段须带 CLAUDE.md 标注");
assert!(!out.contains("devflow.md"), "仅 CLAUDE.md 时不得出现 devflow.md 标注");
std::fs::remove_dir_all(&dir).ok();
}
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ──
#[tokio::test]
async fn both_files_devflow_priority() {
let dir = temp_project_dir();
write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]");
write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目");
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入");
let claim_pos = out.find("领单后改").expect("领单分区须注入");
let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入");
assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)");
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
assert!(out.contains("### 规范纪律(devflow.md)"));
assert!(out.contains("### 领单纪律(devflow.md)"));
assert!(out.contains("### 项目规范(CLAUDE.md)"));
std::fs::remove_dir_all(&dir).ok();
}
// ── 超长裁剪:分区上限 + 总上限生效并注明 ──
#[test]
fn caps_truncate_with_marker() {
let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100);
let section = build_section(&long, "", None).expect("非空输入必有段");
let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or("");
assert!(discipline_part.contains("超上限已截断"), "分区超限须注明");
let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap();
assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限");
// 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸
let discipline = "a".repeat(DISCIPLINE_MAX_CHARS);
let claiming = "b".repeat(CLAIM_MAX_CHARS);
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段");
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
}
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
#[tokio::test]
async fn stable_tail_append() {
let dir = temp_project_dir();
write_file(&dir, "CLAUDE.md", "- 规则条目");
let base = "## 行为准则\n- 既有段内容\n";
let out = append_projection(base.to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
assert!(out.starts_with(base), "既有段必须逐字保留为前缀");
let tail = out.strip_prefix(base).unwrap();
assert!(tail.starts_with("\n\n## 项目认知投影"), "投影段须紧跟既有段之后成稳定尾段");
std::fs::remove_dir_all(&dir).ok();
}
// ── 会话缓存:同会话只读一次盘,文件中途改写不影响本会话;新会话读到新内容 ──
#[tokio::test]
async fn cached_per_conversation() {
let dir = temp_project_dir();
let tag = unique_tag();
write_file(&dir, "CLAUDE.md", "- 第一版规则");
let conv = format!("conv-{}", tag);
let first = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
write_file(&dir, "CLAUDE.md", "- 第二版规则");
let again = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &conv).await;
assert_eq!(first, again, "同会话第二次组装必须复用缓存(文件改写不影响)");
assert!(first.contains("第一版规则"));
let fresh = append_projection("B".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}-new", tag)).await;
assert!(fresh.contains("第二版规则"), "新会话须重读盘拿到最新内容");
std::fs::remove_dir_all(&dir).ok();
}
// ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ──
#[test]
fn partition_extraction() {
let content = "\
# 项目认知
前言段落应被裁掉。
## 规范纪律
- 纪律条目一
### 提交规范
- 子标题下的纪律条目
## 看板摘要
- 未命中关键字的块应被裁掉
## 领单纪律
- 领单条目一
";
let (dev, claim) = extract_partitions(content);
assert!(dev.contains("纪律条目一"), "规范纪律块须命中");
assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留");
assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉");
assert_eq!(claim, "- 领单条目一", "领单分区须独立提取");
// 扁平文件(无命中标题)回退:整文件按规范纪律
let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
assert_eq!(dev, "- 扁平规则一\n- 扁平规则二");
assert!(claim.is_empty());
// 深层标题起始的块:同级才截断
let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断");
assert!(!dev.contains("其他块内容"));
}
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
#[test]
fn work_dir_sorted_first() {
let mut allowed = AllowedDirs::default();
allowed.persistent.insert(PathBuf::from("E:/wk-b/b-project"));
allowed.persistent.insert(PathBuf::from("E:/wk-a/a-project"));
assert_eq!(work_dir_of(&allowed).as_deref(), Some("E:/wk-a/a-project"));
let empty = AllowedDirs::default();
assert_eq!(work_dir_of(&empty), None, "无授权目录返 None");
}
// ── 构建纯函数:全空 None / 单区缺省不产生空小节 ──
#[test]
fn build_section_edge_cases() {
assert!(build_section("", "", None).is_none(), "三路全空须 None");
assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失");
let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段");
assert!(only_claim.contains("### 领单纪律(devflow.md)"));
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
}
}
+2
View File
@@ -36,6 +36,8 @@ pub mod command_stream;
pub mod commands;
pub mod compress;
pub mod conversation;
// 项目规则文件投影(devflow.md/CLAUDE.md):会话启动读取并入 system prompt 稳定尾段
pub(crate) mod devflow_md;
pub mod event_bus;
pub mod http;
pub mod fetch_url;
@@ -1410,6 +1410,7 @@ fn make_test_spawn_agent_ctx(db: &Arc<Database>) -> crate::commands::ai::tools::
crate::commands::ai::agentic::conv_state::ConvStateStore::new(),
),
llm_concurrency: LlmConcurrency::new(3, 2),
allowed_dirs: Arc::new(tokio::sync::RwLock::new(crate::state::AllowedDirs::default())),
db: db.clone(),
sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(),
}
+13 -1
View File
@@ -17,7 +17,7 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::{Mutex, RwLock};
use df_ai::ai_tools::{
AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta,
@@ -52,6 +52,9 @@ pub struct SpawnAgentCtx {
pub conv_states: Arc<ConvStateStore>,
/// LLM 并发控制(子循环 global + per_conv(child) 双份额)
pub llm_concurrency: LlmConcurrency,
/// 文件访问授权白名单(子代理投影段定位工作区:首个持久授权目录;
/// 与 AppState.allowed_dirs 共享同一 Arc)
pub allowed_dirs: Arc<RwLock<crate::state::AllowedDirs>>,
/// 数据库句柄(子循环 provider 解析)
pub db: Arc<Database>,
/// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc)
@@ -268,6 +271,15 @@ async fn spawn_agent_handler(
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang);
// 项目规则文件投影与主会话同源:父会话工作区的 devflow.md/CLAUDE.md 对子代理同样生效
// (以父会话为缓存键,复用父会话已读内容零重复读盘;无授权目录/无文件时原样返回)。
let work_dir = {
let snap = ctx.allowed_dirs.read().await;
crate::commands::ai::devflow_md::work_dir_of(&snap)
};
let system_prompt = crate::commands::ai::devflow_md::append_projection(
system_prompt, work_dir.as_deref(), &parent_conv_id,
).await;
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
+1
View File
@@ -362,6 +362,7 @@ impl AppState {
session: ai_session.clone(),
conv_states: conv_states.clone(),
llm_concurrency: llm_concurrency.clone(),
allowed_dirs: allowed_dirs.clone(),
db: db.clone(),
sub_agents: sub_agent_registry.clone(),
};
+3 -2
View File
@@ -421,9 +421,10 @@ export const aiApi = {
/**
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
* focusConv 标记来源为当前会话的分组(focused=true 供前端高亮);后端未合版本会忽略该参数。
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。
*/
listContextBriefs(perProject?: number): Promise<{ source_project: string; briefs: { title: string; summary: string; source_ref: string | null; source_project: string | null; reuse_count: number }[] }[]> {
return invoke('ai_context_briefs', { perProject: perProject ?? null })
listContextBriefs(perProject?: number, focusConv?: string): Promise<{ source_project: string; focused?: boolean; briefs: { title: string; summary: string; source_ref: string | null; source_conv: string | null; source_project: string | null; reuse_count: number; updated_at: string }[] }[]> {
return invoke('ai_context_briefs', { perProject: perProject ?? null, focusConv: focusConv ?? null })
},
}
+79 -9
View File
@@ -1,6 +1,7 @@
<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。 -->
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
有源会话的条目标题可点跳回原文,空源或跳转失败静默不打断阅读。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
@@ -19,9 +20,19 @@
<div v-if="loading" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsLoading') }}</div>
<div v-else-if="groups.length === 0" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsEmpty') }}</div>
<div v-for="g in groups" v-else :key="g.source_project" class="ctx-briefs-group">
<div class="ctx-briefs-project">{{ g.source_project }}</div>
<div class="ctx-briefs-project">
<span v-if="g.focused" class="ctx-briefs-focus-dot" />{{ g.source_project }}
</div>
<div v-for="b in g.briefs" :key="g.source_project + b.title" class="ctx-briefs-item">
<div class="ctx-briefs-item-title">{{ b.title }}</div>
<div class="ctx-briefs-item-head">
<button
v-if="b.source_conv"
class="ctx-briefs-item-title ctx-briefs-item-link"
@click="openSource(b)"
>{{ b.title }}</button>
<span v-else class="ctx-briefs-item-title">{{ b.title }}</span>
<span class="ctx-briefs-item-time">{{ formatRelative(b.updated_at) }}</span>
</div>
<div class="ctx-briefs-item-summary">{{ b.summary }}</div>
<button
class="ctx-briefs-bring"
@@ -35,26 +46,32 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '../../api/ai'
import { useAiStore } from '../../stores/ai'
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
import { useAiConversations } from '../../composables/ai/useAiConversations'
import { formatRelative } from '../../utils/time'
interface ContextBrief {
title: string
summary: string
source_ref: string | null
source_conv: string | null
source_project: string | null
reuse_count: number
updated_at: string
}
interface ProjectBriefs {
source_project: string
focused?: boolean
briefs: ContextBrief[]
}
const { t } = useI18n()
const store = useAiStore()
const { openConversation } = useAiConversations()
const expanded = ref(false)
const loading = ref(false)
@@ -62,18 +79,40 @@ const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) {
/** 拉取脉络,按当前会话标注聚焦分组;会话已切走时丢弃过期响应(防旧 focused 污染新列表) */
async function load() {
const convId = store.state.activeConversationId ?? undefined
loading.value = true
try {
groups.value = await aiApi.listContextBriefs()
const result = await aiApi.listContextBriefs(undefined, convId)
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = result
} catch {
groups.value = []
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = []
} finally {
loading.value = false
}
}
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) await load()
}
/** 切会话重新拉取(focused 跟随当前会话);收起态只清缓存,保持按需加载 */
watch(() => store.state.activeConversationId, () => {
groups.value = []
if (expanded.value) void load()
})
/** 跳回摘要来源会话(source_conv 由后端自 source_ref 解析,消息级引用已反查);
* 源为空或已删除(not_found)一律静默,不加 toast */
async function openSource(b: ContextBrief) {
if (!b.source_conv) return
try {
await openConversation(b.source_conv)
} catch {
// 静默:跳转失败不打断阅读
}
}
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
@@ -124,14 +163,45 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
color: var(--text-primary, #333);
padding: 5px 2px 2px;
}
.ctx-briefs-focus-dot {
display: inline-block;
width: 5px;
height: 5px;
margin-right: 4px;
border-radius: 50%;
background: var(--accent, #4080ff);
vertical-align: 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 1px dashed var(--border, #eee);
}
.ctx-briefs-item-head {
display: flex;
align-items: baseline;
gap: 6px;
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--text-primary, #333);
}
.ctx-briefs-item-link {
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-briefs-item-link:hover {
color: var(--accent, #4080ff);
}
.ctx-briefs-item-time {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
color: var(--text-tertiary, #999);
}
.ctx-briefs-item-summary {
color: var(--text-secondary, #666);
margin: 2px 0;
+1 -1
View File
@@ -366,7 +366,7 @@ export default {
contextBriefsToggle: 'Expand or collapse project briefs',
contextBriefsTitle: 'Project briefs',
contextBriefsLoading: 'Loading…',
contextBriefsEmpty: 'No published knowledge yet',
contextBriefsEmpty: 'No briefs yet. Conversation takeaways are distilled here automatically when a conversation ends',
contextBriefsBringIn: 'Bring into context',
},
}
+1 -1
View File
@@ -367,7 +367,7 @@ export default {
contextBriefsToggle: '展开或收起项目脉络',
contextBriefsTitle: '项目脉络',
contextBriefsLoading: '加载中…',
contextBriefsEmpty: '暂无项目沉淀知识',
contextBriefsEmpty: '暂无知识沉淀。会话结束时会自动提炼对话要点,沉淀到这里',
contextBriefsBringIn: '带入上下文',
},
}