Compare commits

20 Commits
Author SHA1 Message Date
lxy 0317873a5b 文档: 上下文管理设计/对标u-claw差距分析与看板登记
- 专项设计:M1-M4四批修复蓝图+对标u-claw六能力画像与P0-P2补差
- 看板:M1销账(验收证据)/收尾令/对标P0-P2条目登记
2026-10-19 00:25:52 +08:00
lxy eec5978fa7 修复: 上下文管理第二批(E5注入产物/E13超时收敛/E2降级透出)
- E5:[补充指令]与[系统恢复]同治三处——载入滤出/恢复回放剔除/
  首条任务消息排除(434c8c40占位实证),is_guide_artifact统一判据
- E13:同工具超时连败≥3跨签名聚合,执行前软收敛警告(治换档位调参
  逃逸,434c8c40 run_command 8连败实证)
- E2:压缩失败走关键词摘要兜底时新发AiContextDegraded,前端toast
  「上下文已降级」不再静默失忆(76730ce8 seq166实证)
- vite watch忽略target/(cargo编译写.o触发EBUSY杀dev进程)
- provider-list模板根div缺闭合补齐(拆分半成品断链)

验收:tool_failure 5/keyword_fallback 3/recovery 6 passed;vue-tsc EXIT 0
2026-10-19 00:25:51 +08:00
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00
lxy 302c4bcf52 修复: 飞书审批决议终态闭环与决议词容错
- F5-1:决议词归一(trim/剥尾标点/同义:批准|同意|拒绝|不同意)
- F5-2:审批终态卡原地更新(桌面决议/超时拒后不再恒挂可点);
  迟到决议30min窗内提示已处理,不再注入运行中任务
- APPROVAL_CARDS/RESOLVED_CHATS内存句柄,重启后走幂等回执无损失
2026-10-18 23:15:51 +08:00
lxy 05d0f89c16 修复: aichat上下文管理与信息组织P1批
逐件(证据:深查-aichat质量/信息UX-2026-10-17):
- 恢复链套娃:RECOVERY_ARTIFACT_PREFIX双侧过滤+单点拦截(E1)
- message_repo全量重写保留原created_at(E11,治1750条时间线污染)
- search_files/grep 0命中附阴性存疑提示(E6,共享negative_hit_hint)
- 项目清单三源活跃度排序+system prompt指纹对齐(E4跑题)
- 会话列表活跃/归档offset分页+侧栏加载更多(F4-1,治50硬上限)
- inject消息source标记(轮次分桶不误计发起,V52同源)
- 提示词回复风格条款中英双语(反口水话,含测试断言)
- i18n补键:复制/折叠量/发送title/历史定位提示(F1-1/F2-1/F6-1)

验收:cargo test --workspace 2281 passed 0 failed;vue-tsc EXIT 0
2026-10-18 23:15:45 +08:00
lxy a273a86a7d 优化: 参考 u-agent 管理台范式升级 UI 设计令牌与公共组件 2026-10-18 15:35:15 +08:00
lxy 45f0560f85 修复: 飞书长连接假活自愈——探活持续断流后主动断开重建
看门狗连续 3 个巡检周期(断流约13分钟)判定假活后 Notify 唤醒
run_loop 主动退出,复用外层 backoff 重连循环重建全新 WS 握手
只告警不自愈的盲区已补齐,比 SDK 内部重连更彻底(全新握手)
2026-10-17 22:19:57 +08:00
lxy f74d51b905 优化: 会话侧栏字体层级收敛+v0.1.3发布收尾脚本入库,归档飞书WS假活排查文档 2026-10-17 21:54:25 +08:00
lxy e08b0f4311 优化: 升版 0.1.2 -> 0.1.3,启动 B2/B3 端到端升级验证 2026-10-17 21:18:03 +08:00
lxy 3bb662f092 修复: 工具外置集成测试夹具失效
- 夹具误用错误行信号(.rs:)做普通大结果,错误行全量保留致压缩断言先天不成立,改中性行
- 生产行为缺陷(错误行洪泛击穿压缩,grep/rustc 类输出为主要受害者)登记看板待修
2026-10-15 14:27:33 +08:00
lxy a644e93604 修复: 项目详情页三态分离与反馈一致性
- notFound/已归档/加载失败三态分离:单查兜底+归档恢复入口+加载失败重试钮
- 反馈一致性六项:同步守卫与完成提示/首屏并行加载/筛选空态文案/计数改过滤口径/路径勾选 seq 守卫/删除确认危险态
- 文件列表滚动条对齐全局范式(scrollbar-width:thin 致 Chromium 忽略定制样式)
- 删除失败不再跳转(store 返布尔)/幽灵勾选清理/折叠阈值与 CSS 对齐
2026-10-15 14:27:32 +08:00
lxy 795935bf6a 新增: 飞书文件消息入站与补充注入链完善
- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
2026-10-15 14:27:22 +08:00
lxy 7548896f6d 文档: 登记目标污染与停滞判定实证待办 2026-10-13 13:29:17 +08:00
lxy 5faaa88302 新增: 知识路标投影进系统提示词
- published 且验证或高复用条目按项目优先进投影稳定段
- 段尾固定引导工具故障先检索路标,治有经验不想查
- 与规则文件投影共用会话级缓存,空库零回归
2026-10-13 13:29:16 +08:00
lxy 8c4aa5dc5b 新增: 工具失败连败升级与熔断停靠
- 同工具连败三级提示:再败软提示,三败强制换路并引导检索经验
- 连拦五次强制安全停靠,不给无视熔断的余地
- 连击按会话计数防参数抖动绕过,成功即清零
2026-10-13 13:29:15 +08:00
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
lxy cb23061088 新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区
- 目标快照: 恢复引导按最终有效意图生成,旧目标不复活
- 知识检索项目维度软排序,WorkingContext 持久化迁移
- 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入
- 压缩与缓存能力在主循环/子代理/提示词三面接线
- 两份专项设计文档与看板销账
2026-10-11 22:27:20 +08:00
lxy 6f5c6b7532 优化: 上下文压缩缓存友好化与召回句柄
- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退
- 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配
- BP3 断点机制+缓存命中率观测日志,调参有据
- 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费
- 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗
- namespace 引用跨重启内容哈希懒回填
2026-10-11 22:27:08 +08:00
lxy c7d0ed939c 新增: 连发消息防抖与长命令后台化
- 飞书连发消息 0.5s 窗(拍板值)合并为一次任务:文本空行拼接+图片到达序合并
- 命令/审批/注入即时路径不进窗;追加消息并入即静默返回;窗满统一受理,守卫拒绝落待发箱
- run_command 超 grace 窗(默认 90s,可配)转后台:LLM 收非终态结果,不再整轮阻塞
- 后台任务自持心跳+删除自中止+失败记忆+完成通知+回填续跑;占用闸单点保会话态自洽
- 开关默认关=同步路径逐字节不变;新增 13 测
2026-10-11 12:37:23 +08:00
133 changed files with 11030 additions and 2710 deletions
Generated
+1 -1
View File
@@ -1039,7 +1039,7 @@ dependencies = [
[[package]]
name = "devflow"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"anyhow",
"async-trait",
+3
View File
@@ -310,6 +310,7 @@ mod tests {
role: MessageRole::User,
content: "字面量构造".into(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
@@ -368,6 +369,7 @@ mod tests {
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
@@ -429,6 +431,7 @@ mod tests {
role: MessageRole::Assistant,
content: "answer".to_string(),
parts: None,
source: None,
tool_call_id: None,
tool_calls: None,
model: None,
+253 -18
View File
@@ -62,6 +62,46 @@ pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// BP3 摘要链尾断点开关(缓存友好上下文总开关在本模块的投影,默认开)。
///
/// 本模块不读 src-tauri KV(跨 crate),上层读 settings `df-ai-cache-friendly-context`
/// 后经 [`set_bp3_enabled`] 同步(同 [`set_anthropic_cache_enabled`] 先例)。
/// 关时消息级只打 BP2,断点布局与历史逐字节一致。BP3 仍内嵌于 anthropic 缓存总开关
/// 之下(它发的也是 cache_control),两层正交。
static BP3_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 设置 BP3 摘要链尾断点开关(上层读 settings 后同步)。
pub fn set_bp3_enabled(enabled: bool) {
BP3_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 BP3 开关(`convert_request` 组装消息级断点用)。
pub fn bp3_enabled() -> bool {
BP3_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
/// 缓存命中率观测日志(usage 就绪点每轮一条,供后续水位调参取数)。
///
/// anthropic 语义:input_tokens 为未缓存输入,cache_read/creation 独立计数,
/// 故命中率分母 = read + creation + input;三者全零(端点未回 usage)跳过不报。
fn log_cache_usage(usage: &TokenUsage) {
let total_input = usage.prompt_cache_hit_tokens as u64
+ usage.prompt_cache_miss_tokens as u64
+ usage.prompt_tokens as u64;
if total_input == 0 {
return;
}
let hit_rate = usage.prompt_cache_hit_tokens as f64 / total_input as f64;
let hit_pct = format!("{:.1}%", hit_rate * 100.0);
tracing::info!(
input_tokens = usage.prompt_tokens,
cache_read_input_tokens = usage.prompt_cache_hit_tokens,
cache_creation_input_tokens = usage.prompt_cache_miss_tokens,
hit_rate = %hit_pct,
"Anthropic prompt cache 命中率观测"
);
}
// ============================================================
// Provider 实现
// ============================================================
@@ -147,10 +187,21 @@ impl AnthropicCompatProvider {
// 构建非 system 消息(保留顺序,合并连续 tool_result)
let mut messages: Vec<serde_json::Value> = Vec::new();
let mut pending_tool_results: Vec<serde_json::Value> = Vec::new();
// 摘要锚点 = index>0 的 System(存储态:压缩摘要/话题标记/催办;首条是组装层
// system_prompt,归 BP1 顶层 system 非锚点)。记录「末锚点之后的消息条数」而非
// 下标:取用时按 len 反推目标,此前的 merge/补首条等数组增删天然自校正。
// 锚点以存储态 System 识别——view 层 System→user 转换落地后锚点以 user 形态
// 到达,此处识别不到,BP3 自然旁路(不加断点),无副作用。
let mut msgs_after_anchor: Option<usize> = None;
for m in req.messages.iter() {
for (src_idx, m) in req.messages.iter().enumerate() {
match m.role {
MessageRole::System => continue,
MessageRole::System => {
if src_idx > 0 {
msgs_after_anchor = Some(0);
}
continue;
}
MessageRole::Tool => {
// 累积 tool_result 块,遇到非 Tool 消息时 flush。
// tool_use_id 为 None 时绝不能发 null(GLM anthropic 端点会 500
@@ -173,7 +224,7 @@ impl AnthropicCompatProvider {
}
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
@@ -192,9 +243,12 @@ impl AnthropicCompatProvider {
} else {
messages.push(serde_json::json!({ "role": "user", "content": m.content }));
}
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
}
MessageRole::Assistant => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
let mut content: Vec<serde_json::Value> = Vec::new();
if !m.content.is_empty() {
content.push(serde_json::json!({ "type": "text", "text": m.content }));
@@ -218,10 +272,13 @@ impl AnthropicCompatProvider {
content.push(serde_json::json!({ "type": "text", "text": "" }));
}
messages.push(serde_json::json!({ "role": "assistant", "content": content }));
if let Some(k) = &mut msgs_after_anchor {
*k += 1;
}
}
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results, &mut msgs_after_anchor);
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
@@ -240,10 +297,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
// 消息级缓存断点(开关开):BP2 打在历史段稳定边界,使 system 之后的对话历史
// 也可命中(否则只缓存 system,长会话历史段全价重算);BP3(缓存友好开关,默认开)
// 加打摘要锚点链之后第一条,BP2 因裁剪边界/压缩失效时 [system+摘要链] 小前缀仍命中。
if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages);
let after_anchor = if bp3_enabled() { msgs_after_anchor } else { None };
Self::apply_message_cache_breakpoint(&mut messages, after_anchor);
}
let tools = req.tools.map(|defs| {
@@ -328,34 +387,61 @@ impl AnthropicCompatProvider {
fn flush_tool_results(
messages: &mut Vec<serde_json::Value>,
pending: &mut Vec<serde_json::Value>,
msgs_after_anchor: &mut Option<usize>,
) {
if pending.is_empty() {
return;
}
let blocks: Vec<serde_json::Value> = pending.drain(..).collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
if let Some(k) = msgs_after_anchor {
*k += 1;
}
}
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
/// 消息级缓存断点:BP2 打在「历史段稳定边界」的末个 content block 上,BP3(可选)
/// 加打「摘要锚点链之后第一条」——`after_anchor` 为锚点链后的消息条数
/// (Some(0)=锚点收尾无后继,不打;None=无锚点,只打 BP2)。
///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// BP2 落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
///
/// BP3 服务于 BP2 频繁失效的场景:BP2 随裁剪边界移动/压缩事件/新近度豁免改写而
/// 迁位,前缀反复全价重算;BP3 前缀只含 [system+摘要链+1 条],失效源仅压缩事件,
/// 为该小前缀保底命中。与 BP2 同条(短序列)时跳过,BP2 优先。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
/// 断点预算:Anthropic 单请求最多 4 个 cache_control——system 1 + BP2 1 + BP3 1 = 3,
/// 余 1 备用。
fn apply_message_cache_breakpoint(
messages: &mut [serde_json::Value],
after_anchor: Option<usize>,
) {
let n = messages.len();
if n == 0 {
return;
}
let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx];
let bp2_idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
if let Some(k) = after_anchor {
if k > 0 && k <= n {
let bp3_idx = n - k;
if bp3_idx != bp2_idx {
Self::mark_cache_control(&mut messages[bp3_idx]);
}
}
}
Self::mark_cache_control(&mut messages[bp2_idx]);
}
/// 单条消息打 cache_control:string content 升级为单 text block 数组再标;
/// array(blocks) 标末块;其余形态(异常)不动。
fn mark_cache_control(msg: &mut serde_json::Value) {
let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => {
@@ -729,6 +815,7 @@ impl LlmProvider for AnthropicCompatProvider {
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
reasoning_tokens: 0,
};
log_cache_usage(&usage);
AttemptOutcome::Ok(CompletionResponse {
text,
model: resp.model,
@@ -874,6 +961,12 @@ impl LlmProvider for AnthropicCompatProvider {
if let Some(err) = chunk.error.as_mut() {
*err = format!("{} | messages 摘要: {}", err, messages_summary);
}
// 流式收尾(message_stop 终态帧)即 usage 就绪点,输出命中率观测日志
if chunk.finished {
if let Some(u) = &chunk.usage {
log_cache_usage(u);
}
}
chunks.push(Ok(chunk));
}
}
@@ -1258,7 +1351,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
// 前一条 assistant 的末块带 cache_control
let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap();
@@ -1275,7 +1368,7 @@ mod tests {
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
@@ -1289,7 +1382,7 @@ mod tests {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
@@ -1298,10 +1391,152 @@ mod tests {
#[test]
fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, None);
assert!(messages.is_empty());
}
/// BP3 落「锚点链后首条」(按锚点后条数反推下标),与 BP2 各占一条互不覆盖。
#[test]
fn cache_breakpoint_bp3_marks_first_message_after_anchor_chain() {
// 模拟 [锚点链][q0][a0][q1][a1][q2] 转换后的数组:锚点后 5 条
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(5));
// BP3 → idx0(q0);BP2(末条 user,取 n-2)→ idx3(a1)
for idx in [0usize, 3] {
let blocks = messages[idx]["content"].as_array().unwrap();
assert_eq!(
blocks.last().unwrap()["cache_control"]["type"],
"ephemeral",
"idx{} 应带断点",
idx
);
}
assert_eq!(messages[4]["content"].as_str(), Some("q2"), "末条 user 不打点");
}
/// BP3 与 BP2 同条(短序列)→ 跳过 BP3,BP2 优先,断点预算不重复占。
#[test]
fn cache_breakpoint_bp3_skipped_when_colliding_with_bp2() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "assistant", "content": "a0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
// 锚点后 2 条 → BP3 目标 idx1,与 BP2(n-2=1)同条 → 跳过
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(2));
let blocks = messages[1]["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
assert!(messages[0]["content"].as_str().is_some(), "q0 应保持字符串简写");
}
/// 锚点收尾无后继(after_anchor=0)→ 不打 BP3,BP2 照常落点。
#[test]
fn cache_breakpoint_bp3_zero_after_anchor_noop() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q0" }),
serde_json::json!({ "role": "user", "content": "q1" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages, Some(0));
// BP2(末条 user → n-2=0)照常标 q0
let blocks = messages[0]["content"].as_array().expect("BP2 应照常落点");
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
}
// ---------- BP3 开关门(convert 级,进程级开关用例须持 convert_lock) ----------
/// 构造 BP3 开关用例的请求:首条组装层 system_prompt + (可选)锚点链 + 历史段。
/// 转换后数组恒为 [q0, a0, q1](锚点折叠进顶层 system)。
fn bp3_req(with_anchor: bool) -> CompletionRequest {
let mut messages = vec![
ChatMessage::system("sys-prompt"),
ChatMessage::user("q0"),
ChatMessage::assistant("a0"),
ChatMessage::user("q1"),
];
if with_anchor {
messages.insert(1, ChatMessage::system("## 压缩摘要"));
messages.insert(2, ChatMessage::system("## 话题标记"));
}
CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages,
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
}
}
/// BP3 开 + 有锚点 → 断点落摘要链后第一条(q0),BP2 照常落历史尾(a0)。
#[test]
fn bp3_enabled_with_anchor_marks_first_message_after_chain() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
// BP3:锚点链后首条 q0(原字符串升级为 blocks + cache_control)
let q0_blocks = body.messages[0]["content"].as_array().expect("BP3 应标在锚点链后首条 q0");
assert_eq!(q0_blocks[0]["text"], "q0");
assert_eq!(q0_blocks[0]["cache_control"]["type"], "ephemeral");
// BP2 照常:末条 user 前相邻一条 a0
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写
assert_eq!(body.messages[2]["content"].as_str(), Some("q1"));
set_anthropic_cache_enabled(false);
set_bp3_enabled(true); // 复位到进程默认(默认开),防污染其他并行用例
}
/// BP3 关 → 断点布局与历史一致(仅 BP2,锚点链后首条不打点)。
#[test]
fn bp3_disabled_keeps_legacy_breakpoint_layout() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(false);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(true));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"BP3 关,q0 保持字符串简写"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
/// 无锚点(仅组装层首条 system)→ 不加 BP3,仅 BP2。
#[test]
fn bp3_no_anchor_no_extra_breakpoint() {
let _convert = convert_lock();
set_anthropic_cache_enabled(true);
set_bp3_enabled(true);
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let body = provider.convert_request(bp3_req(false));
assert_eq!(body.messages.len(), 3);
assert_eq!(
body.messages[0]["content"].as_str(),
Some("q0"),
"无锚点应不加 BP3"
);
let a0_blocks = body.messages[1]["content"].as_array().unwrap();
assert_eq!(a0_blocks.last().unwrap()["cache_control"]["type"], "ephemeral", "BP2 照常");
set_anthropic_cache_enabled(false);
set_bp3_enabled(true);
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
+180
View File
@@ -0,0 +1,180 @@
//! 压缩可召回存档:被压缩原文 JSONL 落盘 + 召回句柄拼装。
//!
//! 压缩把原文从 LLM 上下文隔离(is_active 白名单过滤),摘要必有损。本模块在压缩标记时
//! 把本次被压原文追加写入 `<data_dir>/context-archive/<conv_id>-archive.jsonl`
//! (JSON Lines,一行一条 {role, content}),调用方在压缩摘要尾部拼召回句柄行,
//! LLM 需要原文时经 read_file 回读。会话一文件、追加写,目录不存在则创建;
//! 会话删除级联清理留待后续挂点。存档只落盘不进上下文,token 估算零影响;
//! 不与 tool_offload 复用(那是 src-tauri 侧机制,跨 crate 不混用)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::provider::ChatMessage;
/// 存档目录名(data_dir 下)
const ARCHIVE_DIR: &str = "context-archive";
/// 一次压缩事件的存档目标(会话维度)。调用方读到开关开(`df-ai-compress-recall`)后经
/// [`super::ContextManager::set_compress_recall`] 注入,压缩标记时一次性消费。
pub(super) struct RecallTarget {
data_dir: PathBuf,
conv_id: String,
}
impl RecallTarget {
pub(super) fn new(data_dir: &Path, conv_id: &str) -> Self {
Self {
data_dir: data_dir.to_path_buf(),
conv_id: conv_id.to_string(),
}
}
/// 存档文件路径:`<data_dir>/context-archive/<conv_id>-archive.jsonl`
fn file(&self) -> PathBuf {
self.data_dir
.join(ARCHIVE_DIR)
.join(format!("{}-archive.jsonl", self.conv_id))
}
}
/// 把本次被压原文追加写入会话存档(JSONL 追加语义,不覆盖既有行)。
/// 返回存档路径供句柄拼装;空消息/IO 失败返 None(调用方降级为无句柄,压缩本身不受影响)。
pub(super) fn append_archive(target: &RecallTarget, msgs: &[ChatMessage]) -> Option<PathBuf> {
if msgs.is_empty() {
return None;
}
let path = target.file();
let dir = path.parent()?;
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!(error = %e, dir = %dir.display(), "[archive] 存档目录创建失败,本轮无召回句柄");
return None;
}
let mut file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档文件打开失败,本轮无召回句柄");
return None;
}
};
for m in msgs {
let line = serde_json::json!({ "role": &m.role, "content": m.content });
if let Err(e) = writeln!(file, "{line}") {
tracing::warn!(error = %e, path = %path.display(), "[archive] 存档行写入失败,本轮无召回句柄");
return None;
}
}
Some(path)
}
/// 召回句柄行:LLM 按此路径 read_file 回读被压原文。
pub fn recall_line(path: &Path) -> String {
format!("早期历史全文已存档: `{}`(read_file 可召回)", path.display())
}
/// 摘要尾部拼召回句柄行(开关开且存档落盘成功时用;开关关调用方直接用原摘要)。
pub fn with_recall_line(summary: &str, path: &Path) -> String {
format!("{}\n{}", summary, recall_line(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextManager;
use crate::context_helpers::ContextConfig;
use tempfile::TempDir;
fn mgr() -> ContextManager {
ContextManager::new(ContextConfig {
max_tokens: 100_000,
..Default::default()
})
}
/// 灌 2 条可压 + 1 条保护区消息,注入召回目标后压缩 [0..2),返回存档路径。
fn compress_with_recall(tmp: &TempDir, conv_id: &str) -> PathBuf {
let mut m = mgr();
m.push(ChatMessage::user("早期问题"));
m.push(ChatMessage::assistant("早期回复"));
m.push(ChatMessage::user("近期问题"));
m.set_compress_recall(tmp.path(), conv_id);
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "前置:应压缩 2 条");
m.take_archive_path().expect("存档应落盘")
}
/// 压缩时存档文件创建,且每行含被压原文的 role+content。
#[test]
fn compress_with_recall_archives_original_text() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-a");
assert_eq!(
path,
tmp.path().join("context-archive").join("conv-a-archive.jsonl")
);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
let first: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["role"], "user");
assert_eq!(first["content"], "早期问题");
let second: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["role"], "assistant");
assert_eq!(second["content"], "早期回复");
assert!(lines.next().is_none(), "不应有多余行");
}
/// 开关开:摘要尾部含召回句柄行(含存档路径与 read_file 指引);路径一次性消费。
#[test]
fn recall_enabled_summary_gets_handle_line() {
let tmp = TempDir::new().unwrap();
let path = compress_with_recall(&tmp, "conv-b");
let summary = "## 意图\n- 测试主题";
let with_handle = with_recall_line(summary, &path);
assert!(with_handle.starts_with(summary), "句柄拼在摘要尾部,原摘要在前");
let handle = recall_line(&path);
assert!(with_handle.ends_with(&handle));
assert!(handle.contains(&path.display().to_string()));
assert!(handle.contains("read_file"));
}
/// 开关关(不注入召回目标):不落盘、无句柄路径可拼。
#[test]
fn recall_off_no_archive_no_handle() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("问题"));
m.push(ChatMessage::assistant("回复"));
m.push(ChatMessage::user("近期"));
let compressed = m.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
assert!(m.take_archive_path().is_none(), "开关关不应有存档路径");
assert!(
!tmp.path().join("context-archive").exists(),
"开关关不应创建存档目录"
);
}
/// 二次压缩追加不覆盖(JSONL 追加语义):两批原文都在,行数累计。
#[test]
fn second_compress_appends_not_overwrites() {
let tmp = TempDir::new().unwrap();
let mut m = mgr();
m.push(ChatMessage::user("第一批"));
m.push(ChatMessage::assistant("第一批回复"));
m.push(ChatMessage::user("第二批"));
m.push(ChatMessage::assistant("第二批回复"));
m.push(ChatMessage::user("近期"));
m.set_compress_recall(tmp.path(), "conv-c");
let _ = m.compress_old_messages(2);
// 第二次压缩事件:调用方重新注入(开关可能已变),幂等跳过已压缩,只压新批
m.set_compress_recall(tmp.path(), "conv-c");
let second = m.compress_old_messages(4);
assert_eq!(second.len(), 2, "第二次只压新激活的 2 条");
let path = m.take_archive_path().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 4, "两次压缩累计 4 行,不覆盖");
assert!(text.contains("第一批回复"), "第一批原文仍在");
assert!(text.contains("第二批回复"), "第二批原文已追加");
}
}
+248 -5
View File
@@ -44,10 +44,10 @@ fn estimate_message_counts_parts_tokens() {
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
// base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token,实际 {}",
mm_tokens >= 1600,
"大 base64 应按图地板计 token,实际 {}",
mm_tokens
);
@@ -72,8 +72,10 @@ fn short_history_no_trim() {
#[test]
fn over_budget_trims_old() {
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
let mut mgr = ContextManager::new(cfg(200));
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
// 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
let mut mgr = ContextManager::new(cfg(400));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 {
if i % 2 == 0 {
@@ -1234,3 +1236,244 @@ fn push_system_anchor_avoids_dangling_tool_call_head() {
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
);
}
// ============================================================
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
// ============================================================
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
}
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
let est = TokenEstimator::default();
msgs.iter()
.filter(|m| !m.content.starts_with("[早期对话概要]"))
.map(|m| est.estimate_message(m) as u64)
.sum()
}
#[test]
fn block_trim_lands_below_target_water_level() {
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= target,
"裁后水位应落在目标 {} 以下, 实际 {}",
target,
remaining
);
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
let last_real = msgs
.iter()
.rev()
.find(|m| !m.content.starts_with("[早期对话概要]"))
.expect("视图非空");
assert!(
last_real.content.contains("第39条"),
"保护区最新消息被误裁, 实际末条: {}",
last_real.content
);
}
#[test]
fn block_trim_snaps_boundary_to_turn_start() {
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
let mut mgr = ContextManager::new(cfg(300));
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), filler);
}
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
for i in 0..6 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let first = msgs.first().expect("视图非空");
assert!(
matches!(first.role, MessageRole::User),
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
first.role
);
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
view_history_tokens(&msgs) <= target,
"吸附同时仍应满足目标水位"
);
assert_eq!(
has_head(&msgs),
has_tail(&msgs),
"轮吸附不得劈开工具三元组"
);
}
#[test]
fn block_trim_pathological_falls_back_to_hard_budget() {
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
let mut mgr = ContextManager::new(cfg(400));
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
for r in 0..3 {
push_tool_round(&mut mgr, &r.to_string(), &filler);
}
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
mgr.push(ChatMessage::system("[早期锚点]"));
for i in 0..5 {
let text = format!("保护区{}需要足够长的文本来撑量", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
mgr.set_cache_friendly(true);
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "病态回退仍应触发裁剪");
let remaining = view_history_tokens(&msgs);
assert!(
remaining <= 400,
"硬预算安全底线:裁后不得超过 available, 实际 {}",
remaining
);
let first = msgs.first().expect("视图非空");
assert!(
first.content.contains("[早期锚点]"),
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
first.content
);
}
#[test]
fn cache_friendly_off_keeps_greedy_min_trim() {
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
let mut mgr = ContextManager::new(cfg(1000));
for i in 0..40 {
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
if i % 2 == 0 {
mgr.push(ChatMessage::user(&text));
} else {
mgr.push(ChatMessage::assistant(&text));
}
}
// 不注入(字段默认 false)= 开关关
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
let remaining = view_history_tokens(&msgs);
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
assert!(
remaining <= 1000,
"贪心裁后应塞下硬预算, 实际 {}",
remaining
);
assert!(
remaining > target,
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
target,
remaining
);
}
#[test]
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(!msgs.is_empty());
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"开时视图不得残留 system: {:?}",
msgs.iter().map(role_tag).collect::<Vec<_>>()
);
assert!(
mgr.all_messages_clone()
.iter()
.any(|m| matches!(m.role, MessageRole::System)),
"存储态 system 锚点不应被改写(纯视图)"
);
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
final_view.extend(msgs);
assert!(
matches!(final_view[0].role, MessageRole::System),
"组装层首条 system_prompt 不转"
);
assert_eq!(
final_view
.iter()
.filter(|m| matches!(m.role, MessageRole::System))
.count(),
1,
"最终视图应只有组装层首条 system"
);
}
#[test]
fn system_anchor_head_insert_still_converted() {
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.insert_at(0, ChatMessage::system("[摘要]"));
mgr.set_cache_friendly(true);
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
);
}
#[test]
fn system_anchor_stays_system_when_cache_friendly_off() {
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
let (msgs, _t) = mgr.build_for_request(0);
assert!(
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
"关时 system 锚点应原样进视图"
);
}
+139 -15
View File
@@ -20,9 +20,11 @@
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
mod sanitize;
pub mod archive;
use crate::context_helpers::{
build_eviction_summary_text, classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
TRIM_TARGET_RATIO,
};
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
@@ -78,6 +80,16 @@ pub struct ContextManager {
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool,
/// 压缩可召回:本次压缩事件的存档目标(调用方读到 KV 开后注入,compress_old_messages
/// 一次性消费)。None = 本事件不存档(开关关/未注入),零行为变更。
compress_recall: Option<archive::RecallTarget>,
/// 最近一次压缩成功落盘的存档路径(take 语义,供调用方在压缩摘要尾部拼召回句柄;
/// 每次 compress_old_messages 按事件重置,防跨事件残留误报)。
archive_path: Option<std::path::PathBuf>,
/// 缓存友好上下文总开关(跨 crate 注入:settings 在 src-tauri 侧,df-ai 不可直读,
/// 调用方读 KV `df-ai-cache-friendly-context` 后经 [`Self::set_cache_friendly`] 注入)。
/// 默认 false = 旧行为(贪心最小裁剪 + system 锚点原样发送),未注入路径零行为变更。
cache_friendly: bool,
}
impl ContextManager {
@@ -91,6 +103,9 @@ impl ContextManager {
pending_topic_marker: None,
persisted_msg_count: 0,
needs_full_rewrite: false,
compress_recall: None,
archive_path: None,
cache_friendly: false,
}
}
@@ -153,6 +168,8 @@ impl ContextManager {
// 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0;
self.needs_full_rewrite = true;
self.compress_recall = None;
self.archive_path = None;
}
/// 消息数量
@@ -196,10 +213,10 @@ impl ContextManager {
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone());
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
@@ -208,15 +225,12 @@ impl ContextManager {
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in &units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
// 缓存友好开:块状裁剪(目标水位+轮边界吸附);关:贪心最小裁剪(逐字节等价旧路径)
let (trim_end, removed) = if self.cache_friendly {
self.block_trim_target(&units, available)
} else {
self.greedy_trim_target(&units, available)
};
if trim_end == 0 {
tracing::warn!(
@@ -229,9 +243,9 @@ impl ContextManager {
// 致超预算且保护区满时命中)。异常会话(开头连续 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());
let view = self.anchors_to_user(Self::sanitize_messages(self.all_messages_clone()));
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
false,
);
}
@@ -264,14 +278,93 @@ impl ContextManager {
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
let sanitized = Self::sanitize_messages(msgs);
let view = self.anchors_to_user(Self::sanitize_messages(msgs));
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
Self::assert_placeholder_pairing(view, PLACEHOLDER_INTEGRITY_ENABLED),
true,
)
}
/// 贪心最小裁剪:累计淘汰单元直到塞下硬预算即停(历史行为,开关关路径)。
///
/// 缺陷:对话每增长一条消息就重新越过预算一点,裁剪边界随之前移一个单元,
/// 发送视图首条每轮都变,前缀缓存从第 0 字节起永远 miss。
fn greedy_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
(trim_end, removed)
}
/// 块状裁剪:一次裁到目标水位 `TRIM_TARGET_RATIO × available`,且边界吸附轮边界。
///
/// 为什么块状:两次裁剪之间裁剪边界不动 → 被淘汰集不变 → 视图前缀逐字节稳定,
/// 前缀缓存每轮命中;裁剪频度由水位差(块大小)决定而非逐条爬行。
/// 轮边界:单元 end 处下一条未淘汰消息 role=User 即轮起点,整问整答(含工具链)
/// 同生同死,淘汰摘要按轮覆盖语义完整,且轮起点是稳定地标。
/// 病态回退(单轮体量 > 块,扫到保护区仍无两条件齐备的边界):取满足硬预算
/// available 的最后单元边界(安全底线);连硬预算都不可得(保护区自身超限)则
/// 尽力裁到末单元,对齐贪心路径。
fn block_trim_target(&self, units: &[EvictionUnit], available: u32) -> (usize, u64) {
let target = (available as f32 * TRIM_TARGET_RATIO) as u32;
let mut removed: u64 = 0;
let mut hard = (0usize, 0u64);
let mut last = (0usize, 0u64);
for unit in units {
removed += unit.token_sum as u64;
last = (unit.end, removed);
let remaining = self.history_tokens.saturating_sub(removed as u32);
if remaining <= available {
hard = (unit.end, removed);
}
// 目标水位隐含硬预算已满足(target < available),轮吸附与水位同时判定
if remaining <= target
&& self
.messages
.get(unit.end)
.is_some_and(|next| matches!(next.message.role, MessageRole::User))
{
return (unit.end, removed);
}
}
if hard.0 > 0 {
hard
} else {
last
}
}
/// 发送视图出口整形(sanitize 之后):总开关开时把存储态 role=System 消息
/// (压缩摘要/话题标记/警示等锚点)映射为 role=User 发送。
///
/// 为什么:anthropic_compat 把存储态全部 role=System 折叠进顶层 system(带
/// cache_control),每次锚点事件都改写顶层缓存指纹,system 断点与消息断点同时失配。
/// 转 user 后顶层 system 回到纯 system_prompt 稳定段;连续 user 由
/// anthropic_compat::merge_consecutive_users 合并(协议要求严格交替),user 不触发
/// 三元组/首条规则。纯视图:存储态仍 System,压缩定位(has_compressible_messages
/// 的 System 排除)与前端展示语义不变。调用方组装层加入的 system_prompt 不经本
/// 函数(不在 storage),最终视图中它独占 role=System 首条。
fn anchors_to_user(&self, view: Vec<ChatMessage>) -> Vec<ChatMessage> {
if !self.cache_friendly {
return view;
}
view.into_iter()
.map(|mut m| {
if matches!(m.role, MessageRole::System) {
m.role = MessageRole::User;
}
m
})
.collect()
}
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
@@ -668,6 +761,11 @@ impl ContextManager {
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
// 压缩可召回:目标一次性消费(防跨事件残留),路径按本事件重置。压缩标记后原文
// 从 LLM 上下文隔离,此刻原文在手,追加写入会话存档供摘要句柄召回(read_file);
// 失败仅置空路径(调用方降级为无句柄),不影响压缩本身。
let recall = self.compress_recall.take();
self.archive_path = None;
// 压缩边界按对话单元(三元组)对齐:调用方传入的 end(如 len - PROTECT_COUNT)周期性
// 落在单元中间(assistant 头与其 tool 结果之间),只压半个三元组会让保护区以孤立的
// 头或尾开头,成为 sanitize 每请求告警的确定性供给。按全长分组取「完整单元边界中
@@ -694,10 +792,36 @@ impl ContextManager {
// newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() {
self.needs_full_rewrite = true;
if let Some(target) = recall {
match archive::append_archive(&target, &newly_compressed) {
Some(p) => self.archive_path = Some(p),
None => tracing::warn!("[ai] 压缩存档写入失败,本轮摘要无召回句柄(压缩不受影响)"),
}
}
}
newly_compressed
}
/// 注入本次压缩事件的存档目标(压缩可召回,`df-ai-compress-recall` 开时由调用方在
/// `compress_old_messages` 之前调用;开关关不调用)。一次性消费:压缩标记时落盘,
/// 路径经 [`Self::take_archive_path`] 取走。
pub fn set_compress_recall(&mut self, data_dir: &std::path::Path, conv_id: &str) {
self.compress_recall = Some(archive::RecallTarget::new(data_dir, conv_id));
}
/// 取走最近一次压缩成功落盘的存档路径(Some = 本次压缩可召回,调用方据此在摘要尾部
/// 拼句柄行)。take 语义:取后即空,防跨压缩事件残留误报。
pub fn take_archive_path(&mut self) -> Option<std::path::PathBuf> {
self.archive_path.take()
}
/// 注入缓存友好上下文总开关(每请求由 src-tauri 调用方读 KV 后在 `build_for_request`
/// 前调用,持续语义:注入值保持到下次注入)。true = 块状裁剪 + system 锚点 view 层
/// 转 user;false = 两者旁路,行为与旧路径逐字节等价。
pub fn set_cache_friendly(&mut self, enabled: bool) {
self.cache_friendly = enabled;
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
+104 -39
View File
@@ -26,7 +26,7 @@ use crate::provider::{ChatMessage, MessageRole};
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)]
pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
/// 非 CJK 字符(拉丁/数字/符号)→ token 转换系数(默认 0.35,约 3 字符/token)
pub chars_ratio: f32,
/// 每条消息固定开销(role 标记 + 格式)
pub per_message_overhead: u32,
@@ -44,57 +44,83 @@ impl Default for TokenEstimator {
}
}
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
/// CJK(中日韩)字符 token 比率:1 字约 1~1.5 token,取 1.2 略保守。
/// 此前统一按 0.35 计,中文低估 2~3 倍,长中文会话预算门形同虚设
///(估 3 万实 9 万,裁剪不触发直顶 provider 上限)。
const CJK_TOKEN_RATIO: f32 = 1.2;
// 图片 token 地板(base64 与 url 两模式同用,Anthropic 保守 ~1600/图,OpenAI 按 tile)。
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
// 注意单位是 token 不是字符:地板必须在 ×chars_ratio 折扣之后直接加 token,若混进 char_count
// 注意单位是 token 不是字符:地板必须在字符折扣之后直接加 token,若混进字符计数
// 会被 0.35 稀释成 560,保底名存实亡。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
// base64 同用地板(此前按字符 0.35 计):provider 端图片按分辨率/张数计费,与 base64
// 字符串长度无关,25 万字符按 0.35 计 8.7 万 token 是纯高估,致含图消息过度裁剪;
// 两模式语义等价(都是一张图),成本口径单一(DRY)。
const IMAGE_TOKEN_FLOOR: usize = 1600;
/// CJK 字符判定:中日韩文字/假名/谚文/CJK 标点/全角形式。
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F // CJK 标点
| 0x3040..=0x30FF // 日文假名
| 0x3400..=0x4DBF // 表意文字扩展 A
| 0x4E00..=0x9FFF // CJK 基本区
| 0xAC00..=0xD7AF // 韩文谚文
| 0xF900..=0xFAFF // 兼容表意文字
| 0xFF00..=0xFFEF // 全角形式
)
}
/// 分档字符 token 计数:CJK 按 [`CJK_TOKEN_RATIO`],其余按调用方给定比率。
fn weighted_char_tokens(s: &str, plain_ratio: f32) -> u32 {
let (mut cjk, mut plain) = (0usize, 0usize);
for ch in s.chars() {
if is_cjk(ch) { cjk += 1 } else { plain += 1 }
}
(cjk as f32 * CJK_TOKEN_RATIO + plain as f32 * plain_ratio).ceil() as u32
}
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
/// 文本按 CJK/其余分档计数;图片(base64 与 url)统一按 [`IMAGE_TOKEN_FLOOR`] 计,
/// 不随 base64 字符串长度膨胀。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let mut char_count = msg.content.chars().count();
// 939① 估算入账修正:Tool 消息 content 为 namespace 引用时,按 ref 尾段携带的
// 原文字节数入账(字节保守视作字符,中文会 3x 高估——方向安全:宁可高估触发
// 裁剪/压缩,不可漏估致预算门失效,后者曾致 provider 超限 400)。旧格式/已淘汰
// 内容(无 ?len= 尾段)按字面字符保守计,行为同前。
if matches!(msg.role, crate::provider::MessageRole::Tool) {
if let Some(n) = crate::namespace_store::parse_ref_content_len(&msg.content) {
char_count = n;
// namespace 引用(Tool 消息)特殊入账:按 ref 尾段携带的原文字节数保守视作字符
// 并按 plain 比率计(多为代码/JSON 输出);中文引用会低估,方向可接受:ref 体量
// 本就由 namespace 上限闸门控,此处入账只为预算可见性。
let mut content_tokens = if matches!(msg.role, crate::provider::MessageRole::Tool) {
match crate::namespace_store::parse_ref_content_len(&msg.content) {
Some(n) => (n as f32 * self.chars_ratio).ceil() as u32,
None => weighted_char_tokens(&msg.content, self.chars_ratio),
}
}
// url 图地板单独按 token 累加:地板语义是"每图至少 ~1600 token",必须生效于
// ×chars_ratio 折扣之后——若折进 char_count 会被 0.35 稀释成 560,1600 保底失效,
// 含 url 图消息的 history_tokens 低估 → 预算裁剪漏触发 → provider 超限 400。
let mut url_image_floor_tokens: u32 = 0;
} else {
weighted_char_tokens(&msg.content, self.chars_ratio)
};
// 图片地板按 token 直接累加,生效于字符折扣之外(折进字符计数会被 0.35 稀释,
// 1600 保底失效,预算裁剪漏触发 → provider 超限 400)。
let mut image_floor_tokens: u32 = 0;
if let Some(parts) = &msg.parts {
for p in parts {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Text { text } => {
content_tokens = content_tokens
.saturating_add(weighted_char_tokens(text, self.chars_ratio));
}
crate::provider::ContentPart::Image { base64, url, .. } => {
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,按地板直接计 token
if let Some(b) = base64 {
char_count += b.chars().count();
} else if url.is_some() {
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
url_image_floor_tokens = url_image_floor_tokens
.saturating_add(URL_IMAGE_TOKEN_FLOOR as u32);
// base64 与 url 语义等价(都是一张图),同用地板;有 base64 时
// url 为空(构造端二选一),不重复计
if base64.is_some() || url.is_some() {
image_floor_tokens =
image_floor_tokens.saturating_add(IMAGE_TOKEN_FLOOR as u32);
}
}
}
}
}
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + url_image_floor_tokens + self.per_message_overhead;
let mut total = content_tokens + image_floor_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls {
@@ -113,9 +139,9 @@ impl TokenEstimator {
total
}
/// 估算纯文本字符串的 token 数(用于 system prompt)
/// 估算纯文本字符串的 token 数(用于 system prompt),CJK 分档同 [`Self::estimate_message`]
pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
weighted_char_tokens(text, self.chars_ratio)
}
}
@@ -656,6 +682,14 @@ pub struct EvictionUnit {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
pub const PROTECT_COUNT: usize = 6;
/// 块状裁剪目标水位(× available):超预算时一次裁到该水位以下,而非贪心的刚好塞下。
///
/// 块大小 = (1 − T) × available,随模型窗口自适应缩放。两次裁剪之间裁剪边界不动 →
/// 被淘汰集不变 → 淘汰摘要内容不变(关键词提炼确定性)→ 发送视图前缀逐字节稳定,
/// 前缀缓存每轮命中。默认 0.8:块 ≈ 19K tokens,稳定窗口 2–9 轮,估算误差 ±15%
/// 被水位差吸收,不会刚裁完就再触。
pub const TRIM_TARGET_RATIO: f32 = 0.8;
/// 淘汰补偿摘要的硬上限(字符数)。
///
/// build_for_request 超预算裁剪时,把被淘汰文本提炼成一短段 system 摘要插回发送视图
@@ -1755,8 +1789,8 @@ mod tests {
#[test]
fn url_image_token_floor_survives_ratio_discount() {
// url 图地板语义是"每图至少 ~1600 token",必须生效于 ×chars_ratio 折扣之后:
// 若地板折进 char_count 再乘 0.35,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
// 图地板语义是"每图至少 ~1600 token",必须生效于字符折扣之外:
// 若地板折进字符计数再乘比率,1600 被稀释成 ~560,保底失效 → 预算裁剪漏触发。
let est = TokenEstimator::default();
let msg = ChatMessage::user_parts(
"t",
@@ -1764,10 +1798,41 @@ mod tests {
);
let tokens = est.estimate_message(&msg);
assert!(
tokens >= URL_IMAGE_TOKEN_FLOOR as u32,
tokens >= IMAGE_TOKEN_FLOOR as u32,
"url 图消息 token({}) 应 >= 地板 {},稀释 bug 下仅 ~565",
tokens,
URL_IMAGE_TOKEN_FLOOR
IMAGE_TOKEN_FLOOR
);
}
#[test]
fn base64_image_counts_as_floor_not_chars() {
// base64 不按字符计:provider 端图片按张数/分辨率计费,25 万字符按 0.35
// 计 8.7 万 token 是纯高估致过度裁剪。改地板后与图的真实成本同量级。
let est = TokenEstimator::default();
let big_base64 = "iVBORw0KGgoAAAANS".repeat(15000); // ~25.5 万字符
let msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64)],
);
let tokens = est.estimate_message(&msg);
// 期望构成:地板 1600 + "t"(1 plain 字符 ceil 后 1) + 每消息开销 4
assert_eq!(
tokens,
IMAGE_TOKEN_FLOOR as u32 + 1 + est.per_message_overhead,
"base64 图消息应恰为地板+文本+开销,实际 {}",
tokens
);
}
#[test]
fn cjk_chars_cost_more_than_latin() {
// 中文按 1.2 token/字计:同字符数下中文估值须显著高于拉丁,预算门不再低估 2~3 倍
let est = TokenEstimator::default();
let zh = est.estimate_text("预算控制测试"); // 6 CJK 字符
let en = est.estimate_text("abcdef"); // 6 拉丁字符
assert_eq!(zh, 8, "6 CJK 字应计 8 token(6×1.2=7.2 ceil)");
assert_eq!(en, 3, "6 拉丁字符应计 3 token(6×0.35=2.1 ceil)");
assert!(zh > en * 2, "CJK 估值({zh})须显著高于同长度拉丁({en})");
}
}
+82
View File
@@ -74,6 +74,11 @@ pub fn parse_ref_content_len(full_path: &str) -> Option<usize> {
.filter(|_| full_path.contains("?len="))
}
/// 从引用路径提取工具名段(懒回填重建条目元数据用)。
pub fn parse_ref_tool_name(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)?.split('/').next()
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
@@ -280,6 +285,40 @@ impl NamespaceStore {
))
}
/// 跨重启懒回填:store 清空后旧引用首次展开未命中时,从会话消息原文找回并重建条目。
/// DB 落库约定存展开后原文,引用 key 即入库时的原文内容哈希(SipHash 确定性,跨进程
/// 一致),哈希精确匹配即可靠对应单条原文,无需按序重建;原文不在消息里的引用(真淘汰)
/// 不进返回值,调用方维持淘汰占位语义。
/// 入库走 store 既有容量语义(超容量拒绝只影响后续轮次命中,本轮视图仍用返回的原文)。
/// 返回 引用路径 → 原文。
pub fn backfill_from_messages(
&mut self,
missed_refs: &[String],
message_contents: &[String],
) -> HashMap<String, String> {
let mut recovered: HashMap<String, String> = HashMap::new();
if missed_refs.is_empty() {
return recovered;
}
let wanted: HashMap<&str, &String> = missed_refs.iter()
.filter_map(|p| parse_namespace_key(p).map(|k| (k, p)))
.collect();
for content in message_contents {
let key = simple_hash(content);
if let Some(path) = wanted.get(key.as_str()) {
// 同一引用可能在多条历史消息出现,找回一次即可
if recovered.contains_key(path.as_str()) {
continue;
}
if let Some(tool) = parse_ref_tool_name(path) {
self.store(tool, content);
}
recovered.insert((*path).clone(), content.clone());
}
}
recovered
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
@@ -563,4 +602,47 @@ mod tests {
// 旧格式/无尾段 → None(调用方保守按字面计)
assert_eq!(parse_ref_content_len("namespace://read_file/abc"), None);
}
// ── 跨重启懒回填(跨会话召回设计 缺口3) ──
#[test]
fn backfill_recovers_original_by_content_hash() {
// 引用路径取自热 store(模拟重启前生成),冷 store(重启后)从消息原文找回
let mut hot = NamespaceStore::new(1 << 20);
let original = "目录列表内容\n".repeat(100);
let path = hot.store("list_directory", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&[original.clone(), "无关消息".to_string()],
);
assert_eq!(recovered.get(&path).map(String::as_str), Some(original.as_str()));
assert_eq!(cold.read_only(&path).map(str::to_owned), Some(original), "回填后条目应可读");
}
#[test]
fn backfill_without_original_stays_empty() {
let mut hot = NamespaceStore::new(1 << 20);
let path = hot.store("read_file", &"真原文".repeat(500));
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(
&[path.clone()],
&["无关A".to_string(), "无关B".to_string()],
);
assert!(recovered.is_empty(), "无对应原文不应误填");
assert_eq!(cold.read_only(&path), None);
assert!(cold.is_empty());
}
#[test]
fn backfill_duplicate_refs_recover_once() {
// 同一引用出现在多条历史消息:找回一次,store 只建一条
let mut hot = NamespaceStore::new(1 << 20);
let original = "z".repeat(3000);
let path = hot.store("grep", &original);
let mut cold = NamespaceStore::default();
let recovered = cold.backfill_from_messages(&[path.clone(), path.clone()], &[original]);
assert_eq!(recovered.len(), 1);
assert_eq!(cold.len(), 1);
}
}
+3 -1
View File
@@ -68,7 +68,9 @@ fn non_read_file_large_result_also_gets_handle() {
let _ = std::fs::remove_dir_all(&base);
tool_offload::init_base(base.clone());
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.rs:{i}\n")).collect();
// 中性行夹具:不得含 error/panic/.rs: 等错误行信号——错误行保留策略会全量保留
// 命中行,用信号行做夹具会让「应被压缩」断言先天不成立(测的是普通大结果外置)
let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.txt:{i}\n")).collect();
assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)");
let out = extract_key_info(&body, "grep");
assert!(out.len() < body.len(), "应被压缩");
@@ -114,6 +114,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
loop_state: row.get("loop_state").unwrap_or(None),
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
// working_context_json 列老库经 v53 迁移补建。unwrap_or(None) 兜底(NULL=无 WorkingContext)。
working_context_json: row.get("working_context_json").unwrap_or(None),
})
}
@@ -191,14 +193,14 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status, working_context_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json, rec.loop_state, rec.status
rec.boundary_json, rec.loop_state, rec.status, rec.working_context_json
],
)
},
@@ -207,13 +209,16 @@ impl_repo!(
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// 生命周期只经 set_loop_state / set_status 单点写。
// working_context_json 在 SET 列(V53):与 pinned_goals/boundary_json 同为 save 每轮
// 随行写的内存真相源快照,读改写窗口内无独立写入方,不存在 lost-update 面。
conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, working_context_json = ?14 WHERE id = ?15",
params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json,
rec.working_context_json, rec.id
],
)
}
@@ -1186,6 +1191,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.expect("insert");
@@ -1225,6 +1231,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.expect("insert");
@@ -1236,4 +1243,59 @@ mod tests {
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
}
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
#[tokio::test]
async fn working_context_json_roundtrip_and_legacy_null() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let wc_json = r#"{"goals":[{"id":"t1","text":"修登录bug","status":"Active"}],"decisions":[],"unresolved":["端口未定"],"current_step":null,"version":{"goals_changed_at":1,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":1}}"#;
let mut rec = AiConversationRecord {
id: "c1".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(wc_json.into()),
};
repo.insert(rec.clone()).await.expect("insert");
let got = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(got.working_context_json.as_deref(), Some(wc_json), "insert 后应原样读回");
// update_full 全量写(save_conversation 路径)后列随行更新
rec.title = Some("t".into());
rec.working_context_json = Some(r#"{"goals":[],"decisions":[],"unresolved":[],"current_step":null,"version":{"goals_changed_at":0,"decisions_changed_at":0,"unresolved_changed_at":0,"step_changed_at":0,"current_turn":0}}"#.into());
repo.update_full(&rec).await.expect("update");
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
assert!(got2.working_context_json.as_deref().unwrap_or_default().contains(r#""goals":[]"#), "update_full 后应写新快照");
// 老库兼容:绕过 repo 直插不带该列的行(模拟 V53 前老数据)→ 读回 None
{
let conn = db.conn();
let g = conn.lock().await;
g.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, status) \
VALUES ('legacy', '[]', '0', '0', 'active')",
[],
)
.expect("raw insert legacy row");
}
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
assert!(legacy.working_context_json.is_none(), "老库行应为 None(无 WorkingContext)");
}
}
+169
View File
@@ -1178,6 +1178,74 @@ impl KnowledgeRepo {
.await
.map_err(storage_err)?
}
/// 知识路标读侧(devflow.md 投影通道):已发布且(人工验证 或 复用达标)的条目选摘。
///
/// 与检索族(search/search_keywords)的差别:路标无查询词,是「库里有什么」的静态选摘,
/// 供 system prompt 路标段铺开让 agent 知晓库藏、想起检索。入选门槛 = published 且
/// (verified = 1 OR reuse_count >= 3);排序 = 复用降序(唯一客观信号,对齐 search/top_used),
/// 同复用按 created_at 降序定稳定次序。verified 只作入选不作排序(复用是更强的实战信号)。
///
/// 两档拼接:项目档(source_project = project_id,上限 project_limit)在前——会话归属
/// 项目的知识最相关;跨项目补充档(非本项目含无归属条目,上限 global_limit)在后。
/// 两档 WHERE 互斥(= pid vs IS NULL/≠ pid),结果天然无重复。
/// project_id = None → 仅补充档(会话未解析出归属项目,项目档无从谈起)。
pub async fn list_waymark(
&self,
project_id: Option<&str>,
project_limit: usize,
global_limit: usize,
) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let project_id = project_id.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 入选门槛与排序两档共用(verified 在 SQLite 存 0/1;复用达标压线 3 即入选)
let qualify = "status = 'published' AND (verified = 1 OR reuse_count >= 3)";
let order = " ORDER BY reuse_count DESC, created_at DESC LIMIT ";
let mut results = Vec::new();
// 项目档:会话归属项目内的合格条目(复用降序,上限 project_limit)
if let Some(pid) = &project_id {
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify} AND source_project = ?1{order}?2"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![pid, project_limit as i64], knowledge_from_row)
.map_err(storage_err)?;
for r in rows {
results.push(r.map_err(storage_err)?);
}
}
// 跨项目补充档:非本项目(含无归属全局条目)按复用降序补足 global_limit;
// WHERE 与项目档互斥 → 天然去重。project_id = None 时无排他条件,即全量合格条目。
// LIMIT 占位符编号随排他条件有无而变(?1 空档排他时归 LIMIT,有排他时排他占 ?1、LIMIT 占 ?2)
let exclude = if project_id.is_some() {
" AND (source_project IS NULL OR source_project != ?1)"
} else {
""
};
let lim_n = if project_id.is_some() { 2 } else { 1 };
let sql = format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify}{exclude}{order}?{lim_n}");
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = match &project_id {
Some(pid) => stmt
.query_map(params![pid, global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
};
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
impl_repo!(
@@ -1715,6 +1783,107 @@ mod tests {
assert_eq!(hits[0].id, "k1");
}
// ---------- 知识路标读侧(list_waymark,devflow.md 投影通道) ----------
/// 入选门槛:published 且(verified 或 复用达标);candidate/archived/复用不足均淘汰
#[tokio::test]
async fn waymark_qualifies_verified_or_reused_published_only() {
let repo = setup_repo().await;
// verified=true 复用 0 → 入选(验证豁免复用门槛)
let mut v = krec("v", "已验证条目", "...", "lesson", "published", None, 0);
v.verified = true;
repo.insert(v).await.unwrap();
// 未验证复用 3 → 入选(压线达标)
repo.insert(krec("r3", "复用达标条目", "...", "lesson", "published", None, 3))
.await
.unwrap();
// 未验证复用 2 → 淘汰
repo.insert(krec("r2", "复用不足条目", "...", "lesson", "published", None, 2))
.await
.unwrap();
// 复用再高,状态非 published(candidate)→ 淘汰
repo.insert(krec("cand", "候选未发布", "...", "lesson", "candidate", None, 99))
.await
.unwrap();
// archived → 淘汰
repo.insert(krec("arch", "已归档", "...", "lesson", "archived", None, 99))
.await
.unwrap();
let hits = repo.list_waymark(None, 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids.len(), 2, "只应入选 已验证 + 复用达标 两条");
assert!(ids.contains(&"v") && ids.contains(&"r3"));
}
/// 两档序:项目档在前(复用降序,verified 豁免复用但仍按复用参与排序),
/// 跨项目补充档在后(复用降序);异项目高复用压不过同项目低复用(档位优先于复用)
#[tokio::test]
async fn waymark_project_tier_first_global_supplement_after() {
let repo = setup_repo().await;
repo.insert(krec("p9", "同项目复用9", "...", "lesson", "published", None, 9))
.await
.unwrap();
repo.insert(krec("p5", "同项目复用5", "...", "lesson", "published", None, 5))
.await
.unwrap();
// 同项目 verified 但零复用:入选项目档,排序落在复用条目之后
let mut pv = krec("pv", "同项目已验证零复用", "...", "lesson", "published", None, 0);
pv.verified = true;
repo.insert(pv).await.unwrap();
// 异项目高复用 → 补充档
let mut f = krec("f99", "异项目复用99", "...", "lesson", "published", None, 99);
f.source_project = Some("proj-2".to_string());
repo.insert(f).await.unwrap();
// 无归属全局条目 → 补充档
let mut g = krec("g10", "全局复用10", "...", "lesson", "published", None, 10);
g.source_project = None;
repo.insert(g).await.unwrap();
let hits = repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p9", "p5", "pv", "f99", "g10"],
"项目档在前(复用降序),补充档在后(复用降序:异项目99 > 全局10)"
);
// 两档 WHERE 互斥的去重属性:任一条目恰好出现一次
assert_eq!(ids.iter().filter(|i| **i == "p9").count(), 1, "同项目条目不得因两档重复出现");
}
/// 各档上限独立生效:项目档取复用最高的 project_limit 条,补充档另计 global_limit 条
/// (条目复用全部压线达标,确保裁剪只来自 LIMIT 而非入选门槛)
#[tokio::test]
async fn waymark_limits_cap_tiers_independently() {
let repo = setup_repo().await;
for reuse in 3..=5 {
repo.insert(krec(&format!("p{reuse}"), "同项目", "...", "lesson", "published", None, reuse))
.await
.unwrap();
}
for reuse in 3..=5 {
let mut g = krec(&format!("g{reuse}"), "全局", "...", "lesson", "published", None, reuse);
g.source_project = None;
repo.insert(g).await.unwrap();
}
let hits = repo.list_waymark(Some("proj-1"), 2, 2).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p5", "p4", "g5", "g4"],
"项目档取复用最高的 2 条,补充档独立取全局最高的 2 条"
);
}
/// 空库 → 空结果(路标子块整体缺席的前提,投影侧据此零追加)
#[tokio::test]
async fn waymark_empty_db_returns_empty() {
let repo = setup_repo().await;
assert!(repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap().is_empty());
assert!(repo.list_waymark(None, 20, 10).await.unwrap().is_empty());
}
// ---------- 复用即信任(auto_publish_if_reused) ----------
#[tokio::test]
+86 -2
View File
@@ -265,6 +265,12 @@ impl AiMessageRepo {
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
///
/// created_at 保出生时间(2026-10-17 修复批量污染):created_at 语义 = 消息首次入库时间,
/// 全量重写只该变内容不变出生时间。调用方(save_conversation)对整批 records 统一盖
/// 对话级 created_at(老对话 None 时 now 兜底),直插会把全对话消息 created_at 抹成同一秒。
/// 故 DELETE 前先取存量 `id → created_at` 映射,重插时同 id 行回填原值;新 id(真新消息)
/// 保留入参值。ai_messages 表无 updated_at 列,内容变更无单独时间戳(不在此列语义内)。
///
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
@@ -274,7 +280,7 @@ impl AiMessageRepo {
pub async fn replace_conversation(
&self,
conv_id: &str,
records: Vec<AiMessageRecord>,
mut records: Vec<AiMessageRecord>,
) -> Result<()> {
let conn = self.conn.clone();
let conv_id = conv_id.to_owned();
@@ -282,7 +288,29 @@ impl AiMessageRepo {
let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?;
{
// 先删该 conv 全部旧行(全量重写语义)
// 先取存量 id → created_at 映射(DELETE 前留证,重插回填保出生时间)
{
let mut stmt = tx
.prepare("SELECT id, created_at FROM ai_messages WHERE conversation_id = ?1")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![conv_id], |r| {
Ok((r.get::<_, String>("id")?, r.get::<_, String>("created_at")?))
})
.map_err(storage_err)?;
let mut original: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for r in rows {
let (id, created_at) = r.map_err(storage_err)?;
original.insert(id, created_at);
}
for rec in &mut records {
if let Some(orig) = original.get(&rec.id) {
rec.created_at = orig.clone();
}
}
}
// 再删该 conv 全部旧行(全量重写语义)
tx.execute(
"DELETE FROM ai_messages WHERE conversation_id = ?1",
params![conv_id],
@@ -693,6 +721,62 @@ mod tests {
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
}
/// replace_conversation 保 created_at 出生时间(2026-10-17 批量污染回归):
/// 调用方对整批统一盖一个 created_at(如对话级时间或 now),重写后既有 id 行
/// 的 created_at 必须逐行保留原入库值,不得被统一时间抹平;新 id 行保留入参值。
#[tokio::test]
async fn replace_conversation_preserves_created_at() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
// 预置两行,created_at 各异(模拟真实逐条入库的出生时间)
let mut m0 = mk_msg("m0", "c", 0, "user", "v1");
m0.created_at = "2026-10-01T10:00:00.000".into();
let mut m1 = mk_msg("m1", "c", 1, "assistant", "v1");
m1.created_at = "2026-10-02T11:30:00.000".into();
repo.insert_batch(vec![m0, m1]).await.expect("insert");
// 重写:既有 id 内容变了、created_at 被调用方统一盖成同一时间;
// 另带一条新 id 消息(真新消息,该保留入参时间)。
let uniform_ts = "2026-10-17T21:25:01.000";
let rewrite = |id: &str, seq: i64, role: &str, content: &str| {
let mut m = mk_msg(id, "c", seq, role, content);
m.created_at = uniform_ts.into();
m
};
repo.replace_conversation(
"c",
vec![
rewrite("m0", 0, "user", "v2-改写内容"),
rewrite("m1", 1, "assistant", "v2-改写内容"),
rewrite("m2_new", 2, "user", "新消息"),
],
)
.await
.expect("replace");
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 3);
// 逐行断言:既有 id 的 created_at 保持原值,内容确实变了
assert_eq!(got[0].id, "m0");
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "既有行 created_at 不得被重写抹平");
assert_eq!(got[0].content, "v2-改写内容");
assert_eq!(got[1].id, "m1");
assert_eq!(got[1].created_at, "2026-10-02T11:30:00.000", "既有行 created_at 不得被重写抹平");
assert_eq!(got[1].content, "v2-改写内容");
// 新 id(首次入库)保留入参 created_at
assert_eq!(got[2].id, "m2_new");
assert_eq!(got[2].created_at, uniform_ts, "新消息保留入参 created_at");
// 二次重写再验:出生时间已固化,连续重写不漂移
repo.replace_conversation("c", vec![rewrite("m0", 0, "user", "v3")])
.await
.expect("replace again");
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 1);
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "连续重写 created_at 不漂移");
}
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
#[tokio::test]
+80 -16
View File
@@ -87,9 +87,9 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
///
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at):
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。
/// `last_active_at` = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
/// projects.updated_at):任务活跃为主信号(任务面是真实工作发生地),事件表已停写仅保留维度,
/// updated_at 兜底。供前端「最近活跃排序」展示与 system prompt 项目清单排序。
///
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
#[derive(Debug, Clone, serde::Serialize)]
@@ -243,29 +243,35 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间,
/// COALESCE 回退 updated_at)。
/// 列出未删除项目,**按「最近活跃」排序**(三源取大:任务活跃为主信号)。
///
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。
/// 业务语义:last_active_at = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
/// projects.updated_at)。**任务活跃为主信号**——ai-chat 会话/任务面是真实工作发生地,
/// 任务停更即项目停更;project_events 已停写(现网空表,保留维度向后兼容);
/// projects.updated_at 仅元信息修改(改 description/path 也会刷新,不反映真实业务活跃),
/// 仅作兜底。无任务无事件的项目回退 updated_at,排序列统一可比。
///
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。
/// 子查询而非 JOIN:tasks/project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,
/// 标量子查询每行一次聚合,语义清晰无笛卡尔积风险(SQLite 多参 MAX 为标量取大,
/// IFNULL 兜空子查询 NULL)。单用户桌面应用项目数小,无性能压力。
///
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示
/// 与 system prompt「当前项目」清单(build_context_lists)按活跃度排序。
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at,
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
// 三源取大(SQLite 多参 MAX=标量取大,IFNULL 兜空子查询):任务活跃为主信号,
// 事件表已停写仅保留维度,updated_at 兜底。ORDER BY 该列 DESC,同时间 created_at DESC
// 兜底稳定。
let mut stmt = guard
.prepare(
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
updated_at) AS last_active_at \
MAX(\
IFNULL((SELECT MAX(updated_at) FROM tasks WHERE project_id = projects.id AND deleted_at IS NULL), 0), \
IFNULL((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), 0), \
updated_at\
) AS last_active_at \
FROM projects WHERE deleted_at IS NULL AND archived = 0 \
ORDER BY last_active_at DESC, created_at DESC",
)
@@ -840,4 +846,62 @@ mod tests {
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
#[tokio::test]
async fn list_active_with_activity_orders_by_task_activity_over_created_at() {
// 回归(事故形状):项目 A created_at 较晚(创建序排前)但无任务/停更;项目 B
// created_at 较早但有一条 tasks.updated_at 最新 → 活跃序 B 排 A 前
// (任务活跃为主信号,治创建序误导 AI 把停摆项目当最高优先锚点)。
use crate::crud::task_repo::TaskRepo;
use crate::models::TaskRecord;
use df_types::types::TaskStatus;
let db = Database::open_in_memory().await.expect("open_in_memory");
let projects = ProjectRepo::new(&db);
let tasks = TaskRepo::new(&db);
// B 先建(早),A 后建(晚)——创建序会排 A 前
let mut early = prec("pb", "早建但活跃");
early.created_at = "1700000000000".to_string();
early.updated_at = "1700000000000".to_string();
let mut late = prec("pa", "晚建但停更");
late.created_at = "1700000010000".to_string();
late.updated_at = "1700000010000".to_string();
projects.insert(early).await.unwrap();
projects.insert(late).await.unwrap();
// B 挂一条最新任务;A 无任务 → B 活跃时间取任务 updated_at,A 回退自身 updated_at
let task = TaskRecord {
id: "tb".to_string(),
project_id: "pb".to_string(),
title: "B 的任务".to_string(),
description: String::new(),
status: TaskStatus::InProgress,
priority: 0,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
module_id: None,
archived: false,
created_at: "1700000000000".to_string(),
updated_at: "1790000000000".to_string(),
};
tasks.insert(task).await.unwrap();
let listed = projects.list_active_with_activity().await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].record.id, "pb", "任务活跃最新的早建项目应排前");
assert_eq!(listed[1].record.id, "pa", "无任务的晚建项目应排后");
assert_eq!(
listed[0].last_active_at, "1790000000000",
"活跃时间应取该项目任务 updated_at 最新值"
);
}
}
+74 -2
View File
@@ -65,7 +65,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
let steps: [(i32, fn(&Connection) -> Result<()>); 52] = [
// V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -125,6 +127,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(51, migrate_v51),
// V52 = ai_messages 加 source 列(#958 消息溯源:飞书等外部渠道原始消息 ID)。
(52, migrate_v52),
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
(53, migrate_v53),
];
for (version, migrate_fn) in steps {
@@ -1532,7 +1536,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
/// 调度器启动时 reset_stale_sending 复位为 pending。
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
pub const LATEST_SCHEMA_VERSION: i32 = 52;
pub const LATEST_SCHEMA_VERSION: i32 = 53;
fn migrate_v51(conn: &Connection) -> Result<()> {
if !table_exists(conn, "ai_outbox") {
@@ -1584,6 +1588,27 @@ fn migrate_v52(conn: &Connection) -> Result<()> {
Ok(())
}
/// V53: WorkingContext 持久化 — ai_conversations 加 working_context_json TEXT 列。
///
/// 跨会话召回 L4(重启不失忆):WorkingContext(自动目标/决策/未解项/当前步骤/脏标记版本)
/// 此前纯内存,重启后压缩重锚定回填的决策与未解项全丢,恢复的会话半失忆续跑。本列随 save
/// 每轮随行写、会话载入与 parked 恢复读还原;NULL=无上下文(老会话/全空),行为同旧。
/// 目标快照裁决语义另批,本列只做存储载体。
/// - **幂等**:column_exists 守卫(同 V44/V45/V49 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN working_context_json`(SQLite 3.35+)。
fn migrate_v53(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "working_context_json") {
conn.execute(
"ALTER TABLE ai_conversations ADD COLUMN working_context_json TEXT",
[],
)?;
tracing::info!("v53: 补建 ai_conversations.working_context_json 列(WorkingContext 持久化)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [53])?;
tracing::info!("迁移 v53 完成: ai_conversations 加 working_context_json 列");
Ok(())
}
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
/// prompt_tokens+completion_tokens 求真实总量。
///
@@ -2951,4 +2976,51 @@ mod tests {
"逐条相加,缺字段按 0"
);
}
/// V53 幂等:最小老库补建 working_context_json 列;重跑(含版本号回退模拟崩溃断点)
/// 不炸不报 duplicate column;列可写可读(NULL 语义=无 WorkingContext)。
#[test]
fn v53_working_context_json_idempotent() {
let conn = setup_legacy_db();
assert!(
!column_exists(&conn, "ai_conversations", "working_context_json"),
"迁移前应无 working_context_json 列"
);
migrate_v53(&conn).expect("v53 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "working_context_json"), "应有 working_context_json 列");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 53, "应写入版本号 53");
// 模拟崩溃断点:版本号回退但列已建 → 重跑走 column_exists 短路
conn.execute("DELETE FROM schema_version WHERE version = 53", []).unwrap();
migrate_v53(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
let v2: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v2, 53, "重跑后版本号恢复 53");
// 列可写可读;老会话行(不带该列插入)读回 NULL
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, working_context_json) \
VALUES ('c1', '[]', '0', '0', '{\"goals\":[],\"decisions\":[]}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c2', '[]', '0', '0')",
[],
)
.unwrap();
let got: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "working_context_json 应可写可读");
let legacy: Option<String> = conn
.query_row("SELECT working_context_json FROM ai_conversations WHERE id = 'c2'", [], |r| r.get(0))
.unwrap();
assert!(legacy.is_none(), "老会话行应为 NULL(无 WorkingContext)");
}
}
+5
View File
@@ -400,6 +400,11 @@ pub struct AiConversationRecord {
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String,
/// WorkingContext 持久化(V53,跨会话召回 L4 重启不失忆):WorkingContext JSON 序列化,
/// 存储层按不透明 TEXT 存取(结构归 df-ai)。NULL=无上下文(老会话/全空),行为同旧;
/// save 随行写/会话载入与 parked 恢复读还原,随会话级联(对齐 boundary_json 模式)。
#[serde(default)]
pub working_context_json: Option<String>,
}
/// AI 工具执行审计记录
@@ -0,0 +1,48 @@
# ai-chat 上下文管理与信息组织·完善设计(2026-10-18)
> 用户令:ai-chat agent 上下文管理/信息组织开发完善;回复别太口水话(2026-10-18)。
> 证据输入(问题全量论证在彼,此处只引编号不重复):`temp/深查-aichat对话质量复测-2026-10-17.md`(E1-E13)、`temp/深查-aichat信息UX-2026-10-17.md`(F1-F6)。
> 现状判定:10-04 三大病(上下文失控/反复压缩/终局缺失)基本治愈;残量集中在**恢复链污染、终局态半闭环、存量脏数据、信息可达性、输出纪律**五个结构性面。
## 一、信息流模型与缺口定位
链路:`发起(直发/inject) → 目标提取(pinned_goals) → 上下文组装(system+摘要+近期) → 执行循环 → 压缩(view/cache) → 恢复(recovery) → 投影(轮桶/轨迹卡) → 呈现(桌面/飞书)`
| 缺口 | 根因(结构性,非散点) | 归并的散点 |
|---|---|---|
| **G-A 恢复链身份未建模** | 恢复消息无类型标记,兼具「系统信号」与「用户输入」双重身份,被目标提取源再次吸收 | E1 套娃、E3 二次恢复、看板 649 行恢复目标完整性守卫 |
| **G-B 终局态半闭环** | boundary_json/loop_state 字段已建、写入闭环未建(填充率 0) | P2-5、E3 竞态窗口 |
| **G-C 写入不保数据语义** | 写入方各自为政,全量重写/双写不保留原义 | E11 created_at 重写污染 1750 条、E5 存量目标不迁移、E12 双写 |
| **G-D 信息可达性瓶颈** | 呈现层硬上限+折叠体内不可见 | F4-1 会话 50 上限(实测 115 条已击穿)、F1-3+F3-2 注入降级藏审批、F6-1 |
| **G-E 输出纪律缺失** | 聚焦准则管「答什么」,不管「怎么答」——主提示词无回复风格条款 | 用户口水话反馈(本次令);摘要提示词已有该纪律可对齐 |
| **G-F 阴性结论无守卫** | 工具 0 命中被当事实断言,无复核引导 | E6 假阴性 3 轮错判链 |
## 二、修复批次(领单视角,M1 先行)
### M1 · P1 上下文正确性
1. **E1+E3 恢复链治本(G-A)**:恢复消息加 source 标记(与看板 961② inject 标记同基建,复用 V52 source 字段);build_recovery_message 过滤 `[系统恢复]` 前缀目标;目标写入侧拒收同前缀。防二次恢复:快照全部目标=completed 时短路,或恢复后 N 分钟窗口禁重复。
2. **E11 created_at 保义(G-C)**:message_repo.rs:285 全量重写保留原 created_at(records 读取带出原值回写)。
3. **E6 阴性存疑(G-F)**:search_files/grep 0 命中时结果强制附「阴性存疑,建议复核路径/glob」标记(seq 1549 hint 雏形转正)。
4. **E4 跑题换装验证**:三源活跃度排序已在树(第五十三段),换装后重放 6a5a3e04 seq1802 场景验证。
### M2 · P2 上下文域
5. **E5 存量目标一次性迁移(G-C)**:active 目标命中「执行/搜索/查看+工具参数」形态或 `[系统恢复]`/`[补充指令]` 前缀 → 降级为非目标或重取首条任务消息。
6. **E2 压缩降级透出**:压缩失败兜底时前端状态条「上下文已降级」,禁静默。
7. **E13 超时连败收敛**:同命令超时连败 N 次转「换方案/问用户」分支(与看板 B5d 退避策略同族,合并实施)。
8. **P2-5 终局闭环(G-B)**:停靠/终局时填充 boundary_json/loop_state,不只落 status。
### M3 · P2/P3 信息组织域
9. **F4-1 会话可达性(G-D)**:列表 offset 游标分页+侧栏「加载更多」;搜索走后端 LIKE(title+消息内容)。
10. **F5-1+F5-2 飞书审批闭环(同根一改双收)**:决议词表归一(trim/剥标点/同义词,复用 normalize_halfwidth)+ 订阅 AiToolCallCompleted 推终态卡;pending 为空时决议回「已失效」提示而非静默放行。
11. **F1-3+F3-2 折叠体可达(G-D,同根)**:executing 判定改「桶内存在 running/pending 工具」;scrollToFirstPending 前按 data-trace-key 预展开含 pending 的轨迹卡。
### M4 · 输出纪律+UX 小批
12. **G-E 提示词输出纪律**:system_prompt_parts 行为准则追加「回复风格」段——结论先行直接给动作/结果;省略复述用户问题、客套开场、总结性收尾;要点优先于段落;用户没问的不展开。中英双语同步+测试断言(对齐 prompt.rs 既有测试范式)。注意别过度约束:需要解释时照常解释,反的是空话不是内容。
13. **UX 确定性小批**:F1-1 i18n 补键 / F1-2+F3-1 主题 token 化 / F2-1 aria-label / F4-2 loaded 标志 / F6-2+F6-3 Esc 关闭 / 看板尾部队列带图项标记(10-18 残留)。
## 三、验收口径
- **复测脚本复跑**(复用 `temp/复测-aichat质量-1017/` 脚本,timestamp 列口径):恢复消息套娃=0、created_at 污染增量=0、boundary_json 填充率>0(新停靠会话 100%)。
- **可达性**:115 条活跃会话全量可从侧栏到达;审批卡在任何折叠形态下 scrollToFirstPending 均可见。
- **输出纪律**:3 个真实会话抽查,开场复述/空收尾/无信息客套=0;结论仍在首屏。
- 每批销账附验收命令输出(G4 惯例)。
@@ -0,0 +1,53 @@
# 上下文管理对标 u-claw 与大批量并行差距分析(2026-10-19)
> 用户令(2026-10-18 飞书):①大批量 AI 任务调度/多会话多任务并行是未来常态核心场景;②战略目标=DevFlow 部署 Linux 替代 u-claw+Claude Code(CC 效率不够高、资源消耗太大);③第一门槛=上下文管理三命题:丢记忆、历史信息召回、高效利用 token;④方法=学习 u-claw 历史提交记录补 devflow 场景不足。
## 一、u-claw 上下文管理能力画像(源自主仓 6 条相关提交,核心是 86e9216)
| # | 能力 | u-claw 实现 | 要点 |
|---|---|---|---|
| 1 | **token 水位判据** | 140K≈200K 窗口 70% 主判据(可配),体积判据降兜底 | 治「换代过早」:350KB 体积≈真实水位 20-35%,体积判据致 23 代/日→水位判据 <5/日 |
| 2 | **usage 真值采集** | 读流解析 message.usage(input+cache_read+cache_creation),失败静默 0 | 水位判据的数据底座 |
| 3 | **大内容外置纪律** | system prompt 处理规则:大结论写盘 out/,回复只给路径+3 行结论 | 治上下文滚雪球(attach/ 86 文件 25MB 实锤) |
| 4 | **换代通知推送** | rollover 等会话层事件经渠道真推送(非仅日志) | 用户对会话滚动有感知 |
| 5 | **/history 归档检索** | 无参列最近 10 代,关键词 grep summary | 跨代记忆召回入口 |
| 6 | **归档检索指引** | sopHint 注明 jsonl 字段与 grep→读 summary 操作路径 | 让 AI 自己会查历史 |
另:0a4f8dd 重上下文估算(防图片堆积)、a3f28dc 群聊上下文增量注入(按决策价值治理进度卡)。
## 二、devflow 现状对照(三命题 × 已治/缺口)
### 命题 1:丢记忆
- ✅ 已治:目标语义重构(用户任务原文入 pinned_goals)、恢复链快照([系统恢复]/[补充指令] 产物双侧过滤,10-18 批)、压缩摘要四段式(意图/决策/文件/约束+失败事实保留)、E2 降级透出(10-18)
- ❌ 缺口:**boundary/loop_state 填充率 0**(终局态建模半闭环)——「代」的显式语义缺失,会话边界靠消息流推断
### 命题 2:历史信息召回
- ✅ 已治:单会话内压缩可召回(archive jsonl+召回句柄行)、知识路标投影进系统提示词
- ❌ 缺口:**跨会话检索无入口**(u-claw /history 等价物缺失)——「上次那个任务怎么弄的」只能靠人翻侧栏;消息全文搜索未做(F4-1 只做了标题+列表分页)
### 命题 3:高效利用 token
- ✅ 已治:压缩 memoization+决策冻结(10-04 病②治愈)、尾部追加摘要保前缀缓存、E13 超时连败收敛
- ❌ 缺口:
- **usage 真值采集未落地**(6 月 P1 敲定至今暂缓;cache_hit≈0% 说明 cache 口径有列无真值)——水位判据缺数据底座
- **压缩触发判据待查**:压缩/请求比 4.8 偏频,疑体积/轮次判据过敏感(对标 u-claw 病灶同型)
- **大内容外置纪律缺失**:工具输出有 truncate,但「大结论写盘+回复给路径」没有
### 大批量并行新命题(u-claw 亦未解,devflow 需原创)
- 多会话同时压缩互不感知(各自预算独立,全局 token 消耗无视图)
- 并行任务的信息投影(侧栏/仪表盘聚合 N 个会话的进行态)
- 调度面:优先级/抢占/停靠恢复已有底座(TaskBoundary/parked),缺**全局调度器**视角
## 三、补差优先级(P0→P2)
| 级 | 项 | 移植来源 | 落点 |
|---|---|---|---|
| **P0** | usage 真值采集(stream_options.include_usage 三口径) | u-claw #2 | 6 月 P1 设计直接复活;先有数据再谈判据 |
| **P0** | 压缩触发改 token 水位主判据(≈窗口 70%,体积兜底) | u-claw #1 | maybe_auto_compress 判据重构,依赖上项 |
| **P1** | 跨会话归档检索:/history 等价(关键词搜消息全文+摘要) | u-claw #5/#6 | ai_messages 全文索引(FTS5 或 LIKE 起步);检索指令+AI 检索指引 |
| **P1** | boundary/loop_state 写入闭环(代语义显式化) | — | 停靠/终局时填充;换代通知推送(u-claw #4 同款) |
| **P2** | 大内容外置纪律(system prompt 处理规则+out/ 目录约定) | u-claw #3 | prompt.rs 处理规则段+workspace 目录约定 |
| **P2** | 并行资源治理:全局 token 预算视图+多会话压缩协防 | 原创 | 调度器域,与 Linux 化路线(看板 10-18 远程容器方向)合流设计 |
## 四、与 Linux 化战略的衔接
上下文管理是第一门槛(用户拍板),Linux 化是最终形态。依赖链:**usage 真值 → 水位判据 → 代语义+归档检索 → 并行调度治理 → server 化无头运行**。前三步在现有 Tauri 架构内即可推进(P0 两项纯 Rust 域),后两步与远程容器/df-tunnel 线合流。建议节奏:P0 两项作下一批(devflow agent 领单),P1 随后,P2 与 Linux 化立项合并设计。
@@ -0,0 +1,238 @@
# 压缩裁剪缓存友好化 — 设计
> 2026-10-11 · 只读勘察产物
> 主题:上下文压缩与裁剪以破坏 provider 前缀缓存为代价 → 在保住预算安全的前提下最大化缓存命中
> 行号快照:2026-10-11 工作区(context/mod.rs 与 context_helpers.rs 有并行代理在改,实施前须对齐最新行号)
---
## 〇、文档定位与既有工作衔接
本文档是「上下文分层组装器-落地方案-2026-10-10.md」病灶④(裁剪后前缀缓存整体失配)的**机制细化**,并新增覆盖压缩链路的一次压尽问题。该方案已给出验收口径:「稳态下请求前缀只追加、不位移;裁剪以批量滞后发生」——本文档给出达成该口径的具体算法、参数推理与断点布点。两文档不冲突:分层组装器治①(真值 token)②(O(n²))③(预算随窗口)⑤(召回),本文档专治④+压缩,可与 P2.1/P2.2 并行实施(见§八协调注)。
另:看板 AC-EFF-C1-2「每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批)」与「警示消息打断前缀缓存」两登记项,均由本文档方案覆盖。
---
## 一、两个缺陷的准确定性与证据
### 缺陷1:滑动裁剪致前缀缓存整体失配
`build_for_request`(`crates/df-ai/src/context/mod.rs:185`)超预算时走贪心最小裁剪:`for unit in &units { if history_tokens - removed <= available { break } removed += unit.token_sum }`(`mod.rs:211-219`)——**每次只裁到刚好塞下为止**。对话每增长一条消息就重新越过 `available` 一点,裁剪边界 `trim_end` 随之前移一个单元,发送视图的**第一条消息每轮都变**。前缀缓存(anthropic 显式 cache_control 与 openai_compat 自动前缀缓存同理)要求从第 0 字节起逐字节相同,首条即变 → 整段历史永远 miss。
已缓和但不治本的既有修正:
- 淘汰补偿摘要从头部 insert(0) 改尾部 push(user 角色,`mod.rs:250-261`),注释明言治「命中率掉到个位数」;
- `push_system_anchor` 尾部追加不位移前缀(`agentic/helpers.rs:28-44`、`context_lifecycle.rs:309-313`);
- `tool_view_decisions` 冻结压缩决策防跨轮翻转(`agentic/mod.rs:2004-2005`),`compress_cache` 按内容哈希记忆化摘要结果(`stall_detector.rs:368-379`)→ tool_result 视图摘要跨轮字节稳定,已不构成缓存威胁。
**残余根因**:裁剪边界本身逐轮爬行。注意一个连带效应——淘汰摘要每轮即时重生成,其内容在两次裁剪之间是静态的(被淘汰集不变 → `extract_top_keywords` 排序确定性,`context_helpers.rs:284-288`);「摘要逐轮漂移」正是裁剪逐轮爬行的投影。块状化后漂移自动收敛到裁剪事件点。
### 缺陷2:一次压尽
压缩触发:`available*6/10 < history_tokens`(`agentic/context_lifecycle.rs:129-136`),后台 LLM 摘要成功后 `compress_old_messages(protect_start)` 把 `[0, len-6)` 的**全部 active 消息一次性标 compressed**(`context_lifecycle.rs:308`、兜底路径 `:442`;`compress_old_messages` 实现 `mod.rs:670-699`)。sanitize step0 按 `is_active` 白名单过滤(`sanitize.rs:40-43`;`is_active`= status None/Active,`df-ai-core/provider.rs:91-99`)→ 被压消息从发送视图**整体蒸发**,视图前缀从第 0 条起全变,一次压缩 = 历史段全量缓存重建。信息损失同样一次集中(用户体感「丢历史」)。
---
## 二、现状机制盘点(设计依赖的既有事实)
| 机制 | 位置 | 事实 |
|---|---|---|
| 预算公式 | `context_helpers.rs:154-178` | `budget = (max_tokens − output_reserve) × safety_ratio`;默认 128K/8K/0.85 → **budget ≈ 101,836**;`available = budget − sys_tokens`(sys 5–10K → available ≈ 92–97K) |
| 裁剪触发 | `mod.rs:198,214` | `history_tokens > available`(100% 硬门) |
| 保护区 | `context_helpers.rs:683` | `PROTECT_COUNT = 6`(≈最近 2 轮);**context_lifecycle.rs:53 另有一份本地副本 6,双源待统一** |
| 淘汰单元 | `mod.rs:612-642` | 三元组原子(头+尾+紧随独立 assistant),压缩定位/分段标记/裁剪共用 |
| 压缩触发 | `context_lifecycle.rs:129-136` | `0.6×available` + `has_compressible_messages` + 非压缩中 |
| 压缩边界回退 | `mod.rs:676-683` | `compress_old_messages` 内建单元边界回退(只少压不多压)——**分批压缩可直接复用** |
| 摘要补偿 | `context_helpers.rs:332-364` | 淘汰摘要 ≥100 字符才产,硬顶 1000 字符(`:692`);关键词提炼确定性 |
| system 断点 | `anthropic_compat.rs:127-145` | 开关开 → 顶层 system 包 blocks + cache_control |
| 消息级断点 | `anthropic_compat.rs:243-247,339-376` | 打在**末条 user 之前相邻一条**(末条 user 是漂移边界:易变尾段/本轮新问题,打它永远不命中);注释明言预算 4 个 cache_control,现用 2 个 |
| 缓存观测 | `anthropic_compat.rs:723-731` | usage 已映射 `cache_read/cache_creation`——**命中率观测零成本可取** |
| 开关范式 | `prompt.rs:657-695` | settings KV `df-ai-anthropic-cache`,`parse_cache_flag`:空/true/1=开,false/0/杂值=关;进程级 AtomicBool 同步;`cache_split_provider_type` 按 provider gate;关=逐字节旧行为 |
| 易变尾段 | `prompt.rs:709-736`、`agentic/mod.rs:1983-1990`、`stream_relay.rs:791-795` | 日期/清单/augmentation/知识在**每次 send 时构建一次**(`commands/chat.rs:417,996,2209,2476`),loop 内多轮稳定,以 user 角色追加消息流末尾 |
| system 折叠 | `anthropic_compat.rs:128-133,153` | **关键发现:存储态所有 role=System 消息(不论位置)被抽离折叠进顶层 system(sys.join)**。压缩摘要/关键词兜底/话题标记/催办全走 `push_system_anchor(ChatMessage::system(...))`(`context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557`)→ 每次此类事件都改写顶层 system → **BP1 连带全失配**。淘汰摘要已因同因改 user 角色(`mod.rs:254-256` 注释),压缩摘要等锚点未跟进 |
---
## 三、设计总览
三机制分层,各管一段:
```
历史水位轴(×available)
0 0.55 0.75 1.0(=available)
| | | |
|<-- 压缩目标水位 C_low | |
| |<-- 批次≈0.2A |
| | ↑ 压缩触发 C_high(深清,后台,保语义)
| | ↑ 裁剪触发 1.0(应急,view-only)
| | |←→| 裁剪块≈0.2A → 裁到 T=0.8
```
- **裁剪=应急**(build_for_request,view-only):只在压缩失灵(后台失败连续多轮/未及生效)时兜底,块状跳变;
- **压缩=深清**(后台 LLM,改存储 status):常态水位管理,分批渐进;
- 稳态下裁剪永不触发(0.75+批内增长 < 1.0),历史段前缀在两次压缩事件之间逐字节稳定。
---
## 四、方案一:块状裁剪
### 4.1 算法
替换 `mod.rs:211-219` 贪心循环,改「目标水位 + 轮边界吸附」:
1. 触发条件不变:`history_tokens > available`(硬安全门,`mod.rs:214` 同式)。
2. 目标水位 `T × available`(T 默认 0.8):累计淘汰单元 token,直到 `history_tokens − removed ≤ T×available` **且**当前边界吸附到轮边界。
3. **轮边界锚点**:单元边界 `unit.end` 处,若下一时刻消息(未淘汰首条)role=User,则该 end 是轮起点——整问整答同生同死,淘汰摘要按轮覆盖语义完整。
4. 若扫到保护区(`protect_start`)仍无同时满足两条件的边界(病态:单轮 token 均值 > 块大小),回退现行为:取满足硬预算 `available` 的最后单元边界(任何边界,保证不破安全底线)。
两次裁剪之间:`trim_end` 不动 → 被淘汰集不变 → 淘汰摘要内容不变(`extract_top_keywords` 确定性)→ 视图前缀逐字节稳定 → BP2 每轮命中、openai_compat 自动前缀缓存每轮命中。
### 4.2 块大小取值推理
块大小不设独立常量,由水位差导出:`块 = (1−T) × (budget − sys_tokens)`,随模型窗口自适应缩放(与 output_reserve/safety_ratio 的关系:二者先折算进 budget,块是其再切片,无双重调参)。
默认参数下 `1−T=0.2` → 块 ≈ 18–19K tokens。设单轮增长均值 Δ(本应用工具密集,2–10K 常态):
| T | 块 | 稳定窗口 ⌊块/Δ⌋ | 命中率≈窗口/(窗口+1) | 单次裁剪信息损失 |
|---|---|---|---|---|
| 0.9 | ≈9K | 1–4 轮 | 50–80% | 最小 |
| **0.8** | ≈19K | **2–9 轮** | **67–90%** | 中 |
| 0.7 | ≈29K | 3–14 轮 | 75–93% | 大 |
T=0.8 是拐点:再往上窗口塌缩(裁剪频度回升、每次裁剪的「全量重建税」频度变高),再往下单次丢历史变狠。对照今天:超预算后命中率 ≈0%(首条每轮变)。**估算误差 ±15%**(`context_helpers.rs:26` 自述)被 20% 水位差吸收,不会刚裁完就再触。
### 4.3 块边界锚点选型对比
| 候选 | 评价 |
|---|---|
| **轮边界(推荐)** | 语义完整(问+全工具链+答同生死);淘汰摘要按轮覆盖;边界是稳定地标,只在裁剪事件移动;三元组原子性由 `build_eviction_units` 已保证,轮吸附只是再加一层 |
| 消息数 | 块 token 分布不可控(30 条可 5K 也可 80K),裁剪频度与稳定窗口忽大忽小;仅配作病态回退 |
| 纯 token 水位 | 作为**停止条件**必要(§4.1 第 2 步),但单独作边界会劈轮;与轮吸附组合使用 |
---
## 五、方案二:分批压缩
### 5.1 双水位滞后 + 批次
- **触发 C_high = 0.75×available**(现 0.6):提高压缩间隔——压缩事件=全量缓存重建税,频度直接决定命中率;0.75 仍留 0.25A 给后台 LLM 压缩完成并下轮生效的提前量(后台化设计 `context_lifecycle.rs:30-31` 的既有前提)。
- **批次目标 C_low = 0.55×available**:每批压最旧一段,压到 `history − C_low×A` 的 token 量为止,即**每批 ≈ 0.2A ≈ 19K tokens**(与裁剪块同刻度,共用推理)。
- 批边界计算:从最旧起累计淘汰单元至批次 token 量,**轮边界吸附**(同§4.1 第 3 步);`batch_end ≤ len − PROTECT_COUNT`(保护区语义不变)。传入 `compress_old_messages(batch_end)`——其内部单元回退(`mod.rs:676-683`)与占位未闭合跳过(`compress.rs:65-66`)零改动复用。
- **滞后再武装**:压后 history=C_low < C_high,需再增长 0.2A 才触发下一批 → 天然防连发;`is_compressing` 重入标志、`head_id` 版本哨兵(`context_lifecycle.rs:70-77`)、L0-3 后台化全部不动。
- 关键词兜底路径(`context_lifecycle.rs:437-449`)同步改传 batch_end:失败降级的损失也从「一次压尽」收敛为「压一批」。
### 5.2 缓存语义:为什么分批仍然值得
压缩从视图头部移除消息,新前缀=[摘要锚点]+[剩余],**任何一次压缩都是历史段全量重建,分批不减小单次破坏面**。分批的收益在别处:
1. 单次信息损失有界(0.2A≈19K),摘要质量随输入变小而升,用户「丢历史」体感摊薄;
2. 摘要锚点改为**追加链**(见§六 BP3):第 k+1 次压缩把新摘要接在摘要链尾,链之前的 [system+链头] 前缀仍命中——一次压尽则每次都从零重建;
3. 两次压缩间隔拉长(0.75 触发+0.2A 滞后 vs 现 0.6 触发即压尽再爬坡),事件频度下降。
### 5.3 与块状裁剪的先后序(分工)
```
常态: 0.55A ←压缩批次— 0.75A 触发 … 增长 … 1.0A 裁剪(不该到)
失灵: 压缩连续失败(关键词兜底也失败/后台挂)→ 历史爬到 1.0A
→ 块状裁剪应急跳到 0.8A(丢真历史,无摘要)→ 给压缩再争取一批窗口
```
裁剪是唯一硬安全门,保持 100% 触发不动;压缩是语义保全的常态路径。二者水位关系(T=0.8 > C_high=0.75)保证:只要压缩机制活着,裁剪永不出场。
---
## 六、方案三:缓存断点布点
anthropic 预算 4 个 cache_control(`anthropic_compat.rs:349`),现用 2,建议用到 3:
| 断点 | 位置 | 什么变化会打断它 |
|---|---|---|
| **BP1 顶层 system**(现有) | system blocks 尾块(`anthropic_compat.rs:137-141`) | ① custom_prompt 变更(稳定段含自定义段,`prompt.rs:727`);② **任何 System 角色锚点事件**(压缩摘要/关键词兜底/话题标记/催办——折叠进顶层 system,见§二末行);③ 提示词文本随代码版本变更 |
| **BP2 消息级**(现有) | 末条 user 前相邻一条(`:350-357`);有淘汰摘要时落摘要上(摘要两裁剪间静态,反而更稳) | ① 裁剪边界移动(块状化前每轮/块状化后仅裁剪事件);② 压缩事件;③ namespace LRU 淘汰的 notice 替换改写历史中段(`agentic/mod.rs:1965-1982`,低频);④ 新近度豁免滚动——末 2 条 tool_result 豁免过期转压缩时一次性改写(`stall_detector.rs:321-330`,每次新结果进来推移一次);⑤ sanitize 自愈(确定性,输入不变输出不变,不构成威胁) |
| **BP3 摘要链尾**(新增,可选) | 摘要锚点链之后第一条消息 | 仅压缩事件失效;为 [system+摘要链] 小前缀保命中,配合§5.2 追加链语义 |
易变尾段(日期/清单/augmentation/知识)在 BP2 之后,不打断任何断点(既有设计正确);其每次 send 重建的变化被 BP2 挡住,只自伤尾段自身(本来也不可缓存)。
### 6.1 必修项:system 锚点的顶层折叠(打断 BP1 的根)
**发现**:`convert_request` 把存储态全部 role=System 消息抽离折叠进顶层 system(`anthropic_compat.rs:128-133`),消息流中 `System => continue` 剔除(`:153`)。因此每次压缩摘要/话题标记/催办事件:顶层 system 内容变(BP1 失配)+ 消息数组少一条(BP2 位置迁移)→ 双断点同时重置。
**修法(view 层角色转换,存储不动)**:`build_for_request` 出口(sanitize 之后)把**下标>0 的 role=System 消息**映射为 role=User 发送。收益:
- BP1 内容回到纯 system_prompt 稳定段,锚点事件不再打断顶层缓存;
- 存储态仍是 System:`has_compressible_messages` 的 System 排除(`mod.rs:656`)、前端展示、压缩摘要定位语义全部不变——纯视图变更,与淘汰摘要先例(`mod.rs:254-256` role 必须 user)同构收口;
- openai_compat 侧同视图生效(system 留中原位,user 也合法,中性)。
细节:转换须在 sanitize step 之后(user 是最普通角色,不触发三元组/首条规则);首条 system_prompt(`agentic/mod.rs:1983`,组装层加入,不在 storage)不转换。
---
## 七、风险与回退
### 7.1 行为变更逐条
| # | 变更 | 风险 | 缓解 |
|---|---|---|---|
| 1 | 块状裁剪:单次多丢(裁到 T 水位而非刚好塞下) | 裁剪时刻上下文损失变大(有界:≤0.2A) | 换取两裁剪间全量命中;T 可调(拍板点1);病态回退保硬预算 |
| 2 | 分批压缩:C_high 0.6→0.75、批≈0.2A | 后台压缩生效前留给裁剪的余量 0.4A→0.25A;压缩事件频度上升(批小了) | is_compressing/版本哨兵/单元回退全复用;批失败下轮重试(滞后再武装);拍板点2 可保守维持 0.6 |
| 3 | system 锚点 view 层转 user | LLM 把摘要当作用户话语而非系统指令的语义差;多 provider 兼容面 | 纯 view-only 可瞬时回退;淘汰摘要已用同形态跑了数月无异常(`mod.rs:259-261`);拍板点3 |
| 4 | BP3 新增 | 网关对 cache_control 数量/位置的兼容 | 默认关,随开关;预算 4 内 |
### 7.2 开关设计(照 `df-ai-anthropic-cache` 范式)
- 新 settings KV **`df-ai-cache-friendly-context`**,`parse_cache_flag` 同语义(空/true/1=开;false/0/杂值=关),前端设置页一枚开关;
- 总开关 gate 三机制:块状裁剪 + 分批压缩 + system 锚点 view 转换;BP3 挂在同一开关(anthropic 缓存总开关 `df-ai-anthropic-cache` 仍独立管 cache_control 形态,两层正交);
- 开关关 = 三处全部旁路,行为与今日逐字节等价(与 `cache_split_provider_type` provider gate 叠加,非缓存受益 provider 不受 view 转换影响——不,view 转换与 provider 无关,应 gate 在新开关下全员生效或全员旁路,不由 provider 类型分叉);
- 裁剪/转换是 view-only(逐请求),回滚瞬时;压缩是存储 status 单向(已 compressed 不回滚,与今天回滚语义一致,可接受并在开关文档注明)。
### 7.3 回退路径
开关关 → 全量回旧行为。已落库的 compressed 状态保留(软删可追溯,历史数据默认保留);水位常量均在常量层(T/C_high/C_low),极端情况改常量即再调参,无需动逻辑。
---
## 八、分期
### 快赢(下一小批可入,改动小、收益确定)
| 项 | 内容 | 落点 | 量级 |
|---|---|---|---|
| W1 | **块状裁剪**(T 水位+轮吸附+病态回退) | `context/mod.rs:211-219` 替换 + 单测(水位/轮边界/病态三case) | S |
| W2 | **system 锚点 view 层转 user** | `build_for_request` 出口 + 单测(首条不转/转换后 sanitize 不破) | S |
| W3 | **命中率观测日志**:`cache_read/cache_creation` 已在 usage(`anthropic_compat.rs:728-730`),每轮 info 输出命中率 | stream 收尾处 | XS |
| W4 | BP3 摘要链尾断点(随 anthropic 开关) | `apply_message_cache_breakpoint` 加一档 | XS |
W3 最先:先有数据,后面所有调参有据。**协调注**:W1/W2 落在 context/mod.rs(并行代理在改),实施前 rebase;若分层组装器 P2.2(组装器结构化)先行合入,块状裁剪改为在组装器出口实施,算法不变。
### 结构性(需更多验证)
| 项 | 内容 | 依赖 |
|---|---|---|
| S1 | **分批压缩**(C_high/C_low 双水位+批边界+兜底同批)+ PROTECT_COUNT 双源统一(`context_helpers.rs:683` vs `context_lifecycle.rs:53`) | W3 数据验证压缩事件频度;狗粮长会话 |
| S2 | 水位再校准:T/C_high 按 W3 实测命中率-损失曲线微调 | S1 上线后一轮狗粮 |
| S3 | namespace LRU 淘汰 notice 的前缀断治理(notice 文案确定化已天然成立,残余为位置断,收益小后置) | 低优先 |
---
## 九、拍板点(4 项)
1. **裁剪目标水位 T**:推荐 **0.8**(块≈19K、稳定窗口 2–9 轮、命中 67–90%);备选 0.85(保守,少丢但裁剪频)——§4.2 表。
2. **压缩双水位**:推荐 **C_high=0.75 / C_low=0.55**(批≈0.2A,滞后 0.2A);备选维持现状 0.6 单水位(仅做分批不做提频调整)——§5.1。
3. **system 锚点 view 层转 user**:推荐**做**(治 BP1 折叠根因);备选不做(则压缩/话题标记事件继续双断点全失配,BP1 收益打折)——§6.1。
4. **总开关默认值**:推荐**默认开**(对齐 `df-ai-anthropic-cache` 默认开先例,看板 939⑧;view-only 为主可瞬时回退);备选默认关观察一轮——§7.2。
---
## 附:证据坐标索引
| 论断 | 坐标 |
|---|---|
| 贪心最小裁剪逐轮爬行 | `crates/df-ai/src/context/mod.rs:211-219`(触发 `:198,214`,切片 `:239-246`) |
| 淘汰摘要尾追+缓存注释 | `mod.rs:250-261`;确定性提炼 `context_helpers.rs:284-288,332-364` |
| 一次压尽 | `agentic/context_lifecycle.rs:129-136`(0.6 触发)、`:308,442`(compress_old_messages(protect_start))、`mod.rs:670-699` |
| is_active 白名单过滤 | `context/sanitize.rs:40-43`;`df-ai-core/provider.rs:91-99` |
| system 全量折叠(打断 BP1) | `anthropic_compat.rs:128-133,153`;锚点 system 入库 `context_lifecycle.rs:313,449`、`agentic/mod.rs:1869,2510,2557` |
| 消息级断点(末条 user 前) | `anthropic_compat.rs:243-247,339-376`;4 个预算 `:349`;usage 映射 `:723-731` |
| 开关范式 | `commands/ai/prompt.rs:657-695`;易变尾段拆分 `:709-736`、`stream_relay.rs:791-795`、每 send 构建一次 `commands/chat.rs:417,996,2209,2476` |
| tool_result 摘要已稳定(不威胁) | 冻结 `agentic/mod.rs:2004-2005`、`stall_detector.rs:352-363`;记忆化 `:368-379`;新近度豁免滚动 `:321-330` |
| 预算公式与保护区 | `context_helpers.rs:154-178,683,692`;双源 PROTECT_COUNT `context_lifecycle.rs:53` |
| 请求组装序 | `agentic/mod.rs:1143`(sys_tokens)、`:1957`(build_for_request)、`:1965-1982`(namespace 展开)、`:1983-1990`(system+history+尾段) |
| 上位目标与验收口径 | `docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md`(病灶④/「裁剪以批量滞后发生」) |
@@ -0,0 +1,132 @@
# DevFlow 布局样式大重构设计(2026-10-18)
> 依据:用户令「布局样式大重构,参考 D:\Downloads\u-agent」+「每页样式 DRY 提取统一定义」
> 输入:u-agent设计系统勘察-2026-10-18.md · 样式走查-1018/报告-布局全局审计.md · 报告-配色全局审计.md
> 原则:DRY 单一真值 · token 化 · 高密度(u-agent 13px 范式)· 双主题保留 · 紫 accent 品牌保留 · 视觉回归可控(dev 模式实时预览)
## 0. 现状诊断(三份勘察合并)
| 维度 | 现状 | u-agent 基准 | 差距 |
|---|---|---|---|
| 基准字号 | html 14px,aichat 已降 9-13,views 仍 11-14 | 13px 全局 | 功能页粗一档,密度断层 |
| 页头 | 三套并存(aichat 46px / Dashboard 18px 标题 / 功能页 24px h1 无固定高) | 46px 定高单行 | 全站统一为紧凑单行 |
| 侧栏 | 192px | 250-280px | 偏窄,功能页导航拥挤 |
| 按钮 | .btn 双基类分叉(global 34px vs settings 30px)+ 图标钮三档 | .cbtn 单基类 | 合一 30px |
| 徽章/tag | 圆角 3/4/6/8/10 五档并存;.df-tag 3px 字面量 | 小标签小圆角一档 | 收敛一档入 token |
| 圆角 token | 覆盖 75.5%,9 处 var 回退值写错(静默降档) | — | 修回退+字面量 97 处归 token |
| 间距 | 76.7% 收敛,7px×8/82px 手算对齐等离群 | 4px 刻度 | 离群归位+魔法数 token 化 |
| 字号 | 半像素 24 处(10.5/11.5/12.5)+17px 孤值 | 纯整数谱 | 归整 |
| 输入框 | 三档并存(21-22 / 26-27 / 30) | 30px 单档为主 | 行内搜索统一 26px 档 |
| 小节头 | 各页自造 | 9-10px uppercase mono 字距 统一范式 | 抽 .df-sec-h |
| 公共技法 | 无 | hairline 网格 kv 卡 / 发丝线行 / 染色两档 | 引入公共类 |
## 1. Token 层扩展(global.css :root,单一真值)
### 1.1 新增 token
```css
/* 字号谱(纯整数,对齐 u-agent) */
--df-fs-micro: 9px; /* 小节头/tag/计数 */
--df-fs-meta: 10px; /* 徽章/元数据 */
--df-fs-aux: 11px; /* 辅助文字/列表次级 */
--df-fs-ctrl: 12px; /* 控件文字/小按钮 */
--df-fs-body: 13px; /* 正文基准 */
--df-fs-title: 16px; /* 页头标题 */
/* 控件高度档 */
--df-h-btn: 30px; /* 主按钮(.btn 合一目标) */
--df-h-input: 26px; /* 行内输入/搜索 */
--df-h-modal-input: 30px;
--df-h-icon-btn: 26px; /* 图标钮统一档(22/26/28 收敛) */
/* 微内距暗刻度(chip/badge 族) */
--df-pad-chip: 1px 6px;
--df-pad-badge: 3px 8px;
/* 图标槽 */
--df-icon-gutter: 42px; /* ToolCard 图标槽(38/42 魔法数归一) */
/* 染色两档(语义色背景/边框透明度统一,双主题各配) */
/* 由 --df-*-bg(12% 底)+ 新增 --df-*-bd(30% 边)承担,消除散落 rgba 字面量 */
--df-success-bd / --df-warning-bd / --df-danger-bd / --df-info-bd / --df-accent-bd
```
### 1.2 修正既有错误
- 9 处 var 回退值写错(lg 8→10 / md 6→8 / xs 3→2):**删回退值**(token 必注入,回退是静默降档源)
- .df-tag 圆角 3px 字面量 → var(--df-radius-xs)
- body 基准 14px → 13px(line-height 1.5 保持);aichat 已对齐,功能页随页头波次跟进
## 2. 公共组件层(global.css/components.css 单一来源)
| 公共类 | 来源 | 定义去向 |
|---|---|---|
| `.btn` 族 | global+settings 双基类合一 30px/8px 16px/13px | global.css 唯一定义,settings.css 删副本 |
| `.df-sec-h` | u-agent 小节头范式(10px uppercase letter-spacing .5px mono tx3) | global.css 新增 |
| `.df-kv` / `.df-kv-i/k/v` | hairline 网格档案卡(gap:1px+bd 背景) | components.css 新增 |
| `.df-row` | 发丝线行(border-bottom+last-child none) | components.css 新增 |
| `.df-note` | 通知条(info/warning/danger 染色) | components.css 新增 |
| `.df-pill` | 状态胶囊(全圆+染色底) | components.css 新增 |
| `.df-tab` / `.df-tab--active` | 下划线 tab(2px accent 底线+计数 chip) | components.css 新增 |
| 既有 `.df-dot/.df-badge/.df-tag/.df-mono/.df-hstat` | 第五十四段已注入 | 保留,随 1.2 修圆角 |
## 3. 页面适配波(DRY 提取:各页 scoped 重复样式→公共类)
- **页头统一**:功能页 8 页 + Dashboard 全部改 `.page-header` 紧凑单行(h1 16px/500 + 定高 46px 等价 padding + header-actions gap 8px),删各页自造页头样式
- **侧栏 192→220px**(AppSidebar 一处)
- **输入框**:GitChanges/Provider 行内 21-22px → var(--df-h-input)
- **列表/详情页**:status-tag/status-* 徽章族(components.css 已有 13 个状态色)染边补齐 + 圆角统一;Details 页 kv 区改 .df-kv;tab 行改 .df-tab
- **settings 族**:settings.css .btn 副本删除并轨;面板内重复 card/row 样式提取
- **ai 族**(已对齐 workbench):仅扫尾——圆角字面量 9px 气泡入注释锚定、7px 代码块归 token、tag 圆角收敛
- **离群修复**:7px 间距→6/8、ProviderPanel 82px 手算对齐改结构、26px 图标位 calc、FilePreview outline-name 补 nowrap(确定 bug)、定宽输入/浮层补 max-width
## 4. 实施波次(文件零交集,逐波门禁 vue-tsc + 目检)
| 波 | 内容 | 文件面 |
|---|---|---|
| R0 底座 | token 扩展+回退修正+公共类新增+.btn 合一+body 13px | styles/global.css + components.css + settings/settings.css |
| R1 功能页族 | Projects/Tasks/Ideas/Knowledge/AuditLog 页头+密度+tag 收敛 | views/*.vue(5 页可再拆并行) |
| R2 详情页族 | ProjectDetail/TaskDetail/IdeaDetail(kv/tab/徽章/页头) | 3 个 Detail.vue 可并行 |
| R3 设置族 | settings.css 并轨+面板 DRY 提取 | components/settings/* |
| R4 ai 扫尾+杂项 | 圆角/token 扫尾+离群修复+FilePreview bug | components/ai/* + 杂项 |
| R5 配色收口 | 按配色审计报告优先级表 10 项实施(报告 temp/样式走查-1018/报告-配色全局审计.md):①幽灵 token 清零(含 --df-bg-secondary 等 6 处声明失效真 bug)②.status-* 徽章系 token 三件套③白叠层 64 处入 token(--df-hover/--df-zebra 浅色覆写黑叠层)④回退值 111 处全删⑤global.css 边框 rgba 改 color-mix⑥--df-neutral-bg⑦views 族 rgba 清零⑧--df-shadow-sm/md⑨alpha 收敛⑩豁免注释 | 按报告分布;R5 先补 token(叠层/中性/阴影)再批量替换 |
每波:前台同步代理实施 → 主代理 diff 核查 + vue-tsc → dev 窗口目检(用户可实时看)。R0 先行(其他波依赖新 token/类),R1-R4 在 R0 后并行。
## 5. 拍板点(按推荐值先行,用户可回改)
1. **基准字号 13px**(功能页降一档,信息密度对齐 u-agent;若觉偏小回 14 仅改一处 token 层 body 声明)
2. **侧栏 220px**(192→220 折中,非 250)
3. **页头 16px 标题+紧凑单行**(废 24px 大标题)
4. **按钮 30px 主档**(对齐 settings 既有 30px 而非 global 34px)
5. tag/badge 圆角统一 xs(2px)/sm(4px) 档
## 6. 单文件拆分轨([补4],与样式波错峰)
行数实测(2026-10-18):MessageList 3297 / ProjectDetail 1750 / TopBar 1700 / Tasks 1540 / ChatInput 1503 / IdeaDetail 1228 / ConversationSidebar 1226 / TaskDetail 1160 / AiChat 1145 / FilePreview 1144 / ProviderPanel 964 / FileExplorer 932 / ToolResultBody 895。
**拆分纪律**:
- 纯拆分不改行为——样式/逻辑原样搬迁,验收=vue-tsc 0 + 构建过 + dev 窗口目检无视觉回归
- 拆分边界按「区块自治」(模板区块+配套样式+相关逻辑成组),优先抽展示型子组件;共享状态经 props/emits 或既有 composable,不新造 store
- **与样式波互斥调度**:同一文件的样式适配与拆分不并行(样式先行,拆分在后——拆完再改样式要重新定位锚点)
- 拆分批次(样式波收口后启动,每批文件零交集):
- C1 ai 族:MessageList(3297→消息项/工具卡区/折叠条带等子件) → ChatInput(1503) → TopBar(1700)
- C2 项目族:ProjectDetail(1750) → FilePreview(1144) → FileExplorer(932)
- C3 任务/想法族:Tasks(1540) → TaskDetail(1160) → IdeaDetail(1228)
- C4 侧栏+设置族:ConversationSidebar(1226) → AiChat(1145) → ProviderPanel(964) → ToolResultBody(895)
- 每文件拆分目标:主文件 ≤600 行,子件单一职责
## 7. 整洁性轨(用户令 2026-10-18:代码整洁性全量处理+样式代码统一抽取,[补1] 共享的样式才抽取)
探针实测(dead-style-scan.json / dup-style-scan.json,脚本在 temp/样式走查-1018/):
- 死样式候选 368 处 / 56 文件(MessageList 34/ToolCard 25/TopBar 20/ActiveProjectsPanel 19 领衔);**假阳性规则**:Vue transition 生成类(xxx-enter/leave/from/to/active 由 `<transition name="xxx">` 隐式消费,非死)、动态拼接类(`'status-' + s`)、子组件透传类——删前须逐处验证
- 跨文件共享重复规则 49 条;典型双轨:`.ai-toast-*`(AiChat/ApprovalPopup)与 `.toast-*`(Knowledge/Settings)同义共存;`.confirm-actions`×3;`@keyframes spin/shimmer` 10/3 文件各写一份
**底座已落**(主代理亲码 global.css):`.df-toast` 四色族+过渡 / `@keyframes df-spin`/`df-shimmer` / `.df-confirm-actions`+过渡 / `.df-diff-pre` 族。
**D 批分路**(R5 收口后启动,文件族零交集,每路两件事:本族死样式验证删除 + 本族共享类消费替换):
- D-A ai 族+根组件(MessageList/ToolCard/TopBar/ConversationSidebar/ChatInput/ToolResultBody/AiChat/ToolCardList):死样式 100+ 处 + ai-toast 族消费 + spin/shimmer keyframes 消费 + .ai-tool-diff-pre 族并轨 .df-diff-pre
- D-B views+project 族(Knowledge/ApprovalPopup/Settings/Projects/ProjectDetail/Tasks/AuditLog/FilePreview/FileExplorer/GitChanges/ProjectCard):死样式 100+ 处 + .toast-* 族消费 + confirm-actions 消费
- D-C settings+layout+dashboard+knowledge 族(AdvancedSection/AllowedDirsPanel/UpdateBadge/ActiveProjectsPanel/KnowledgeDetail 等):死样式残余 + 确认弹层消费
- D4 TS/模板整洁(随各路顺带):未用 import/变量(vue-tsc noUnusedLocals 级)、console.log 残留、死模板分支——纯删不改语义
**验收纪律**:每路 vue-tsc 0 + grep 证据(死样式删除数/共享类消费数);transition 类假阳性必须 grep `<transition name=` 确认后再删。
@@ -0,0 +1,203 @@
# 跨会话召回四层架构 — 设计
> 日期: 2026-10-11 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点
> 决策背景: 用户拍板决策单 C1 走 B 重量路线(对齐 u-claw 分身养成层四层架构), 推翻原「先 A 后 B」建议(见 aichat上下文管理-派工与决策单-2026-09-29.md C1)。
> 前序文档: 跨会话记忆四层架构-设计-2026-09-29.md(首版四层稿, W1-W4 批次均未实施)与跨会话召回最小闭环-设计-2026-10-04.md(已拍板并落地, 构成本设计的存量底座)。本文档是 09-29 首版按 2026-10-11 现状的重勘与收编, 实施以本文档为准。
> 铁律(继承 u-claw, 09-29 首版已立): **跨会话召回内容严禁进 system prompt 与常驻组装段, 只走工具按需查**。工具描述可进 system(静态文本, 属稳定缓存前缀), 召回内容不进。u-claw 原案教训: 频繁变更的注入毁 provider 前缀缓存。
> 勘察基线: 2026-10-11 工作区。crates/df-ai 的 context/mod.rs 与 context_helpers.rs 有并行代理在改, 引用行号实施前须复核。
---
## 一、四层口径与 u-claw 的对齐
u-claw 分身养成层原案按数据流阶段分层(捕获/精炼/召回/验证, 见 memory project-uclaw-avatar-layers); devflow 侧 09-29 首版起按**记忆的时间半径**分层(轮内/跨轮/跨会话/跨重启), 本设计沿用时间半径口径, 理由是它与 devflow 现有基建一一对应, 逐层可独立验收。两套口径的语义交点在第三层: u-claw 的「召回严禁进 system prompt」铁律原样继承, 并在 devflow 语境下收紧(u-claw 原案保留了低频 CLAUDE.md 摘要注入通道, rollover 级更新保缓存; devflow 侧跨会话召回不做任何形式的自动注入, 连低频摘要注入也不要, 只走工具)。
| u-claw 阶段口径 | devflow 时间半径口径 | 对应关系 |
|---|---|---|
| L1 捕获(hooks 零延迟) | L1 轮内 | 轮内上下文采集与组装 |
| L2 精炼(信号过滤+小模型) | L2 跨轮 | 压缩精炼+WorkingContext 回填 |
| L3 召回(严禁进 system prompt) | L3 跨会话 | 本设计主体 |
| L4 验证(观察项) | L4 跨重启 | 目标快照+恢复语义(验证语义落在恢复续接正确性) |
---
## 二、四层在 devflow 的映射(存储/写入/读取/生命周期/现状)
### 2.1 L1 轮内(现状已建, 本设计不动)
- 存储载体: per_conv 内存(`ContextManager.messages`, `crates/df-ai/src/context/mod.rs`)+ `ai_conversations.messages` 落库。
- 写入时机: 每轮 push, 大工具结果引用化入账(`src-tauri/src/commands/ai/audit/mod.rs:1184-1191`)。
- 读取通道: 每轮组装, 滑窗 `build_for_request`(`context/mod.rs:185`)+ system 段 `build_runtime_prompt`(`agentic/mod.rs:3042`)。
- 生命周期: 会话级, 随会话存亡。
- 现状标注: 分层组装器方案(上下文分层组装器-落地方案-2026-10-10.md)管辖此层与组装结构, 本设计零改动。
### 2.2 L2 跨轮(现状已建, 2026-10-11 新增压缩存档, 本设计不动)
- 存储载体: 内存视图(compressed 状态标记/淘汰摘要)+ 压缩摘要锚点(system anchor 尾部追加, `agentic/context_lifecycle.rs:335`)+ **压缩原文存档**(`<data_dir>/context-archive/<conv_id>-archive.jsonl` 追加写, `crates/df-ai/src/context/archive.rs:35-39`)。
- 写入时机: 压缩水位 `available*6/10 < history_tokens` 触发(`context_lifecycle.rs:133-135`), 后台 LLM 产 nl_summary+json_card; WorkingContext 写入链 `begin_turn`(`commands/chat.rs:950`、`:2444`)与 `sync_after_compress`(`context_lifecycle.rs:337`); 存档追加写(`archive.rs:44`), 开关 `df-ai-compress-recall` 默认开(`commands/ai/prompt.rs:709-718`)。
- 读取通道: 摘要常驻锚点(会话内每轮可见); 原文经摘要尾部召回句柄行 read_file 按需回读(`archive.rs:72-74`)。
- 生命周期: 会话级; 存档文件跨重启仍在, 会话删除级联清理是已记录缺口(`archive.rs:7` 注释自证「留待后续挂点」)。
- 现状标注: 压缩可召回 2026-10-11 刚落地(工作区未提交); 记忆卡四类卡(C-m1, 上下文三层记忆-设计-2026-10-04.md)设计已立未实施, 落地后是 L2 的结构化产物。
### 2.3 L3 跨会话(本设计主体)
**存量底座(A 路线已落地部分, 2026-10-11 实测)**:
- 写入: 自动提炼 `maybe_spawn_extraction`(`commands/ai/knowledge_inject.rs:660`)挂在正常收敛点(`agentic/mod.rs:1636`, save 之后), 水位制防重(`EXTRACT_WATERMARKS` :70, 新增 10 条用户消息起提 :52, 提炼窗口 40 条 :48), 产 candidate 带 source_project 弱推断归属(:1180、:1212)与 source_ref 会话级溯源(:1216-1219); `knowledge_insert` 工具(`commands/ai/tools/knowledge.rs:95`, 固定 candidate, 但 handler 无会话上下文拿不到 conv_id, source_ref 记 "ai-chat" 渠道兜底, :13-16 注释自证)。
- 读取: 注入侧 `auto_inject` top-3 带项目软排序(`knowledge_inject.rs:520-521`, 检索走 `search_keywords_scoped`, `crates/df-storage/src/crud/idea_repo.rs:834`, 同项目第一档/全局第二档/异项目第三档); 工具侧 `knowledge_search`(`tools/knowledge.rs:51`, 仅 published, 分词检索, 单条 content 截 500 字 :41, 空结果带可执行指引 :72-77)。
- 把关: `knowledge_promote` 显式转正工具(`tools/knowledge.rs:165`), 复用达标自动晋升(默认关, `knowledge_inject.rs:549-565`), 生命线留痕(KnowledgeTimeline)。
**缺口(本设计补齐)**: 会话终态提炼(删除/停靠时点无提炼挂点)、工具写入的归属补全(缺会话上下文注入机制)、工具读取的项目维度(knowledge_search 未接 scoped 检索)、用户侧脉络卡通道。详见第三节。
### 2.4 L4 跨重启(持久化面盘点)
**已有**:
- 消息与上下文重建: messages 落库 + `restore_from_messages`(`context/mod.rs:310` 附近)。
- 目标钉扎持久化(G1 已落地): pinned_goals 每轮 save 落库(`commands/ai/conversation.rs:466-468`), 会话载入恢复(`commands/conversation.rs:492-495`)。
- 任务边界: boundary_json(V45, `crates/df-storage/src/migrations.rs:1366`)。
- 断点续跑(V49): loop_state 列(`migrations.rs:1446`), 启动扫描 running 改 parked(`src/state.rs:455-460`), 恢复链 `ai_list_recoverable`/`ai_recover_parked`/`recover_parked_inner`(`commands/conversation.rs:292-337`), 前端 G5 可恢复分组(`src/components/ai/ConversationSidebar.vue:208`), B5 无人值守引擎自动恢复优先于领单(`agentic/unattended.rs:1017-1018`)。
- 收敛快照事件: AiCompleted 透传 pinned_goals_snapshot(`agentic/mod.rs:1663`)。
**缺口(本设计补齐, 详见第四节)**: WorkingContext 不持久化(df-storage 全仓零引用, 重启后 goals/decisions/unresolved 全丢, 压缩重锚定与目标钉扎跨重启断裂); namespace_store 重启清空(`agentic/mod.rs:1975` 注释自证「重启后 namespace 清空」, 懒重建未做); 目标快照槽语义缺失(看板 D4 挂账); 压缩存档文件无删除级联。
---
## 三、L3 主体设计
### 3.1 写入侧: 三通道加一挂点
| 通道 | 时机 | 状态 |
|---|---|---|
| 自动提炼 | 每轮正常收敛后, 水位制(10 条新用户消息起提) | 已落地 |
| 工具沉淀 | 用户说「记住/记一下」时 agent 调 knowledge_insert | 已落地, 归属待补 |
| 显式转正 | 用户认可时 knowledge_promote | 已落地 |
| **会话终态提炼(新增)** | 会话删除前 + parked 停靠时 | 本设计新增 |
**终态提炼设计**:
- 触发点两处: ① `ai_conversation_delete`(`commands/conversation.rs:854`)删除动作前, 后台 fire-and-forget 执行, 提炼失败或未完成不阻塞删除(删除是用户显式意图, 延迟它属于体验倒退; 未及落库的条目随会话消失是可接受损失, 与「历史数据默认保留」不冲突, 因候选未及产生即无产物); ② `emit_task_parked` 停靠三件套处(`agentic/mod.rs:1464-1477`), 停靠意味着任务未完结, 此刻的目标/决策/未解项最有提炼价值。手动触发已有(`trigger_extraction_now`, `knowledge_inject.rs:819`), 不重复建。
- 强制清水位: 复用 `trigger_extraction_now` 的清哨兵语义(:827-828), 终态提炼不受「10 条新用户消息」水位限制, 短会话有实质产出也应提炼一次; 防重仍走 InFlight 水位, 与可能在飞的常规提炼互斥。
- 提炼范围: 常规提炼只看尾部 40 条窗口(:48); 终态提炼取全量 active 消息, 并把 WorkingContext 的 decisions/unresolved 一并喂给提炼提示词(此刻 working_context 在内存中是最新态), 让「本会话定了什么、卡在哪」成为提炼输入而非仅靠消息流重放。
- **提炼什么与不提炼什么的边界**: 提炼产出限「跨会话可复用的经验」: 决策与依据、事实发现(环境约束/结构/坑)、偏好与约定、踩坑。**未完成事项不进知识表**: 它是会话态不是知识, 归 L4 目标快照与停靠说明承接(与根治设计分工矩阵一致: knowledge 是高蒸馏沉淀, pinned_goals/任务态是实时意图, `docs/02-架构设计/上下文根治设计-20261006.md` 第十节)。此边界修正 09-29 首版「未完成事项提炼进知识库」的含糊表述。
- 工具写入归属补全: knowledge_insert 拿不到 conv_id 的根因是工具 handler 只有 args 入参(`tools/knowledge.rs:13-16`)。补法: 工具注册或执行侧传入当前活跃会话 id(与 R1 批的会话上下文注入机制同一件基建, 见 3.3), insert 前 `resolve_source_project`(`knowledge_inject.rs:948`)算归属, source_ref 记 `conv:{id}`, 与自动提炼产物同格式。
### 3.2 存储选型: 复用 knowledge 表, 零 schema 变更
| 选项 | 评价 |
|---|---|
| **A 复用 knowledges 表(推荐)** | 检索/复用计数/晋升/生命线/归属/溯源全套机制已在; kind 七种语义(踩坑/架构模式/诊断/评审规则等, `migrations.rs:1828-1830`)覆盖决策与事实类产出; 零迁移零新面 |
| B knowledges 补 scope 层级字段 | 知识库模型重审(2026-10-05)UC6 提案(会话/项目/全局); 触发制后置, 单项目桌面场景 source_project 已够, 宁少勿预留 |
| C 新建 session_memories 表 | 与 knowledges 的检索/晋升/代谢/留痕重复建设, 双轨违背 DRY; 09-29 首版未选, 本版明确否决 |
| D 复用压缩存档 JSONL 当跨会话记忆 | 存档是原文不是蒸馏产物, 无结构化元数据, 检索需全文件扫描; 根治设计第八节定位明确: 知识条目是晋升终点, 原文页池是全程。存档维持现状(会话内 read_file 召回, 已落地), 不升级为 L3 存储 |
**推荐 A**。与根治设计晋升单向门一致: 会话内页(压缩存档, 原始)到记忆卡(会话内, 中蒸馏, C-m1)到 knowledge 条目(跨会话, 高蒸馏)。L3 只收蒸馏产物, 原料留在 L2 侧。
### 3.3 读取侧: 只走工具按需查
**铁律的两层精确表述**(消解与组装器方案的表面冲突):
- 跨会话层(本设计): 召回内容一律走工具, 无任何自动注入通道。prompt 总 token 不随历史会话数增长(09-29 首版 W3 验收口径沿用)。
- 会话内层(组装器管辖): 分层组装器 P2.3 的 recall 段(会话内窗口外页/记忆卡)落发送视图尾部 user 角色、带配额闸, 不进 system 段, 与铁律字面一致; 其 v1 半径拍板已锁「仅当前会话」(组装器方案拍板点 4 选 A), 跨会话被明确延后到本设计, 且本设计选择工具通道而非注入通道, 比组装器的会话内置尾注入更保守。两文档无冲突: 组装器治会话内, 本设计治跨会话, 共用 `knowledge_inject` 检索面。
**工具语义(不新造 recall_memory, 强化学员 knowledge_search)**:
09-29 首版提案的 recall_memory 专用工具与已落地的 knowledge_search 语义重合(查「之前怎么解决的/有没有记过」, `tools/knowledge.rs:55` 描述已含使用时机引导), 再造一个违背 DRY。本设计对 knowledge_search 做两点强化:
1. 项目软排序接入: 检索改走 `search_keywords_scoped`, project_id 由调用时上下文解析(见下条)。同项目经验排前, 异项目与全局知识仍可召回不淹没(三档排序已在 df-storage 侧就绪, `idea_repo.rs:834-880`, 零新存储逻辑)。
2. 会话上下文注入机制: 工具 handler 现拿不到 conv_id(`tools/knowledge.rs:13-16` 自证), 需要在工具执行侧补一个「当前会话 id」的注入通道(注册时捕获或执行时从 conv_states 解析)。这是本设计唯一的新基建件, 同时服务 3.1 的写入归属与 3.3 的读取排序, 一件基建两处收益。
**引导注入方式**: 靠工具描述(静态, 随 system 进稳定缓存前缀, 现有描述已含使用时机)与空结果指引(:72-77 已有), **不新增每轮注入文字**。易变尾段是每轮变化区, 放常量引导既占 token 又无增量价值。
**注入侧与工具侧分工**: 注入侧(`auto_inject`, top-3, 项目软排序已接)是「高蒸馏 published 知识的被动注入」, 服务「agent 不知道自己不知道」的场景; 工具侧是「agent 主动按需查」, 服务明确的历史问题。两者互补且都已存在, 本设计不扩大注入剂量(top-3 配额不动)。
**脉络卡(用户侧辅助通道)**: 新会话或会话列表项上, 按 source_project 聚合展示同项目近期会话的决策/结论摘要(数据源: knowledge 表按 source_ref 反查会话 + ai_conversations 元数据, 纯前端读侧)。用户点「带入上下文」才转为显式 @提及注入。非 LLM 注入, 不触碰铁律; 把「要不要带历史」的决策权交给用户, 与 bot 决策接口哲学(用户只做决策)一致。
---
## 四、L4 与目标快照: 重启恢复还缺什么
现有 parked 续跑链(V49 扫描、ai_recover_parked、B5 引擎自动恢复)解决的是「loop 死了能接着跑」; 缺的是「接着跑时脑子里还带着之前的记忆与最终意图」。三个缺口:
**缺口 1: WorkingContext 持久化(最重)**。重启后 goals/decisions/unresolved 全丢, 压缩重锚定(sync_after_compress 回填的决策)与目标钉扎跨重启断裂, 恢复的会话等于半失忆续跑。设计:
- 载体: `ai_conversations` 加 `working_context_json` 列(V50, ALTER TABLE 可逆, 与 boundary_json V45/loop_state V49 同款模式)。
- 写入时机: `save_conversation` 每轮落库点, 与 pinned_goals 同段序列化 `conv.working_context`(`commands/ai/conversation.rs:466` 附近)。
- 恢复时机: 会话载入(commands/conversation.rs:492 恢复 pinned_goals 的同段)与 `recover_parked_inner` 反序列化回 per_conv。
- 不选 settings KV 单键: 全会话映射挤一个键, 清理随会话删除无法级联, 单点膨胀。
**缺口 2: 目标快照槽(对齐 u-claw goal.go L1/L2/L3 语义, 看板 D4 并入本设计)**。现状恢复语义是注入恢复引导消息重走 spawn 正典链, 没有「按最终有效意图续, 不重放历史目标流」的快照裁决。设计:
- 快照内容: Active 目标(WorkingContext.goals)+ pinned_goals + unresolved + updated_at + source。**不另立存储**, 就是 working_context_json 列的语义化读取(单一事实源, 宁少勿预留), pinned_goals 已有独立列继续沿用。
- 恢复语义: 恢复/续接时按快照对齐当前有效意图, 中途被调整作废的目标不复活; 停靠卡与恢复引导消息由快照生成, 防止「按旧目标继续跑偏」(D4 原始动机)。
- 验收沿用 09-29 首版 W4 口径: 会话 A 调整目标后重启, 续接时只认最终目标。
**缺口 3: namespace 懒重建**。重启后 namespace 引用只读得到淘汰占位(`agentic/mod.rs:1975`), 而 DB messages 存的是展开后原文(namespace_store 侧既有约定)。设计: per_conv 重建时从 messages 原文回填 store(懒: 首次展开命中该引用时回填, 避免载入即全量扫描)。独立小批, 量级 S。
**顺带挂点**: 压缩存档的会话删除级联清理(archive.rs:7 记录的缺口)挂在 R3 批的删除提炼同一条链路上(提炼完成后清理存档文件, 顺序: 先提炼后删文件), 避免单独再开删除挂点。
---
## 五、与既有机制的关系
**知识库读写闭环(看板「观察进化系统只写不读」验收, docs/todo.md:414-415)**: 本设计 L3 写入侧(自动提炼+工具沉淀+终态提炼三通道)与读取侧(注入面+工具面+脉络卡三面)就是该验收的实现载体。质量闸引用知识库模型重审(2026-10-05)的实施序: 准入分级(candidate 不注入, published 且 verified 或 reuse 达标才可注入)与置信重估(自动提取默认 medium, 治 87% high 通胀)是该设计已立项项, 本设计不重复立项, 仅保证终态提炼产物走同一套把关。
**与分层组装器方案(2026-10-10)**: 见 3.3 两层表述。补充一点衔接: 组装器的 MemoryCardSource(C-m1 记忆卡)属会话内 L2 产物; 记忆卡中被验证可复用的条目, 未来经终态提炼通道晋升进 knowledge 表(会话内中蒸馏到跨会话高蒸馏的单向门), 这是两套设计的汇合点, 实施上互不阻塞。
**与 u-claw 分身的关系: 各自实现, 语义对齐, 不共享组件**。理由: ①语言栈不同(Go 网关 vs Rust/Tauri 桌面应用), 抽公共 crate 的跨语言成本远大于收益; ②数据载体不同(u-claw 账本 JSONL+CLAUDE.md 通道 vs devflow SQLite knowledge 表+工具注册表), 硬对齐载体必有一方扭曲; ③真正需要一致的是四层语义、召回铁律、目标快照续接语义, 这些走设计档互引即可(本文档第一节即为对齐记录)。与「devflow集成u-claw-可行性研究-2026-09-28.md」的通道集成(ask_uclaw 工具形态)正交: 那是能力互调, 这是各自体内记忆架构, 不冲突。
---
## 六、分期(每批独立验收)
| 批 | 内容 | 依赖 | 量级 | 验收 |
|---|---|---|---|---|
| R1 | L3 读取强化: 会话上下文注入机制 + knowledge_search 接项目软排序 | 无(scoped 检索 df-storage 侧已就绪) | S-M | 新会话问「这个项目之前定了什么」调工具命中且同项目排前; prompt token 不随历史会话数增长 |
| R2 | L4 重启不失忆: working_context_json 列 + 保存/恢复接线 | 无 | M | 重启应用后压缩重锚定与目标钉扎仍生效 |
| R3 | L3 写入收口: 会话终态提炼(删除前+parked 停靠) + knowledge_insert 归属补全 + 存档级联清理 | R1(共用会话上下文注入机制) | M | 关会话/停靠后知识库新增归属正确条目; 删除会话后存档文件消失 |
| R4 | 目标快照槽+恢复续接语义(D4 并入) | R2(载体) | M | 调整目标后重启续接只认最终目标, 作废目标不复活 |
| R5 | namespace 懒重建 | 无 | S | 重启后旧引用可读原文, 淘汰占位消失 |
| R6 | 脉络卡(前端读侧) | R3(数据在) | S-M | 新会话可见同项目近期决策摘要, 「带入上下文」为显式动作 |
顺序: R1(读取先行见效, 依赖最少)到 R2 到 R3 到 R4, R5/R6 可随批插空。后置触发制(不在本设计实施): scope 层级字段、代谢任务、语义检索(知识库模型重审已立项, 触发制)。
---
## 七、拍板点(6 项)
1. **终态提炼触发点集合**: 建议三触发(删除前后台非阻断+parked 停靠+手动已有); 备选仅停靠+手动(删除走静默, 防删除动作被后台提炼占用资源, 代价是删除会话的经验流失)。见 3.1。
2. **knowledge_search 工具级项目软排序与会话上下文注入机制**: 建议做(跨会话工具查询才有项目维度, 且一件基建服务读写两处); 备选不做(工具保持全局检索, 项目维度仅注入侧, 量级降为 S)。见 3.3。
3. **WorkingContext 持久化载体**: 建议加列 working_context_json(V50 模式, 可迁移可回退, 随会话级联); 备选 settings KV 单键(免迁移, 但清理难、单点膨胀)。见四节缺口 1。
4. **目标快照与 D4 合并**: 建议并入本设计 R4 批(D4 原始诉求与快照槽同一件事, 拆开必双实现); 备选留 D4 单独走(则本设计 R4 改为只做存储载体)。见四节缺口 2。
5. **脉络卡是否本期做**: 建议做(用户可见收益最大, 纯读侧零注入风险); 备选后置(先跑工具通道看真实使用率)。见 3.3。
6. **未完成事项是否进知识表**: 建议不进(归 L4 快照与停靠说明, 知识表只收可复用经验, 防任务态污染知识面); 备选兼容收(kind 用 workflow_optimization+tags 标注 todo, 有「过期待办变死知识」风险, 依赖代谢任务兜底)。见 3.1 边界。
---
## 八、风险
**存储膨胀**: 终态提炼加自动提炼的 candidate 增长(基线 1073 条, 知识库模型重审 2026-10-05 快照)。治理: 提炼查重(title 相似命中即跳过或 reuse+1, 最小闭环设计第四节已列)、代谢任务(重审 UC4, 触发制后置)、存档级联清理(R3 子项)、注入与检索配额上限不动(top-3/top-10)。万条量级 LIKE+索引可承受(重审 E2 前提)。
**提炼质量与幻觉**: LLM 提炼可能编造事实或置信通胀(87% high 已实证)。治理: candidate 不进检索面(误写代价低, 人工审核可弃)、自动提取置信默认 medium(high 需证据, 重审 E4)、reasoning 字段留判断依据(`knowledge_inject.rs:1197-1198` 已回填)、promote 显式转正为唯一人为把关口、蒸馏来源质量权重(重审 E5, 后置)。终态提炼喂入 WorkingContext 决策属结构化输入, 幻觉面小于全文重放, 但仍按同一候选通道把关不豁免。
**隐私边界**: 全链本地 SQLite+本地 JSONL, 与「本地优先, 数据不出门」pivot 一致。三个注意点: embedding 管道维持默认关(C2 已拍板, 本设计不触碰); 终态提炼调用 LLM 发送会话内容, 与普通对话同一 provider 同一外发面, 无增量风险; 压缩存档明文落 data_dir, 敏感度与 DB messages 同级, 确保不进任何同步/上传/日志路径(现状无此路径, 保持即可)。
**缓存与注入纪律回归**: 最大长期风险是未来实现把召回内容「顺手」拼进 system 或常驻段(毁前缀缓存, u-claw 原案教训)。结构防线: 工具通道在组装结构上就不进组装、脉络卡纯前端、组装器召回段落尾 user 角色; 验收红线: 「prompt token 不随历史会话数增长」进每批回归口径, R1 验收即含此项。
---
## 附录: 证据坐标索引
| 论断 | 坐标 |
|---|---|
| 自动提炼挂正常收敛点 | `src-tauri/src/commands/ai/agentic/mod.rs:1636`; 水位制 `knowledge_inject.rs:660-720`(:52 阈值/:48 窗口/:70 水位) |
| 提炼产 candidate 带 source_project/溯源 | `knowledge_inject.rs:1180`(:1212)/(:1216-1219); 归属弱推断 `:948` |
| 注入侧项目软排序已接 | `knowledge_inject.rs:520-521`; `crates/df-storage/src/crud/idea_repo.rs:834-880`(三档 CASE :862-867) |
| 三知识工具与归属缺口自证 | `src-tauri/src/commands/ai/tools/knowledge.rs:49`(register)/(:51 search)/(:95 insert)/(:165 promote); 拿不到 conv `:13-16` |
| 压缩链与 WorkingContext 写入 | `context_lifecycle.rs:133-135`(水位)/(:335 锚点)/(:337 sync); `chat.rs:950`(:2444) begin_turn; `context_helpers.rs:886-996`(结构与方法) |
| 压缩存档(今日落地, 未提交) | `crates/df-ai/src/context/archive.rs:17`(:35 路径/:44 追加/:72 句柄); 开关 `prompt.rs:709-718`; 清理缺口 `archive.rs:7` |
| knowledge 表 schema | `crates/df-storage/src/migrations.rs:1835-1858`(V7, source_project :1846, kind 注释 :1828-1830) |
| L4 已有持久化面 | `conversation.rs:466-468`(pinned 落库)/`commands/conversation.rs:492-495`(恢复); V45 `migrations.rs:1366`; V49 `:1446`; 启动扫描 `state.rs:455-460`; 恢复链 `commands/conversation.rs:292-337`; 前端 `ConversationSidebar.vue:208` |
| L4 缺口自证 | df-storage 全仓无 working_context 引用(grep 实测); namespace 重启清空 `agentic/mod.rs:1975`; AiCompleted 快照透传 `agentic/mod.rs:1663` |
| 删除与停靠挂点 | `commands/conversation.rs:854`(ai_conversation_delete); `agentic/mod.rs:1464-1477`(emit_task_parked 三件套) |
| 上位设计与衔接 | 上下文根治设计-20261006.md(第七/八/九/十节); 上下文分层组装器-落地方案-2026-10-10.md(3.3/P2.3/拍板点4); 上下文三层记忆-设计-2026-10-04.md(C-m1); 知识库模型重审-精细化设计-2026-10-05.md(UC6/E4/E5/实施序) |
@@ -0,0 +1,30 @@
# 飞书通道 WS 假活:探活只告警不自愈
> 登记: AI-Ops 2026-10-11 22:15 · 来源: 用户 @ bot 零回应事故排查 · 实施归 devflow agent
## 现象
用户 20:07 后在飞书 @ bot 发消息,零回应。feishu-channel.log 尾部停在 20:07 「打包换装」watcher 终版,其后 2h+ 零「收到消息」事件——消息根本未达应用,非模型/会话层问题。
## 证据
- 日志: `~/AppData/Roaming/top.1216.devflow/feishu-channel.log`
- 累计 90 条探活告警「660s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)」,跨 10-05~10-11 多日分布,连接时好时坏
- 时间线: 14:00 前持续告警 → 20:04-20:07 恢复(收到 4 条消息正常受理+终版) → 20:07 起再断,至 22:12 仍零入站,进程未重启(devflow.exe PID 14172 同前)
- 应用本身健康: trace.log 持续输出, B5 引擎「🟢 健康,空闲」
- 对照: u-claw 曾有同款(热重建不关旧连接致僵尸 WS,closeGraceful 须显式 Close),见 u-claw 记忆 uclaw-zombie-ws-connection
## 根因方向
WS 长连接假活: 探活任务已检测到「无事件+无在途」但仅打日志,无自动重建连接动作,恢复依赖新消息触发或人工重启。为何 20:04 能自愈一次、20:07 后不能,需读 SDK 连接层确认(是否依赖入站流量触发重连)。
## 修复建议
1. 探活告警连续 N 次(如 2 次,约 20 分钟)→ 主动断开并重建 WS 连接(显式 Close 旧连接防僵尸),重建结果写日志
2. 重建失败退避重试,持续失败升级通知(桌面通知/卡片)
3. 复核 90 条告警的历史分布,确认是否存在更早未被用户察觉的静默窗口
## 排除项
- 97「收到消息」vs 47「受理返回」差值: 新旧版本日志口径混杂(受理返回为后加日志),受理守卫拒绝仅 2 条且走旧「AI 正在生成中」路径,无消息丢失
- watcher 终版无「回复」行: 新版投递走卡片原地更新(edit_card),非回复消息,属正常
+44 -6
View File
@@ -353,6 +353,11 @@
### 附:F2 兜底前移(可选)
- `ai_chat_stop` 的心跳 60s stale 判死+emit AiCompleted 兜底只在用户点停止时运行;可在 loop spawn 时自带一份,panic 死 loop 无需用户点停止也能收敛。
### 🔍 工具结果压缩·错误行洪泛缺陷(2026-10-15 发现,未修)
- **现象**:`extract_key_info_inner` 的错误行保留策略无上限——结果里每个含 `error`/`panic`/`.rs:`/「失败」的行都全量保留并加 `[行 N]` 前缀,grep/rustc 类输出(恰是压缩的主要对象)错误行占多数时输出≥原文,压缩失效且无落盘句柄(`compressed.len() < content.len()` 门不过)。
- **佐证**:`crates/df-ai/tests/context_offload.rs` 的 `non_read_file_large_result_also_gets_handle` 用 `src/foo.rs:N` 做夹具,先天必挂——该集成测试历来未被 `--lib` 门禁覆盖, until 2026-10-15 全量 workspace 门禁才暴露(夹具已改中性行修绿,生产行为未动)。
- **修法方向**:错误行保留加封顶(如最多 N 条+「另有 M 条错误行省略」),超限走正常省略段;配套恢复信号行夹具的鉴别力测试。
# aichat 三路走查增量待办(2026-10-02 深夜)
> 来源:上下文管理批收口期 rev-backend/rev-frontend/rev-crates 三路走查(历三次会话重启后续跑完成)。**全文与批次归并建议见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**。以下为可执行登记,开工以现场源码为准。
@@ -590,7 +595,7 @@
### 待办(按优先级)
1. [ ] **攒批提交 47 文件**(已授权):停笔窗口执行,按主题 4 笔分组——①V49 断点续跑批(state/main/lib/sub_agent/df-storage) ②飞书渠道批(feishu_channel 四修+流式+D1-D3) ③纪律内置+知识库批(N1 helpers 等) ④前端流式链批(useStreamRenderer 等)。提交前 cargo check + vue-tsc 终验。
2. [ ] **C-r0 验证命令后台化**(验收:>90s 同步等待占比 <15%,现状 45%)
2. [x] **C-r0 验证命令后台化**(原验收:>90s 同步等待占比 <15%,现状 45%)——**【10-11 已实施并推送 c7d0ed9,代码就绪;实测口径待开关开启后取数】**:实现见下方 941 条;开关 KV `df-ai-run-command-background` **默认关**(=同步路径逐字节不变,故生效前占比不变属预期),需开开关后跑一段时间再核 <15% 目标。
3. [ ] **D2a ai_tool_executions 结果过期外置**(DB 193.7MB 半天+17.6MB,紧迫)+ D2d 归档 90 天随批
4. [ ] **锁面+同步 IO 技术债**(196 处 async 锁抽查、commands/ai 内 5 文件 std::fs 混入)
5. [ ] **注入验证**:知识库 691b351d/c142e6b2 已 published+high,新会话确认铁律段带出
@@ -640,6 +645,11 @@
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
- [ ] **目标提取质量闸**(2026-10-12 目标丢失事故实锤:用户抱怨句「丢失 会话上下文了 找回目标」被注册成任务目标、「继续」单字成目标、半截命令「ssh-proxy upload --name i」以 active 驱动续跑):写入侧把关——短文本/纯沟通词/疑问句不入 goals,含参数的命令目标须完整形态;恢复侧放行前过同闸
- [ ] **恢复消息递归套娃**(2026-10-12 实锤:重启恢复消息整段被当用户输入再提取,目标里嵌套上一次恢复全文,层层放大):恢复消息注入走旁路,不进目标提取源
- [ ] **G2 停滞判定叠输出变化维度**(2026-10-12 OSS迁移会话实证:轮询 grep DONE 同参数连发5次被防卡死拦截,但输出 skip 8000→8500 在变=有进展硬证据):样本键叠加输出内容哈希,输出仍在变不算停滞(真死循环输出也不变,非放松防线);stall_detector.rs 签名族
- [ ] **恢复目标完整性守卫**(同上实证:残缺目标「ssh-proxy upload --name i」进快照被恢复引导放行,AI 强行续跑):R4 恢复消息对参数明显不完整的目标标注「须先向用户确认参数」;偏产品语义,与快照线同族
- [ ] **压缩失败路径召回句柄核查**(同上实证:压缩失败兜底摘要+archive jsonl 归档正常,但截断参数仍丢失):核查④压缩可召回句柄行在压缩失败路径是否注入/AI 是否被引导召回;与知识路标层同族问题
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
@@ -664,7 +674,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 不可见——机制防护优先于行为归因。
@@ -705,13 +715,13 @@
- **📋 决策记录(2026-10-11 用户令留档): namespace→offload 双轨归一方案已分析并否决,后续再consider**——提案=大结果入队即 offload 摘要+句柄(不进池无 ref 无展开),三维看似全面优(精准零偏差/无协议演化/单轨砍展开点)。**破坏性分析否决理由**:①致命=新近度豁免失效(RECENCY_EXEMPT_TOOL_RESULTS=2,末尾 2 条恒不压缩保 LLM 见刚读文件全文;offload 入队即摘要,豁免无法作用——重建延迟摘要机制=回到现状架构,复杂度反超);②落盘膨胀无界(池有 LRU 上界,offload 只增不减需新增配额+清理);③压缩链输入降级(context_lifecycle:177 展开原文喂摘要任务→变摘要的摘要);④readonly 缓存比对语义漂移(cache.rs:85 两不同原文可能同摘要误命中)。**重consider触发条件**(全部满足才重启):近度豁免有替代机制(如句柄随取随读的 agent 习惯训练/近度窗内读池不读盘)+落盘配额与清理策略设计定稿+readonly 缓存比对改原文哈希。
- **HIGH ②** 每轮 O(n) 全量深克隆 + sanitize + namespace 展开 + 摘要 + 估算(`context/mod.rs:198-205` / `agentic/mod.rs:1904-1921`,`1945-1957`,`1981-1984`)→ 会话内 O(n²) 累积(全量重发本体);治本=设计档 P2 分层组装。
- **★HIGH ③ ✅ 已修(两批)**:主会话=model_context.rs 按 resolved_model 窗口构造(b37279f,未知窗口保守回退 128K 零回归);子代理=sub_agent_context_config 继承父会话预算(10-11,同模型同窗口单一事实来源,恒 128K 缺口收口)。
- **HIGH ④(压缩)** 压缩**单向不可逆**:被压原文对 LLM 永久不可见(`context/mod.rs:664-699` 标 Compressed + `sanitize.rs:42` is_active 过滤出局),设计 C-r1「压缩不丢/可召回」未落地 → 方向:复用 P1 句柄模式,压缩写 pointer 可回读。
- **④ 设计要点(2026-10-11 登记,待实施)**:①落盘时机=compress_old_messages 标记时(原文在手,compress_old_messages 返回值即 newly_compressed 克隆);②句柄复用 tool_offload(ctx- 前缀同目录,或独立 archive- 前缀区分语义);③召回入口=压缩摘要尾部附一行「早期历史全文已存档: <path>(read_file 可召回)」;④开关 df-ai-compress-recall 默认开(行为变更可回退);⑤磁盘治理:存档随会话删除级联(复用 959 后的清理挂点)或按「只增不减」纪律暂不清理;⑥估算零影响(存档不在上下文);⑦与 907 邻接防线的交互:摘要+句柄行是 system 消息内容,不楔三元组(插入点已在 system 锚点避让保护内)。工作量:中(一个落盘调用+摘要尾部拼行+开关),可与小批实施。
- MED ⑤ `chars_ratio=0.35` 中文低估 2-3 倍、base64 高估(`context_helpers.rs:29-44/73-74`);⑥ 保护区固定 6 条 + 补偿仅 300 字(`:648/:655`);⑦ 分层组装 P2 未落地(窗口外历史无检索召回);⑧ **★** system 可变段(日期+项目/任务清单)内嵌稳定段致**前缀缓存失效**(`prompt.rs:611-635` 缓存拆分开关默认关;清单描述不截断 `:565/:584`);⑨ 滑动裁剪致前缀缓存整体失配(`context/mod.rs:239-246`);⑩ 一次压尽(windows 式分批未做);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
- **HIGH ④(压缩)** ✅ 已实施(2026-10-11):压缩原文 JSONL 存档(`data_dir/context-archive/<conv>-archive.jsonl` 追加写)+摘要尾部召回句柄行(经 push_system_anchor 锚点内,sanitize 安全)+开关 `df-ai-compress-recall` 默认开(src-tauri 读 KV 传参,df-ai 不直读 settings);存档失败降级无句柄不影响压缩;4 新测试,585+845 双绿。原设计 C-r1「压缩不丢/可召回」落地。
- **④ 设计要点(2026-10-11 登记)→ ✅ 已按要点实施,偏离两处**:②句柄改 df-ai 内独立轻量存档(archive.rs,settings 在 src-tauri 侧 SQLite 跨 crate 不可直读,tool_offload 同因不复用);⑤磁盘治理按「只增不减」,级联清理留挂点注释。其余要点逐条落地。
- MED ⑤ ✅ 已修(2026-10-11):CJK 分档 1.2 token/字+base64 改图地板(与 url 同口径,估算对象从字符流修正为实际消耗),`weighted_char_tokens` 单点;⑥ ✅ 已修(补偿上限 300→1000,fda991c);⑦ 分层组装 P2 → **✅ 已解锁待实施**(2026-10-11 C1 拍板走 B:跨会话召回四层架构,[设计文档](./02-架构设计/专项设计/跨会话召回四层架构-设计-2026-10-11.md)——L1/L2 基建已齐,L3 只补四缺口(终态提炼/工具归属与项目排序/脉络卡/读取强化),L4 三缺口(WorkingContext 持久化/namespace 懒重建/目标快照),6 批分期 R1-R6 拍板 6 项;窗口外历史召回走 L3 工具通道铁律不进 system prompt)→ **✅ R1-R6 全部落地**(2026-10-11:R1 读取强化/R2 WorkingContext 持久化 V53/R5 namespace 懒重建先行;R3 终态提炼删除+停靠双挂点/R4 目标快照恢复续接防旧目标复活/R6 脉络卡纯读侧+带入上下文经输入框显式注入);⑧ ✅ 已修核验(2026-10-11):缓存拆分默认开(cd77c1d)+清单描述 120 字截断(truncate_desc)+消费链四调用点实通(chat.rs×3/helpers.rs×1),开关关=逐字等价旧路径;⑨ 滑动裁剪致前缀缓存整体失配+⑩ 一次压尽 → **✅ 快赢 W1-W4 已实施**(2026-10-11 拍板按推荐值,[设计文档](./02-架构设计/专项设计/压缩裁剪缓存友好化-设计-2026-10-11.md)):W1 块状裁剪(0.8×水位+轮边界吸附+病态回退,贪心路径保留为开关关回退)/W2 锚点 view 转 user(治 BP1 折叠根因)/W3 命中率观测日志(每轮 info 供调参)/W4 BP3 断点(机制就位,W2 开时自然旁路);总开关 `df-ai-cache-friendly-context` 默认开。**剩 S1 分批压缩**(C_high 0.75/C_low 0.55,待 W3 观测数据验证压缩事件频度后实施)+S2 水位再校准+S3 namespace 淘汰断(后置);⑪ ✅ 已修(内容版本守卫,context_lifecycle 提交前比对快照);⑫ ✅ 已修(reset_compressing_after_panic)。
- LOW ⑬ 估值双轨(estimated_prompt 准确用于路由 vs history_tokens 失真用于预算)从不互相校准;⑭ sys_tokens 未计工具 schema。
- **已核良好**:裁剪三元组原子性正确(`context/mod.rs:612-642`);909 两处修复(锚点避让/单锁复位)现状有效,未见旧窗口复现。
941. [ ] **C-r0 验证命令后台化:方案已定,落地需拍板(行为变更)**(2026-10-10 Wave A 5a 勘察,代理按纪律拒改并交方案)
941. [x] **C-r0 验证命令后台化(2026-10-11 已实施并推送 c7d0ed9)**(原 2026-10-10 Wave A 5a 勘察)——**【10-11 实施完成】**:audit/bg_run.rs 新模块(~530 行)——开关 KV `df-ai-run-command-background`(**默认关=同步路径逐字节不变**)+`df-ai-run-command-grace-secs`(默认 90,解析失败/0 按默认);**仅主会话生效**,子代理独立循环不转后台;grace 三路竞速(Done/Expired/Aborted)+占用闸 BG_HOLDS+HoldGuard RAII+detached 自持 30s 心跳+会话删除自中止+失败记忆+完成通知;挂接 audit/mod.rs(签名升 &Arc,同步路径逐字保留仅抽 abort_when_invalid 工厂)+guard.rs(+9 行 transition 单点拦 Idle,终局放行)。自查修复真缺陷:peek_loop_exited 先读后 release(先释放吞标记致永久占用)。验证:cargo test -p devflow --lib 845 passed(含本项新 10 测)。**注**:loop 主体 agentic/mod.rs 零改动,占用语义收敛在 guard/conv_state 单点表达(比原预估更小)。
- 事实:`run_command` 是 >90s 桶的 **95%**(11.21h/11.84h,n=4250),阻塞点=`agentic/mod.rs:2235` 整轮同步 await 工具 → `audit/mod.rs:246 execute_with_heartbeat`(tokio::select! await 至工具返回)→ `tools/file.rs:1304` → `df-execute/src/shell.rs:464`(等子进程 exit)。
- **为何不能只改工具层**:轮次生命周期必须同步改写,否则态不自洽——① 心跳 task 随 `execute_with_heartbeat` 返回即 abort(`audit/mod.rs:393-408`),前端 130s 看门狗(`useAiEvents.ts:262`)靠它续命;② 后台运行期间该会话是 Generating 还是 Idle(属轮次语义,落点在 `mod.rs`+前端);③ 完成回填若为注入消息,**不得楔进 head 与其 tool result 之间**(否则复现 907 类 400);④ 给 LLM 返回「已后台」非终态 tool_result = 核心 ReAct 语义变更。
- 建议方案(待拍板):`audit/mod.rs:246` 把 tools future 交 `tokio::spawn`(`tools` 形参升 `&Arc<AiToolRegistry>`,调用点已 clone/持 Arc,签名变更对调用方零改动)+ grace 窗口 select;detached task 自持心跳/失败记忆/完成通知;完成回填复用 `emit_ai_chat_event` + `chat.rs:1133 inject_runtime_message` / `helpers.rs:60 try_continue_agent_loop`;KV 开关 `df-ai-run-command-background`(默认关)+ grace 秒可配;**loop 侧配套改动**(心跳接管/后台期 conv 态定义/收敛判定)在 `mod.rs`。
@@ -746,3 +756,31 @@
961. [~] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。
- ✅ **①已修(2026-10-11)**:执行中 step 卡支持点击展开(复用 expandedTraceIds,key 与完成态 trace 同构,执行中展开→完成转 trace 态不丢;展开区实时渲染该轮过程工具卡)。
- **②③设计要点(实施前拍板)**:轮边界判定锚点从「压缩摘要」改为「用户发起消息」——发起=桌面直发/飞书入站的 user 消息,补充=inject_runtime_message 写入的消息(需来源标记,建议复用 V52 source:{channel:"inject"} 与发起区分,归属判定 O(1));轮号=conv 内第 N 条「发起消息」序(数据派生,天然稳定,不随压缩/视图漂移),轨迹卡头/导航条/进度徽标统一取该口径(单一真值来源)。涉及:轮桶分桶逻辑(MessageList genBucketItems 上游)+轮号计算函数收敛+inject 消息 source 标记。
# 产品方向定位:执行 agent 运行到远程容器(2026-10-18 用户口述,设计先行未实施)
- **定位**:DevFlow 未来形态=执行 agent 跑在远程容器(本机只做控制面/交互面),对齐 u-claw 分身实例形态——桌面端发起任务→远程容器内 agent 全权执行→结果回传。收益:长任务不占本机/环境隔离/多实例并行/24h 无人值守天然成立。
- **衔接存量**:df-tunnel/u-relay 线(v3 wire 协议+ws 隧道,已在树)正是传输底座;TaskBoundary 无人值守体系(边界内自主)是权限面底座;两者拼合即远程执行骨架。
- **待设计问题**(立项时展开):远程执行体的工具面裁剪(本机路径语义如何映射)/媒体与文件存储归属(远程落盘 vs 回传)/审批与边界在远程侧的执行点/凭据注入方式/断线重连与会话恢复/多容器池调度。
- **本条为方向登记,实施等用户令。**
- [ ] 队列带图项零标记(2026-10-18 走查残留):队列行渲染在 AiChat.vue:81-115,带图队列项无图标记;与 F3-2(ToolCardList scrollToFirstPending 预展开)同批处理。
### 💡 2026-10-18 ai-chat 上下文管理与信息组织完善(设计已定,按批领单)
> 用户令「上下文管理/信息组织开发完善+回复别太口水话」。蓝图:[02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md](../02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md);证据:temp/深查-aichat对话质量复测-2026-10-17.md(E系)+temp/深查-aichat信息UX-2026-10-17.md(F系)。M1 为 P1 先行,条目锚点/修法/验收全在设计文档,此处只列索引。
- [x] M1-P1① 恢复链治本:恢复消息 source 标记+[系统恢复]目标双侧过滤+防二次恢复(E1/E3;与 961② 同基建,可并 649 行旧条销账)——✅ 验收:cargo test -p devflow --lib recovery **6 passed**(zero_nesting_with_polluted_goals/all_polluted_falls_back/drops_recovery_artifacts_idempotent 等);conversation.rs:415 双侧过滤+单点拦截在位
- [x] M1-P1② message_repo 全量重写保留原 created_at(E11;污染存量 1750 条不回刷,止增量即可)——✅ 验收:cargo test -p df-storage **14 passed** 含 replace_conversation_preserves_created_at ok
- [x] M1-P1③ search_files/grep 0 命中「阴性存疑」标记转正(E6)——✅ 22:11 AI-Ops 子代理补齐:file.rs negative_hit_hint 共享函数+grep/search_files 两处接入;验收:negative_hit_hint_states_doubt_and_scope ok
- [~] M1-P1④ 跑题修复换装验证(E4;修复在树,换装后重放 6a5a3e04 seq1802 验证)——**已在工作区实施待验收**(prompt.rs list_active_with_activity 三源活跃度排序+指纹缓存对齐;验证方式:跑 dev 后重放场景)
- [ ] M2 存量目标一次性迁移/压缩降级前端透出/超时连败收敛(并 B5d)/boundary_json+loop_state 写入闭环(E5/E2/E13/P2-5)
- [ ] M3 会话 offset 分页+后端搜索/飞书审批决议词归一+终态卡/executing 桶内活性判定+审批预展开(F4-1/F5-1+2/F1-3+F3-2)
- [x] M4-a 提示词回复风格纪律(反口水话,中英双语+测试断言)——✅ 22:11 AI-Ops 子代理补齐:prompt.rs 中英两版行为准则各加「回复风格/Response style」条款(结论先行/省客套复述收尾/要点优先/没问不展开/「省的是空话不是内容」);验收:system_prompt_zh/en_has_response_style_clause 2 passed(共 5 passed)
- [ ] M4-b UX 小批(i18n 补键/token 化/aria/loaded/Esc/队列带图标记)——未动,留板
- [x] **收尾令(用户 2026-10-18 21:41 飞书下达到 AI-Ops,转登记)**:M1 销账后——①按 G3 提交本批改动并 push origin main(推送令已拍板,限本批范围);②启动 dev 模式运行验证(重点验 M4 反口水话与 M1 恢复链);③完成信息经飞书推送用户。若 AI-Ops 监测到 M1 已销账而本条未执行,AI-Ops 代执行。——✅ 23:0x AI-Ops 执行完毕:四笔分组提交(05d0f89 上下文P1批/302c4bc 飞书闭环/25ec9cf 前端令牌批/fb83339 文档)已 push(3bb662f..fb83339);验收 cargo test 2281 passed 0 failed+vue-tsc EXIT 0;dev 已跑(vite:1420 HTTP 200);完成信息已飞书推送。
### 💡 2026-10-19 上下文管理对标 u-claw·大批量并行差距(AI-Ops 分析,待领单)
> 用户战略令(10-18 飞书):DevFlow 上 Linux 替代 u-claw+CC,第一门槛=上下文管理三命题(丢记忆/历史召回/token 高效),大批量多会话并行是常态场景。详情:[02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md](../02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md)
- [ ] P0-A usage 真值采集:stream_options.include_usage 三口径(input/cache_read/cache_creation),6 月 P1 设计复活(当时暂缓,结构层已回退干净)
- [ ] P0-B 压缩触发改 token 水位主判据(≈窗口 70% 可配,体积兜底;u-claw 86e9216 同款,治压缩/请求比 4.8 偏频),依赖 P0-A
- [ ] P1-A 跨会话归档检索(/history 等价):消息全文搜索(FTS5 或 LIKE 起步)+检索指令+AI 检索指引进 sop
- [ ] P1-B boundary/loop_state 写入闭环(代语义显式化+换代通知推送)
- [ ] P2-A 大内容外置纪律(大结论写盘 out/+回复给路径,system prompt 处理规则)
- [ ] P2-B 并行资源治理(全局 token 预算视图/多会话压缩协防)——与 Linux 化/远程容器方向合流设计
+10
View File
@@ -0,0 +1,10 @@
# 探测本机正在运行的 DevFlow 进程及其版本(只读)
$p = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Path
if ($p) {
$v = (Get-Item $p).VersionInfo
Write-Host "PATH=$p"
Write-Host "FileVersion=$($v.FileVersion)"
Write-Host "ProductVersion=$($v.ProductVersion)"
} else {
Write-Host "NO_DEVFLOW_PROCESS"
}
+33
View File
@@ -0,0 +1,33 @@
# 校验 updater 验签密钥配对(终版): 比对 keyid(KEYNUM 8字节) —— minisign 格式对齐
# conf.pubkey blob(42B): SIGALG"Ed"[0..2] + KEYNUM[2..10] + PUBKEY[10..42]
# 私钥文件 blob(116B): SIGALG"Rs"[0..2] + KDFALG[2..4] + CKSUM[4..12] + SALT[12..28] + OPS[28..36] + MEM[36..44] + KEYNUM[44..52] + SIG[52..116]
$confPath = "E:\wk-lab\devflow\src-tauri\tauri.conf.json"
$keyPath = Join-Path $env:USERPROFILE ".tauri\devflow.key"
# 1. conf 侧: 提取 pubkey → 解码 → 取 KEYNUM
$raw = Get-Content $confPath -Raw
if ($raw -match '"pubkey"\s*:\s*"([A-Za-z0-9+/=]{40,400})"') { $confPubB64 = $Matches[1] } else { Write-Host "CONF_PUBKEY_NOT_FOUND"; exit 1 }
# conf.pubkey 是 base64(minisign .pub 文件文本),需二次解码取末行
$confText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($confPubB64))
$pubLines = @($confText -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
if ($pubLines.Count -lt 1) { Write-Host "CONF_PUB_FORMAT_UNEXPECTED"; exit 1 }
$confBlob = [Convert]::FromBase64String($pubLines[0])
$confKeynum = ($confBlob[2..9] | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host "conf keyid : $confKeynum (blob $($confBlob.Length)B)"
# 2. key 侧: 私钥文件 = 注释行 + 1 条 b64 blob(KEYNUM 未加密,明文可读)
if (-not (Test-Path $keyPath)) { Write-Host "KEY_NOT_FOUND: $keyPath"; exit 1 }
$b64Lines = @(Get-Content $keyPath | Where-Object { $_ -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
if ($b64Lines.Count -lt 1) { Write-Host "KEY_FORMAT_UNEXPECTED"; exit 1 }
$secBlob = [Convert]::FromBase64String($b64Lines[0])
$secKeynum = ($secBlob[44..51] | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host "key keyid : $secKeynum (blob $($secBlob.Length)B)"
# 3. 配对判定
if ($confKeynum -eq $secKeynum) {
Write-Host "MATCH - updater 验签密钥配对正确(keyid $confKeynum), B3 安装验签可通过"
exit 0
} else {
Write-Host "MISMATCH - tauri.conf.json 的 pubkey 与签名私钥不配对, B3 安装验签将失败!"
exit 1
}
+58
View File
@@ -0,0 +1,58 @@
# 发布收尾:杀挂起脚本 -> 手动签名 0.1.3 -> latest.json -> 上传 -> 远端验证
$ErrorActionPreference = "Stop"
$repo = "E:\wk-lab\devflow"
$nsis = "$repo\target\release\bundle\nsis"
$exe = "$nsis\devflow_0.1.3_x64-setup.exe"
$key = "$env:USERPROFILE\.tauri\devflow.key"
$site = "suke-dev"
$remoteDir = "/var/www/app-site/download/devflow"
$baseUrl = "https://app.1216.top/download/devflow"
$notes = "版本更新与自动升级全链路上线:设置页新增「关于」,支持应用内检查/下载/安装更新"
# 1. 杀挂起的发布脚本与构建残留
Get-Process -Id 14328,2008,26208,28276,19412 -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host "[1/5] 挂起进程已清理"
# 2. 手动签名(key 文件内容经 env 传入——-k/ev 传路径会被当 base64 解码,报 Invalid symbol 58)
Set-Location $repo
$env:TAURI_SIGNING_PRIVATE_KEY = (Get-Content $key -Raw).Trim()
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
cmd /c "cargo tauri signer sign -p `"`" --app-version 0.1.3 `"$exe`" < NUL 2>&1"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 签名"; exit 1 }
$sigPath = "$exe.sig"
if (-not (Test-Path $sigPath)) { Write-Host "[失败] .sig 未生成"; exit 1 }
Write-Host "[2/5] 签名完成: $sigPath"
# 3. 生成 latest.json(UTF-8 无 BOM,防 PS5.1 BOM 坑)
$sig = (Get-Content $sigPath -Raw).Trim()
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$latestJson = [ordered]@{
version = "0.1.3"
notes = $notes
pub_date = $pubDate
platforms = [ordered]@{
"windows-x86_64" = [ordered]@{
signature = $sig
url = "$baseUrl/devflow_0.1.3_x64-setup.exe"
}
}
} | ConvertTo-Json -Depth 5
$latestPath = "$nsis\latest.json"
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "[3/5] latest.json 已生成 (version=0.1.3)"
# 4. 上传三件套
$env:MSYS_NO_PATHCONV = "1"
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
foreach ($f in @($exe, $sigPath, $latestPath)) {
$name = Split-Path $f -Leaf
Write-Host "[4/5] 上传 $name ..."
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
}
# 5. 远端验证
Write-Host "[5/5] 远端清单验证:"
ssh-proxy exec -n $site -c "ls -la $remoteDir | grep 0.1.3; head -c 200 $remoteDir/latest.json"
Write-Host ""
Write-Host "[发布完成] v0.1.3 清单地址: $baseUrl/latest.json"
+25
View File
@@ -0,0 +1,25 @@
# 后台启动 publish-release.ps1(tauri build 10 分钟级,run_command 600s 上限装不下)
# 用法: run_command 执行本文件 -> 立即返回 pid -> 之后轮询 release-out.log
$repo = 'E:\wk-lab\devflow'
$log = "$repo\target\release-out.log"
$errl = "$repo\target\release-err.log"
# 清掉上次 600s 超时可能残留的构建进程,防 cargo 构建锁互等
$killed = Get-Process cargo,cargo-tauri,rustc -ErrorAction SilentlyContinue
if ($killed) {
$killed | Stop-Process -Force
Write-Host ("清理残留构建进程: " + (($killed | Select-Object -ExpandProperty Id) -join ','))
} else {
Write-Host "无残留构建进程"
}
Remove-Item $log,$errl -ErrorAction SilentlyContinue
$p = Start-Process -FilePath 'powershell.exe' `
-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',"$repo\scripts\publish-release.ps1") `
-WorkingDirectory $repo `
-NoNewWindow `
-RedirectStandardOutput $log `
-RedirectStandardError $errl `
-PassThru
Set-Content -Path "$repo\target\release-pid.txt" -Value $p.Id -Encoding ascii
Write-Host "release started, pid=$($p.Id), log=$log"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "devflow"
version = "0.1.2"
version = "0.1.3"
description = "DevFlow Desktop App"
authors = [""]
edition = "2021"
@@ -30,9 +30,10 @@
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::path::Path;
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
use df_ai::context::ContextManager;
@@ -40,7 +41,7 @@ use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency;
use crate::state::{AppState, LlmConcurrency};
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
use super::super::compress::compress_via_llm;
@@ -207,6 +208,17 @@ pub(super) async fn maybe_auto_compress(
return false;
}
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时把原文
// 存档 + 摘要尾部拼召回句柄。spawn 前读值拷贝,后台 task 不持 AppState 引用
//(State 借用非 'static,不可移入 task)。
let (recall_on, data_dir) = {
let st = app_handle.state::<AppState>();
(
super::super::prompt::compress_recall_enabled(&st).await,
st.data_dir.clone(),
)
};
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
//
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
@@ -258,7 +270,7 @@ pub(super) async fn maybe_auto_compress(
);
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
keyword_fallback_commit(
let degraded = keyword_fallback_commit(
&session_arc,
&conv_id,
protect_start,
@@ -266,8 +278,19 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens,
"压缩 provider 重建失败",
&head_id,
recall_on.then_some(data_dir.as_path()),
)
.await;
// E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发)
if degraded {
emit_ai_chat_event(
&app_handle,
AiChatEvent::AiContextDegraded {
conversation_id: Some(conv_id.to_string()),
reason: "压缩 provider 重建失败,已降级为关键词摘要".into(),
},
);
}
return;
}
};
@@ -305,12 +328,21 @@ pub(super) async fn maybe_auto_compress(
);
return;
}
if recall_on {
conv.messages.set_compress_recall(&data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start);
// 召回句柄拼在摘要尾部(存档落盘成功才有):LLM 需要原文时 read_file
// 回读;句柄行只进 system 锚点,前端事件仍用原 nl_summary 不外发。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary.nl_summary, &p),
None => summary.nl_summary.clone(),
};
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary));
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card);
}
@@ -362,7 +394,7 @@ pub(super) async fn maybe_auto_compress(
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
keyword_fallback_commit(
let degraded = keyword_fallback_commit(
&session_arc,
&conv_id,
protect_start,
@@ -370,8 +402,20 @@ pub(super) async fn maybe_auto_compress(
pre_compress_tokens,
"压缩失败",
&head_id,
recall_on.then_some(data_dir.as_path()),
)
.await;
// E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑,
// 用专用事件让前端透出降级状态条/提示(不打断流)
if degraded {
emit_ai_chat_event(
&app_handle,
AiChatEvent::AiContextDegraded {
conversation_id: Some(conv_id.to_string()),
reason: "LLM 压缩失败,已降级为关键词摘要".into(),
},
);
}
}
}
}) // 闭 catch_unwind 内 async move
@@ -409,6 +453,9 @@ pub(super) async fn maybe_auto_compress(
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
///
/// `recall`:压缩可召回开关(Some = 开,携带存档 data_dir)。开则压缩标记时原文落盘存档,
/// 关键词摘要尾部拼召回句柄行;None 零行为变更(不落盘、摘要无句柄)。
async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -417,11 +464,12 @@ async fn keyword_fallback_commit(
pre_tokens: u32,
log_head: &str,
head_id: &Option<String>,
) {
recall: Option<&Path>,
) -> bool {
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) {
return;
return false;
}
let conv = session.conv(conv_id);
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
@@ -431,7 +479,7 @@ async fn keyword_fallback_commit(
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
log_head
);
return;
return false;
}
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false);
@@ -439,14 +487,23 @@ async fn keyword_fallback_commit(
// 开关关闭 → 回退原裸裁剪行为:不压缩不插摘要,仅完成复位。
false
} else {
// 压缩可召回:开(Some)则压缩标记时原文落盘存档,关键词摘要尾部拼召回句柄行。
if let Some(dir) = recall {
conv.messages.set_compress_recall(dir, conv_id);
}
let _compressed = conv.messages.compress_old_messages(protect_start);
if keyword_fallback.is_empty() {
false
} else {
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进 system 锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(keyword_fallback, &p),
None => keyword_fallback.to_string(),
};
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push_system_anchor(ChatMessage::system(keyword_fallback));
conv.messages.push_system_anchor(ChatMessage::system(&anchor_text));
true
}
}
@@ -459,6 +516,7 @@ async fn keyword_fallback_commit(
log_head
);
}
inserted
}
#[cfg(test)]
@@ -487,7 +545,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c1", 4).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head, None).await;
let session = arc.lock().await;
let conv = session.conv_read("c1").expect("conv 应存在");
@@ -510,7 +568,7 @@ mod tests {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c2", 4).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head, None).await;
let session = arc.lock().await;
let conv = session.conv_read("c2").expect("conv 应存在");
@@ -527,7 +585,7 @@ mod tests {
async fn keyword_fallback_commit_missing_conv_noop() {
let arc = Arc::new(Mutex::new(AiSession::new()));
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await;
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None, None).await;
let session = arc.lock().await;
assert!(
@@ -83,6 +83,15 @@ impl GeneratingGuard {
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
fn transition(&self, target: ConvState) {
// 后台工具占用闸:后台命令在飞时拦截 loop 退出的 Idle 迁移,
// 会话保持 Generating(受理守卫判占用、前端不显示空闲),并记录「loop 已退」
// 供 detached task 终局决策 try_continue 续跑;占用复位由最后一个后台任务
// 终局时放行(audit/bg_run.rs)。用户停止/致命错误路径不经过此闸(仅拦 Idle)。
if target == ConvState::Idle
&& crate::commands::ai::audit::bg_run::hold_blocks_idle(&self.conv_id)
{
return;
}
let conv_states = self
.app_handle
.state::<crate::state::AppState>()
+245 -26
View File
@@ -39,7 +39,7 @@ use df_storage::models::AiProviderRecord;
// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。
use crate::state::{AppState, LlmConcurrency};
use super::audit::{process_tool_calls, ToolExecPolicy};
use super::audit::{process_tool_calls, take_tool_fail_park, ToolExecPolicy};
use super::event_bus::emit_ai_chat_event;
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
use super::conversation::{save_conversation, TokenAccumulator};
@@ -527,6 +527,32 @@ pub(crate) async fn run_agentic_loop(
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0;
// 真目标源(2026-10-15 目标语义重构):pinned_goals 从「工具推理流水账」改为「用户任务原文」
// ——流水账模式下用户真实意图从不进目标体系,压缩/重启后 AI 即失忆(2026-10-12/15
// 两次丢目标事故同根)。首条任务型 user 消息写入首位;已有目标不覆盖(尊重
// UPDATE_GOALS 显式修正);[系统恢复] 引导与超短碎片不入(递归套娃教训)。
if conv.pinned_goals.is_empty() {
let task_text = conv
.messages
.iter()
.find(|m| {
matches!(m.role, df_ai::provider::MessageRole::User)
&& !m.content.starts_with("[系统恢复]")
&& !m.content.starts_with("[补充指令]")
&& m.content.chars().count() >= 4
})
.map(|m| {
let mut t: String = m.content.chars().take(GOAL_MAX_CHARS).collect();
if m.content.chars().count() > GOAL_MAX_CHARS {
t.push('…');
}
t
});
if let Some(t) = task_text {
conv.pinned_goals.push(super::GoalEntry::new(t));
tracing::info!(conv_id = %conv_id, "[ai] 会话目标取首条任务消息(目标语义重构)");
}
}
(
conv.stop_flag.clone(),
conv.notify.clone(),
@@ -969,7 +995,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 门控) ──
@@ -1714,6 +1740,61 @@ async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, S
}
}
/// 展开请求视图里的 namespace 引用:store 命中直取原文;未命中记入返回列表
/// (下标 + 引用路径)交懒回填,条目本身维持引用原样待替换。
fn collect_namespace_misses(history: &mut [ChatMessage], store: &df_ai::namespace_store::NamespaceStore) -> Vec<(usize, String)> {
let mut misses = Vec::new();
for (idx, m) in history.iter_mut().enumerate() {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
match store.read_only(&path) {
Some(original) => m.content = original.to_string(),
None => misses.push((idx, path)),
}
}
}
misses
}
/// 回填应用:找回的引用替换为原文;无原文(真淘汰:LRU 淘汰且消息里无原文)维持
/// 既有富提示/EVICTED_PLACEHOLDER 语义,不误填。
fn apply_namespace_views(
history: &mut [ChatMessage],
misses: &[(usize, String)],
store: &df_ai::namespace_store::NamespaceStore,
recovered: &HashMap<String, String>,
conv_id: &str,
) {
for (idx, path) in misses {
history[*idx].content = match recovered.get(path) {
Some(original) => original.clone(),
None => {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
store
.eviction_rich_notice(path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
}
};
}
}
/// namespace 懒回填的原文源:该会话 DB messages 原文(落库约定存展开后原文)。
/// 仅回填触发时调用(常态零 DB 开销);读失败返回空列表,回填退化为占位语义不阻塞本轮。
async fn load_conv_message_contents(app_handle: &AppHandle, conv_id: &str) -> Vec<String> {
let state = app_handle.state::<AppState>();
match state.ai_messages.list_by_conversation(conv_id).await {
Ok(records) => records.into_iter().map(|r| r.content).collect(),
Err(e) => {
tracing::warn!(conv_id = %conv_id, error = %e, "[namespace] 回填原文拉取失败,维持占位");
Vec::new()
}
}
}
// ============================================================
// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01)
@@ -1945,8 +2026,23 @@ async fn prepare_round_inputs(
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
// build_for_request 读 per_conv.messages。
// conv_id 来源:run_agentic_loop 入参。
let messages = {
let session = session_arc.lock().await;
// 缓存友好上下文总开关(KV `df-ai-cache-friendly-context`,默认开):开则 build_for_request
// 走块状裁剪 + system 锚点 view 层转 user;关则两者旁路(与旧路径逐字节等价)。
// 锁外读值拷贝(下方 build 锁段无 await,对齐 context_lifecycle 的开关读取范式)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
super::prompt::cache_friendly_context_enabled(&st).await
};
let (mut history_msgs, namespace_misses) = {
let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(conv_id).is_some() {
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
// 本轮请求已含截至此刻的全部消息,注入计数清零(=已消化);此后到收敛前
// 再到的注入会计数,收敛出口凭 >0 续一轮消化(见收敛分支守卫)。
session.conv(conv_id).pending_injects = 0;
}
let conv = match session.conv_read(conv_id) {
Some(c) => c,
None => {
@@ -1960,25 +2056,21 @@ async fn prepare_round_inputs(
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。
for m in &mut history_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = session
.namespace_store
.eviction_rich_notice(&path)
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
}
}
// read_only None(条目被 LRU 淘汰 / 重启后 store 清空)→ 记入未命中,下方懒回填;
// 仍无原文才落占位文案(见 apply_namespace_views)。
let namespace_misses = collect_namespace_misses(&mut history_msgs, &session.namespace_store);
(history_msgs, namespace_misses)
};
// namespace 懒回填(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时才触发——从该会话 DB messages 原文按内容哈希找回并重建 store 条目,
// 避免载入即全量扫描;消息里无原文的真淘汰维持既有富提示/占位。
// DB 拉取在会话锁外(锁内禁长 I/O),回填与视图替换短暂重入锁。
if !namespace_misses.is_empty() {
let contents = load_conv_message_contents(app_handle, conv_id).await;
let mut session = session_arc.lock().await;
let refs: Vec<String> = namespace_misses.iter().map(|(_, p)| p.clone()).collect();
let recovered = session.namespace_store.backfill_from_messages(&refs, &contents);
apply_namespace_views(&mut history_msgs, &namespace_misses, &session.namespace_store, &recovered, conv_id);
}
let mut msgs = vec![ChatMessage::system(system_prompt)];
msgs.extend(history_msgs);
@@ -1987,8 +2079,7 @@ async fn prepare_round_inputs(
if let Some(tail) = volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
msgs
};
let messages = msgs;
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
//
@@ -2156,7 +2247,10 @@ async fn finalize_round_output(
})
.collect();
} else {
update_pinned_goals(&mut session, conv_id, tool_calls_acc);
// 工具推理流水账停写(2026-10-15 目标语义重构):「执行 xxx/查看 xxx」是手段记录
// 不是用户目标,入 pinned_goals 挤占真目标且压缩/重启后即失忆;工具轨迹已由
// 轨迹卡与审计表完整覆盖。目标来源收敛为:首条任务消息(入口写入)+UPDATE_GOALS
// 显式修正(上方分支,LLM 显式目标管理继续有效)。
}
}
}
@@ -2283,6 +2377,35 @@ async fn finalize_round_output(
// 正常收敛(loop 主体置 converged 后 break)
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
// 注入未消化守卫(2026-10-14 实测):本轮组装后到达的补充指令,纯问答轮一轮收敛即
// 无人消费(本轮上下文已在生成中定型)。计数>0 → 插提示续一轮,下轮
// build_for_request 自然带上补充内容;计数已 take 清零,同批注入不重复触发。
let pending_injects = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
std::mem::take(&mut session.conv(conv_id).pending_injects)
} else {
0
}
};
if pending_injects > 0 {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
let mut tip = ChatMessage::system(format!(
"用户在回答生成期间补充了 {pending_injects} 条消息(见对话记录中的补充指令);\
请结合补充内容继续或修正上面的回答,勿忽略。"
));
tip.model = Some(resolved_model.to_string());
conv.messages.push(tip);
}
tracing::info!(
conv_id = %conv_id, pending = pending_injects,
"[ai] 收敛时检出未消化注入,插提示续一轮消化"
);
// 不 return Converged:掉到函数尾部 RunTools(本轮无工具空转),
// loop 顶部正常续轮,补充指令获得完整消费轮。
} else {
if round_truncated_by_max_tokens {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
@@ -2298,6 +2421,7 @@ async fn finalize_round_output(
return RoundEpilogue::Converged;
}
}
}
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
@@ -2355,6 +2479,24 @@ async fn process_round_tools(
return ToolsOutcome::Exit;
}
// B5d 工具连败熔断终局:同会话失败记忆拦截连击达阈且期间无成功 → 强制停靠。
// 拦截计数在 audit 层(execute_with_heartbeat)累积,停靠在 loop 层执行:guard 复位
// 与 emit_task_parked 正典序列只有本层触达,audit 层只判定不执行。停靠即终局,
// 不再给模型无视 blocked 提示继续重试的机会(实证:拦截后模型仍原样重试 10+ 次)。
if take_tool_fail_park(conv_id) {
tracing::warn!(
conv_id = %conv_id,
"[ai] 工具连续失败熔断:失败记忆拦截连击达阈,任务强制停靠"
);
guard.reset().await;
crate::commands::ai::emit_task_parked(
app_handle, conv_id,
"工具连续失败熔断(同一调用被失败记忆反复拦截仍重试),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续",
"circuit_breaker",
);
return ToolsOutcome::Exit;
}
// AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。
// 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。
if is_tool_call_over_limit(round_tool_call_count) {
@@ -3176,11 +3318,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 短路。
@@ -3306,6 +3450,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)
}
@@ -3514,6 +3664,7 @@ fn push_assistant_message(
// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ──
// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。
// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。
#[allow(dead_code)]
fn update_pinned_goals(
session: &mut AiSession,
conv_id: &str,
@@ -3813,3 +3964,71 @@ mod empty_round_guard_tests {
assert!(empty_round_should_park(4, 3));
}
}
// namespace 懒回填契约(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
// 未命中时从 DB messages 原文按内容哈希回填;二次展开走 store 不再触发回填;
// 消息里无原文的真淘汰维持占位不误填。全链 = collect/apply 两纯函数 + store 回填 API。
#[cfg(test)]
mod namespace_backfill_tests {
use super::{apply_namespace_views, collect_namespace_misses};
use df_ai::namespace_store::{NamespaceStore, EVICTED_PLACEHOLDER};
use df_ai::provider::ChatMessage;
// 引用路径取自热 store(模拟重启前生成),与生产 store() 同源
fn ref_path_of(content: &str) -> String {
NamespaceStore::new(1 << 20).store("read_file", content)
}
fn history_with_ref(path: &str) -> Vec<ChatMessage> {
vec![ChatMessage::tool_result("tc1", path)]
}
#[test]
fn miss_backfills_from_message_original_and_expands() {
let original = "文件A的内容行\n".repeat(300);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default(); // 重启后空 store
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
assert_eq!(misses.len(), 1, "store 空应记未命中");
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, original, "展开应得到原文而非占位");
assert_eq!(
store.read_only(&path).map(str::to_owned),
Some(original),
"store 应已回填该条目",
);
}
#[test]
fn second_expansion_hits_store_without_rebackfill() {
let original = "x".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
// 下一轮重建视图:store 已命中,未命中为空 → 回填阶段(含 DB 拉取)不再触发
let mut history2 = history_with_ref(&path);
let misses2 = collect_namespace_misses(&mut history2, &store);
assert!(misses2.is_empty(), "二次展开应走 store,无未命中");
assert_eq!(history2[0].content, original);
}
#[test]
fn real_eviction_keeps_placeholder_without_wrong_fill() {
let original = "y".repeat(4096);
let path = ref_path_of(&original);
let mut store = NamespaceStore::default();
let mut history = history_with_ref(&path);
let misses = collect_namespace_misses(&mut history, &store);
// 消息里无对应原文(仅无关内容)→ 回填为空,维持占位,store 不误填
let recovered = store.backfill_from_messages(&[path.clone()], &["无关消息".to_string()]);
assert!(recovered.is_empty(), "无对应原文不应产生回填结果");
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
assert_eq!(history[0].content, EVICTED_PLACEHOLDER);
assert_eq!(store.read_only(&path), None, "真淘汰不得误填 store");
}
}
+15 -1
View File
@@ -48,7 +48,7 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
@@ -892,9 +892,23 @@ async fn run_sub_agent_loop_inner(
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
// 缓存友好上下文总开关(与主 loop 同款:KV `df-ai-cache-friendly-context`,默认开;
// 锁外读值拷贝,下方锁段无 await)。
let cache_friendly = {
let st = app_handle.state::<AppState>();
crate::commands::ai::prompt::cache_friendly_context_enabled(&st).await
};
let (messages, estimated_prompt) = {
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await;
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
if session.conv_read(&child_conv_id).is_some() {
session
.conv(&child_conv_id)
.messages
.set_cache_friendly(cache_friendly);
}
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
+539
View File
@@ -0,0 +1,539 @@
//! run_command 超时转后台执行。
//!
//! 长命令(构建/测试)整轮阻塞 agent——`run_command` 占 >90s 工具桶的 95%。
//! 本模块把超 grace 窗口未完成的工具 future 移交 detached task:LLM 立即拿到
//! 「已转后台」非终态 tool_result 继续轮次(或自然收尾);命令跑完后结果经
//! inject_runtime_message 注入原会话,并按需 try_continue_agent_loop 续跑。
//!
//! 三条红线(勘察定):
//! - 心跳接管:execute_with_heartbeat 的心跳 task 随函数返回即 abort,前端 130s
//! 看门狗靠它续命——detached task 自持 30s 心跳并刷新 per_conv.last_heartbeat
//! (loop 存活证据,与 agentic heartbeat_loop / ai_chat_stop 兜底判活同源)。
//! - 会话占用:后台运行期会话必须维持 Generating(受理守卫判占用)。loop 收敛
//! 退出时 GeneratingGuard 的 Idle 迁移被占用闸拦截(guard.rs),最后一个
//! detached 终局时才放行复位——占用语义单点落在 guard/conv_state 层,loop
//! 主体零改动。用户停止(Stopping)与致命错误(Error)不拦截:前者终局时
//! 结果落 system 通知后放行,后者保持 Error 由用户重试流程自管。
//! - 回填禁楔:结果注入复用 inject_runtime_message(尾部追加,既有 system 锚点
//! 避让保护),不自写裸 insert——楔进 assistant(tool_calls) 头与其 tool result
//! 之间会破坏「result 紧随其头」的上游不变式直接报 400;同 tool_call_id
//! 也不得二次回填 tool_result
//! (非终态 result 已占位,重复 id 会触发 provider 400)。
//!
//! 开关:KV df-ai-run-command-background(默认关=同步路径逐字节不变)+
//! df-ai-run-command-grace-secs(默认 90,解析失败/0 按默认)。仅主会话生效:
//! 子代理有独立循环(run_sub_agent_loop)与挂钟预算,try_continue_agent_loop 是
//! 主循环恢复通道,子会话误续跑会绕开子代理全部约束,故 conv_origins 命中
//! sub_agent 一律不转后台(超时仍走原 outer_secs 腰斩)。
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use dashmap::DashMap;
use tauri::{AppHandle, Manager};
use crate::commands::ai::agentic::conv_state::ConvState;
use crate::commands::ai::agentic::helpers::insert_system_notice;
use crate::commands::ai::agentic::try_continue_agent_loop;
use crate::commands::ai::commands::chat::inject_runtime_message;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::AiChatEvent;
use crate::state::{AppState, SettingsNorm};
pub(crate) const BG_SWITCH_KEY: &str = "df-ai-run-command-background";
pub(crate) const BG_GRACE_KEY: &str = "df-ai-run-command-grace-secs";
const DEFAULT_GRACE_SECS: u64 = 90;
/// 与 sub_agent.rs SUB_AGENT_ORIGIN 同值(该 const 私有,此处按值比对 conv_origins)
const SUB_AGENT_ORIGIN: &str = "sub_agent";
// ============================================================
// 资格判定
// ============================================================
/// 后台化资格:开关开 + run_command + 主会话 → Some(grace 秒);否则 None
/// (调用方走同步原路径,行为逐字节不变)。每工具一次 KV 读(可接受成本),
/// 非 run_command 先短路不触 KV。
pub(crate) async fn background_grace_secs(
app: &AppHandle,
name: &str,
conv_id: &str,
) -> Option<u64> {
if name != "run_command" {
return None;
}
let state = app.state::<AppState>();
if !state.settings.get_bool_norm(BG_SWITCH_KEY, false).await {
return None;
}
let is_sub = {
let session = state.ai_session.lock().await;
session
.conv_origins
.get(conv_id)
.map(|o| o == SUB_AGENT_ORIGIN)
.unwrap_or(false)
};
if is_sub {
return None;
}
Some(parse_grace(state.settings.get_string_norm(BG_GRACE_KEY).await))
}
/// grace 解析:非法/0 归默认 90(0 会让命令瞬时转后台,视为配置错误不采纳)
fn parse_grace(raw: Option<String>) -> u64 {
raw.and_then(|s| s.parse::<u64>().ok())
.filter(|g| *g > 0)
.unwrap_or(DEFAULT_GRACE_SECS)
}
// ============================================================
// per-conv 后台占用登记(会话删除/停止感知的最小形态:per-conv 计数)
// ============================================================
/// in_flight = 在飞 detached 数;loop_exited = loop 已收敛退出但被占用闸拦下
/// 未复位 Idle(guard.rs 置位,终局决策消费)。
struct BgHold {
in_flight: usize,
loop_exited: bool,
}
static BG_HOLDS: OnceLock<DashMap<String, BgHold>> = OnceLock::new();
fn holds() -> &'static DashMap<String, BgHold> {
BG_HOLDS.get_or_init(DashMap::new)
}
/// 占用闸(guard.rs 调用):后台任务在飞时拦截 loop 退出的 Idle 迁移,并记录
/// 「loop 已退」供 detached 终局决策决定是否 try_continue 续跑。
pub(crate) fn hold_blocks_idle(conv_id: &str) -> bool {
let Some(mut hold) = holds().get_mut(conv_id) else {
return false;
};
if hold.in_flight == 0 {
return false;
}
hold.loop_exited = true;
true
}
/// 只读在飞判定(终局放行用,不带置位副作用)
fn hold_in_flight(conv_id: &str) -> bool {
holds().get(conv_id).map(|h| h.in_flight > 0).unwrap_or(false)
}
fn hold_acquire(conv_id: &str) {
holds()
.entry(conv_id.to_string())
.or_insert(BgHold { in_flight: 0, loop_exited: false })
.in_flight += 1;
}
/// hold 名额 RAII:正常路径显式 [`release`](Self::release)(返回是否最后一个);
/// detached task 异常 unwind 时 Drop 兜底释放,防名额泄漏致会话永久卡 Generating。
struct HoldGuard {
conv: String,
released: bool,
}
impl HoldGuard {
/// 接管 spawn_detached 已登记的名额(不重复 acquire)
fn takeover(conv_id: &str) -> Self {
Self { conv: conv_id.to_string(), released: false }
}
fn release(&mut self) -> bool {
if self.released {
return false;
}
self.released = true;
hold_release(&self.conv)
}
}
impl Drop for HoldGuard {
fn drop(&mut self) {
self.release();
}
}
/// 释放一个在飞名额,返回是否最后一个(终局复位语义只在最后一个生效)
fn hold_release(conv_id: &str) -> bool {
let last = match holds().get_mut(conv_id) {
Some(mut h) => {
h.in_flight = h.in_flight.saturating_sub(1);
h.in_flight == 0
}
None => true, // 条目缺失视为无在飞(防御,不该发生)
};
if last {
holds().remove(conv_id);
}
last
}
/// 读「loop 已退出」标记(只读不消费,须在 hold.release 前调——release 摘除条目
/// 会连带吞掉标记):true=loop 已收敛退出,最后一个名额终局时需 try_continue 续跑
/// 消费注入消息;false=loop 仍在飞,活跃 loop 下一轮自然消费,续跑反而抢断当前轮。
fn peek_loop_exited(conv_id: &str) -> bool {
holds()
.get(conv_id)
.map(|h| h.loop_exited)
.unwrap_or(false)
}
// ============================================================
// grace 竞速 + 移交
// ============================================================
/// grace 窗口三路竞速结果
pub(crate) enum GraceRace<R> {
/// 窗口内完成:与同步原路径结果完全一致
Done(R),
/// 窗口超时:future 未被消费,归还调用方移交 detached task
Expired,
/// 监视判死(会话删除/审批失效):同原提前退出语义
Aborted(String),
}
/// exec 与 grace 计时、监视 future 三路 select。select 只借 `&mut *exec`,
/// 借用随 select 结束解除,超时分支把未消费的 future 原样归还不违反所有权
/// (这正是「移交 detached」的机制基础:future 在竞速失败后仍可独立驱动)。
pub(crate) async fn race_grace<R, W>(
exec: Pin<Box<dyn Future<Output = R> + Send>>,
grace: Duration,
watch: W,
) -> (GraceRace<R>, Pin<Box<dyn Future<Output = R> + Send>>)
where
W: Future<Output = String>,
{
let mut exec = exec;
let raced = tokio::select! {
r = exec.as_mut() => GraceRace::Done(r),
_ = tokio::time::sleep(grace) => GraceRace::Expired,
reason = watch => GraceRace::Aborted(reason),
};
(raced, exec)
}
/// 非终态 tool_result:LLM 立即拿到明确「已转后台」信号,可继续轮或自然收尾;
/// 完成后结果自动回传本会话(ReAct 语义变更,开关默认关保回退)。
pub(crate) fn background_payload(grace: u64) -> serde_json::Value {
serde_json::json!({
"status": "background",
"message": format!("已转后台执行(超 {grace}s),完成后结果自动回传本会话"),
})
}
/// grace 超时移交:登记占用(先于返回,guard 占用闸随即生效)→ spawn detached。
/// **必须是普通同步函数(内部无 await)**:调用方(execute_with_heartbeat)以普通调用
/// 而非 await 进入,background_run 的 coroutine 不进调用方协程的 Send 验证链——否则
/// background_run→try_continue→run_agentic_loop→execute_with_heartbeat 构成自动 trait
/// 验证环(rustc E0391)。
pub(crate) fn spawn_detached(
exec: Pin<Box<dyn Future<Output = anyhow::Result<serde_json::Value>> + Send>>,
name: String,
args_for_memory: serde_json::Value,
app: AppHandle,
conv_id: String,
tool_call_id: String,
last_heartbeat: Option<Arc<AtomicU64>>,
) {
// F2 存活心跳证据源:detached 续写 per_conv.last_heartbeat(ai_chat_stop 兜底判活同源);
// conv 已删时给孤儿 Arc(store 无消费者,仅保类型)
let hb = last_heartbeat.unwrap_or_else(|| Arc::new(AtomicU64::new(0)));
hold_acquire(&conv_id);
tokio::spawn(background_run(exec, name, args_for_memory, app, conv_id, tool_call_id, hb));
}
/// detached task 本体:自持心跳 + 会话删除自中止 + 失败记忆 + 完成通知 + 回填续跑。
/// 后台失败同样有终局(注入失败消息),不静默蒸发。
async fn background_run(
mut exec: Pin<Box<dyn Future<Output = anyhow::Result<serde_json::Value>> + Send>>,
name: String,
args_for_memory: serde_json::Value,
app: AppHandle,
conv_id: String,
tool_call_id: String,
last_heartbeat: Arc<AtomicU64>,
) {
// 接管 spawn_detached 已登记的名额:panic unwind 时 Drop 兜底释放,防占用泄漏
let mut hold = HoldGuard::takeover(&conv_id);
// 自持心跳:接管随 execute_with_heartbeat 返回被 abort 的原心跳,30s emit +
// 刷 last_heartbeat,后台运行期前端看门狗持续续命
let hb_stop = Arc::new(AtomicBool::new(false));
let heartbeat = {
let app = app.clone();
let conv = conv_id.clone();
let stop = hb_stop.clone();
let hb_arc = last_heartbeat.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await; // 弃首 tick(对齐原心跳,首 tick 立即返回)
loop {
interval.tick().await;
if stop.load(Ordering::SeqCst) {
break;
}
hb_arc.store(df_types::now_millis() as u64, Ordering::SeqCst);
emit_ai_chat_event(&app, AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) });
}
})
};
// 会话删除自中止(对齐 execute_with_heartbeat 提前退出监视):执行中删除会话,
// 后台命令最多再跑 2s 即中止,不再空转到命令自身超时。审批失效监视不武装
// (detached 诞生于执行期,pending_approvals 必无此 id,按字面监视为死分支)
let watch_app = app.clone();
let watch_conv = conv_id.clone();
let deleted_watch = async move {
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
let state = watch_app.state::<AppState>();
let session = state.ai_session.lock().await;
if session.deleted_convs.contains(&watch_conv) {
return format!("会话 {} 已删除", watch_conv);
}
}
};
let outcome = tokio::select! {
r = exec.as_mut() => Ok(r),
reason = deleted_watch => Err(reason),
};
hb_stop.store(true, Ordering::SeqCst);
heartbeat.abort();
// 失败记忆(与同步路径 postlude 同源):成功清零 / 失败累加,后台失败也进
// 断路统计,防 LLM 拿到失败后原样重调
let (ok, result_text) = match outcome {
Ok(Ok(v)) => {
crate::commands::ai::tool_failure_memory::record(&name, &args_for_memory, true, None);
(true, v.to_string())
}
Ok(Err(e)) => {
let msg = e.to_string();
crate::commands::ai::tool_failure_memory::record(&name, &args_for_memory, false, Some(msg.clone()));
(false, format!("工具 {} 后台执行失败: {}", name, msg))
}
Err(reason) => {
crate::commands::ai::tool_failure_memory::record(&name, &args_for_memory, false, Some(reason.clone()));
(false, format!("工具 {} 后台执行中止: {}", name, reason))
}
};
// 完成通知:emit Completed 让前端工具卡从「已转后台」收敛为真实结果
emit_ai_chat_event(&app, AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: serde_json::Value::String(result_text.clone()),
conversation_id: Some(conv_id.clone()),
});
let note = if ok {
format!("后台命令「{name}」已执行完成,结果如下:\n{result_text}")
} else {
format!("后台命令「{name}」执行失败,详情如下:\n{result_text}")
};
let state = app.state::<AppState>();
// 先注入后释放名额:占用期 is_active 恒真(Generating),注入守卫全场景可过;
// 先释放会在「名额清零且 loop 已退」窗口露出非活跃态,注入误走降级分支
match inject_runtime_message(&state, &conv_id, &note).await {
Ok(true) => {
// 先读「loop 已退」再释放名额(release 摘除条目会吞标记);多名额时
// 标记保持 true,由最后一个名额的终局决策消费(was_last 门控防重复续跑)
let loop_gone = peek_loop_exited(&conv_id);
let was_last = hold.release();
if was_last && loop_gone {
// loop 已收敛退出:正典续跑通道消费注入消息(iteration 累计透传,
// 对齐 ai_approve 续跑口径,防反复续跑跑满轮次预算)
let start_iter = {
let session = state.ai_session.lock().await;
session.conv_read(&conv_id).map(|c| c.iteration_used).unwrap_or(0)
};
// Box 擦除内层 coroutine 类型:background_run → try_continue → run_agentic_loop
// → execute_with_heartbeat → spawn(background_run) 构成 Send 自动 trait 验证环,
// trait object 在此边截断(行为不变,仅类型层面)。
let continued = Box::pin(try_continue_agent_loop(&app, &state, &conv_id, start_iter))
as Pin<Box<dyn Future<Output = ()> + Send>>;
continued.await;
}
// 其余情况:loop 仍在飞/名额未尽,活跃 loop 下轮自然消费注入消息
}
Ok(false) => {
// conv 不存在(已删/未建):仅释放名额,状态随删除链路清理
hold.release();
}
Err(_) => {
// 非活跃(用户停止 Stopping / 致命错误 Error):结果落 system 通知留档
// (下轮打开会话可见),最后一个名额终局时放行占用复位
hold.release();
insert_system_notice(
&state.ai_session.clone(), &conv_id, &note,
"后台命令完成回填(会话非活跃,注入通道已关)",
).await;
if !hold_in_flight(&conv_id) {
release_occupancy(&app, &conv_id).await;
}
}
}
}
/// 终局放行:Generating/Stopping → Idle + 状态事件(对齐 guard.transition 的
/// emit + 托盘同步);Error 保持原态(用户重试流程自管,结果已落 system 通知)。
async fn release_occupancy(app: &AppHandle, conv_id: &str) {
let state = app.state::<AppState>();
let cur = state.conv_states.get(conv_id);
if !matches!(cur, ConvState::Generating | ConvState::Stopping) {
return;
}
if let Ok(next) = state.conv_states.transition(conv_id, ConvState::Idle) {
emit_ai_chat_event(app, AiChatEvent::AiConvStateChanged {
conv_state: next,
conversation_id: Some(conv_id.to_string()),
});
crate::tray::update_tray_runtime(app);
}
}
// ============================================================
// 单测(纯逻辑部分;execute_with_heartbeat 集成路径依赖 AppHandle 无测点,
// 开关关=同步路径逐字节不变,由存量测试全绿作回归证明)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ── 非终态 tool_result 话术(ReAct 语义变更的核心载体,锁定文案) ──
#[test]
fn payload_states_background_and_promises_callback() {
let p = background_payload(90);
assert_eq!(p["status"], "background");
let msg = p["message"].as_str().unwrap();
assert!(msg.contains("已转后台执行(超 90s)"), "实际: {msg}");
assert!(msg.contains("完成后结果自动回传本会话"), "实际: {msg}");
}
#[test]
fn payload_carries_configured_grace() {
let p = background_payload(30);
assert!(p["message"].as_str().unwrap().contains("(超 30s)"));
}
// ── grace 配置解析(默认 90 / 非法与 0 归默认 / 合法值采纳) ──
#[test]
fn grace_defaults_when_missing_or_invalid() {
assert_eq!(parse_grace(None), 90);
assert_eq!(parse_grace(Some("abc".to_string())), 90);
assert_eq!(parse_grace(Some("0".to_string())), 90, "0 视为配置错误归默认");
assert_eq!(parse_grace(Some("-5".to_string())), 90);
}
#[test]
fn grace_accepts_valid_value() {
assert_eq!(parse_grace(Some("30".to_string())), 30);
assert_eq!(parse_grace(Some("600".to_string())), 600);
}
// ── per-conv 占用登记(会话占用红线的注册表语义) ──
// 静态表进程级共享:各测用例用独立 conv key + 结束清理,防并行互踩
#[test]
fn hold_blocks_idle_only_while_in_flight() {
let key = "test-hold-basic-1";
assert!(!hold_blocks_idle(key), "未登记不拦截");
hold_acquire(key);
assert!(hold_blocks_idle(key), "在飞时拦截 Idle 迁移");
assert!(hold_release(key), "单名额释放即最后");
assert!(!hold_in_flight(key), "释放后无在飞");
assert!(!hold_blocks_idle(key), "释放后不再拦截");
holds().remove(key);
}
#[test]
fn hold_release_reports_last_only() {
let key = "test-hold-last-2";
hold_acquire(key);
hold_acquire(key);
assert!(!hold_release(key), "仍有 1 个在飞,非最后");
assert!(hold_release(key), "最后一个释放返 true");
assert!(!hold_in_flight(key));
}
#[test]
fn loop_exited_set_on_block_and_survives_until_release() {
let key = "test-hold-exit-3";
hold_acquire(key);
assert!(!peek_loop_exited(key), "未拦截前置位应为 false");
// loop 收敛退出经 guard 占用闸 → loop_exited 置位
assert!(hold_blocks_idle(key));
// 只读不消费:终局决策在 release 前读,多名额时标记保持供最后一个消费
assert!(peek_loop_exited(key));
assert!(peek_loop_exited(key), "peek 不消费,重复读恒 true");
holds().remove(key);
}
// ── grace 竞速(机制核心:窗口内完成=原路径结果一致;超时=future 可移交) ──
#[tokio::test]
async fn race_done_within_window_matches_direct_result() {
let exec = Box::pin(async {
Ok::<serde_json::Value, anyhow::Error>(serde_json::json!({"exit": 0, "out": "done"}))
});
let (raced, _fut) = race_grace(
exec,
Duration::from_secs(5),
std::future::pending::<String>(),
).await;
match raced {
GraceRace::Done(Ok(v)) => assert_eq!(v, serde_json::json!({"exit": 0, "out": "done"})),
other => panic!("grace 内完成应返 Done 且与同步结果一致,实际 {:?}", result_tag(&other)),
}
}
#[tokio::test]
async fn race_expired_hands_future_to_detached() {
let exec = Box::pin(async {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok::<serde_json::Value, anyhow::Error>(serde_json::json!({"late": true}))
});
let (raced, mut fut) = race_grace(
exec,
Duration::from_millis(30),
std::future::pending::<String>(),
).await;
assert!(matches!(raced, GraceRace::Expired), "超时应返 Expired");
// 移交语义:future 未被消费,spawn 后仍跑到终局(detached 启动的机制本质)
let handle = tokio::spawn(async move { fut.as_mut().await });
let v = tokio::time::timeout(Duration::from_secs(10), handle).await
.expect("detached 续跑应在时限内完成").unwrap().unwrap();
assert_eq!(v, serde_json::json!({"late": true}));
}
#[tokio::test]
async fn race_watch_abort_wins_over_pending_exec() {
let exec = Box::pin(std::future::pending::<Result<serde_json::Value, anyhow::Error>>());
let (raced, _fut) = race_grace(
exec,
Duration::from_secs(60),
std::future::ready("会话 1 已删除".to_string()),
).await;
match raced {
GraceRace::Aborted(reason) => assert!(reason.contains("已删除")),
other => panic!("监视判死应返 Aborted,实际 {:?}", result_tag(&other)),
}
}
/// 测试辅助:竞速结果打标签(GraceRace 未要求 Debug, panic 消息用)
fn result_tag<R>(r: &GraceRace<R>) -> &'static str {
match r {
GraceRace::Done(_) => "Done",
GraceRace::Expired => "Expired",
GraceRace::Aborted(_) => "Aborted",
}
}
}
+291 -40
View File
@@ -24,6 +24,9 @@ use crate::commands::ai::is_destructive_command;
mod utils;
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars_ellipsis};
// bg_run(audit/bg_run.rs):run_command 超时转后台(KV 默认关)。
pub(crate) mod bg_run;
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
// 第五批从本文件抽离,行为零变更。
mod diff;
@@ -225,6 +228,76 @@ fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
}
}
// B5d 工具连败升级(快速放弃+换路引导)。三级全部复用既有失败记忆(tool_failure_memory)
// 的连败计数,不另造计数轨道:首败=裸错误原样;第 2 次失败=错误包附软提示(勿原样重试);
// 第 3 次起=入口拦截包升级为强制换路通牒(换工具/查知识库/向用户说明)。拦截在本会话
// 连续达 PARK_AFTER_BLOCKS 且期间无任何工具成功 → take_tool_fail_park 请求 loop 层停靠
// (2026-10-12 实证:拦截后模型仍原样重试 10+ 次,软提示拦不住,须有终局)。
/// 第 2 次失败附带的软提示:提醒而非禁止,给模型自行调参换路的机会。
const HINT_AVOID_RETRY: &str = "已连续失败 2 次,勿盲目重试同命令;考虑调整参数或换路径";
/// 第 3 次起拦截包附带的强制换路提示(终局通牒,含知识库检索引导)。
const HINT_MUST_SWITCH: &str = "连续失败≥3次,禁止重试同命令,必须:①换工具或换路径 ②调 knowledge_search 查既有经验 ③仍无法绕过则向用户说明并停手";
/// 同会话失败记忆拦截连续达此值(且期间无任何工具成功)→ 请求 loop 层强制停靠。
const PARK_AFTER_BLOCKS: u32 = 5;
/// 同会话失败记忆拦截连击计数:键=conv_id,值=自该会话最近一次成功执行以来的拦截次数。
/// 与失败记忆分开记:拦截不触发工具执行,失败记忆的连败数停在阈值不再增长,连击数
/// 只能另记;按会话而非按调用键记,参数抖动绕开调用键时连击依旧成立。
static BLOCK_STREAKS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, u32>>> =
std::sync::OnceLock::new();
fn bump_block_streak(conv_id: &str) -> u32 {
let streaks = BLOCK_STREAKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut m = streaks.lock().unwrap_or_else(|p| p.into_inner());
let n = m.entry(conv_id.to_string()).or_insert(0);
*n += 1;
*n
}
/// 任一工具成功即打断连击(会话有产出,不再是纯挣扎状态)。
fn clear_block_streak(conv_id: &str) {
if let Some(m) = BLOCK_STREAKS.get() {
m.lock().unwrap_or_else(|p| p.into_inner()).remove(conv_id);
}
}
/// loop 层消费停靠请求:连击达 PARK_AFTER_BLOCKS 且未被成功清零 → 取走标志返 true。
pub(crate) fn take_tool_fail_park(conv_id: &str) -> bool {
let Some(m) = BLOCK_STREAKS.get() else { return false };
let mut map = m.lock().unwrap_or_else(|p| p.into_inner());
match map.get(conv_id) {
Some(&n) if n >= PARK_AFTER_BLOCKS => {
map.remove(conv_id);
true
}
_ => false,
}
}
/// 第 2 次失败的错误包附软提示;首败原样透传。
fn with_streak_hint(err: anyhow::Error, streak_hit: bool) -> anyhow::Error {
if streak_hit {
anyhow::anyhow!("{err}\n{HINT_AVOID_RETRY}")
} else {
err
}
}
/// 拦截包升级:hint 字段换为强制换路通牒,status/tool/consecutive_failures/last_error
/// 保留不丢。提示文本跨次恒定(不带递增计数):L1 断路器按错误文本哈希归一 key,
/// 变化文本会打散其连败累计;连击数走 tracing 日志与停靠判定,不进模型可见包。
fn upgrade_blocked_hint(blocked: &str) -> String {
let mut v: serde_json::Value = serde_json::from_str(blocked)
.unwrap_or_else(|_| serde_json::json!({ "status": "blocked_by_failure_memory" }));
if let Some(obj) = v.as_object_mut() {
obj.insert("hint".into(), serde_json::json!(HINT_MUST_SWITCH));
}
v.to_string()
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成)
///
@@ -243,8 +316,11 @@ fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
/// `tools` 形参为 `&Arc<AiToolRegistry>`:后台化移交需把工具 future 移入
/// detached task('static),调用点本就持有 Arc(process_tool_calls 三处 clone 局部 +
/// chat.rs 审批/授权两窗口 `&state.ai_tools`),签名升级对调用方零改动。
pub(crate) async fn execute_with_heartbeat(
tools: &AiToolRegistry,
tools: &Arc<AiToolRegistry>,
name: &str,
args: serde_json::Value,
app: &AppHandle,
@@ -255,17 +331,20 @@ pub(crate) async fn execute_with_heartbeat(
use tokio::time::Duration;
// ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ──
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃),
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(强制换路通牒,升级版),
// 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。
// 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。
// 拦截同时累加会话连击,达 PARK_AFTER_BLOCKS 由 loop 层(process_round_tools)停靠收场。
if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) {
let blocks = bump_block_streak(conv_id);
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[failure-memory] 同调用连败达标,执行前拦截"
blocks,
"[failure-memory] 同调用连败达标,执行前拦截(本会话累计第 {blocks} 次)"
);
return Err(anyhow::anyhow!(blocked));
return Err(anyhow::anyhow!(upgrade_blocked_hint(&blocked)));
}
// record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。
// 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。
@@ -358,6 +437,92 @@ pub(crate) async fn execute_with_heartbeat(
} else {
60
};
// ── 后台化资格(run_command 专属,KV 默认关):None=同步原路径 ──
// 资格判定含主会话排除(子代理独立循环不转后台),详见 bg_run 模块文档。
// 前置于 exec_fut 构造:后台路径须以 Arc 克隆 + name 所有权重建工具 future
// (原构造借用 &tools/&name,非 'static,无法移交 detached task)。
let bg_grace = bg_run::background_grace_secs(app, name, conv_id).await;
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
// 长工具最多再跑 2s 即中止,不再空转到超时上限。
let tc_watch = tool_call_id.to_string();
let was_pending_at_entry = {
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
session.pending_approvals.contains_key(&tc_watch)
};
let exec_result = if let Some(grace) = bg_grace {
// ── 后台路径:grace 窗口三路 select(窗口内完成 / 超时移交 / 监视判死)──
// owned 'static future:Arc 克隆 + name/args 所有权重建(execute 语义不变:
// 查表 + handler 调用);command_stream sink 注入保留——后台期实时流式仍进原工具卡。
// 窗口内完成与同步路径同语义(含监视);超时把未消费 future 移交 detached task,
// 本函数立即返非终态 tool_result(「已转后台」),loop 与前端不再被该命令阻塞。
// outer_secs 在此被 grace 取代:后台期命令受工具内部 timeout_secs 约束,不再需要
// 外层硬腰斩(移交后外层腰斩语义无从谈起);监视判死仍生效(会话删除即中止)。
let tools_owned = tools.clone();
let name_owned = name.to_string();
let args_owned = args.clone();
let sink = crate::commands::ai::command_stream::CommandSink::new(
app.clone(),
tool_call_id.to_string(),
Some(conv_id.to_string()),
);
let exec = Box::pin(crate::commands::ai::tools::spawn_agent::scope_caller_conv(
conv_id.to_string(),
crate::commands::ai::command_stream::scope(
Some(sink),
async move { tools_owned.execute(&name_owned, args_owned).await },
),
));
let watch = abort_when_invalid(app.clone(), conv_id.to_string(), tc_watch, was_pending_at_entry);
let (raced, exec) =
bg_run::race_grace(exec, Duration::from_secs(grace), watch).await;
match raced {
bg_run::GraceRace::Done(r) => r,
bg_run::GraceRace::Expired => {
tracing::info!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
grace,
"[ai] run_command 超 grace 窗口,移交后台执行(LLM 收非终态 tool_result)"
);
// 存活心跳证据源:detached 续写 per_conv.last_heartbeat(ai_chat_stop
// 兜底判活同源);conv 已删传 None(bg_run 给孤儿 Arc 仅保类型)
let detached_hb = {
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
session.conv_read(conv_id).map(|c| c.last_heartbeat.clone())
};
bg_run::spawn_detached(
exec,
name.to_string(),
args_for_memory,
app.clone(),
conv_id.to_string(),
tool_call_id.to_string(),
detached_hb,
);
return Ok(bg_run::background_payload(grace));
}
bg_run::GraceRace::Aborted(reason) => {
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[ai] execute_with_heartbeat 提前退出: {reason}(防空转)"
);
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
}
}
} else {
// ── 同步原路径(构造段 + select 与历史版本逐字一致)──
// run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id),
// handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。
// 非 run_command 不注入 sink(handler 内 emit_output 静默 noop),零行为变更。
@@ -394,37 +559,7 @@ pub(crate) async fn execute_with_heartbeat(
conv_id.to_string(),
exec_fut,
);
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
// 长工具最多再跑 2s 即中止,不再空转到 outer_secs 上限。
let tc_watch = tool_call_id.to_string();
let was_pending_at_entry = {
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
session.pending_approvals.contains_key(&tc_watch)
};
let watch_app = app.clone();
let watch_conv = conv_id.to_string();
let abort_watch = async move {
loop {
// 2s 轮询:短锁纯读(锁不跨 await 持有);中止延迟 ≤2s 与锁竞争频率的权衡
tokio::time::sleep(Duration::from_secs(2)).await;
let st = watch_app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
if session.deleted_convs.contains(&watch_conv) {
return format!("会话 {} 已删除", watch_conv);
}
if was_pending_at_entry && !session.pending_approvals.contains_key(&tc_watch) {
return format!("审批 {} 已不在挂起表", tc_watch);
}
}
};
let abort_watch = abort_when_invalid(app.clone(), conv_id.to_string(), tc_watch, was_pending_at_entry);
tokio::pin!(exec_fut);
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
@@ -451,18 +586,60 @@ pub(crate) async fn execute_with_heartbeat(
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
}
};
exec_result
};
// ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ──
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数,
// ── B5d 工具失败记忆:执行后回写 + 连败升级提示 ──
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀)并打断拦截连击;失败累加计数,
// 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。
match &exec_result {
Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None),
Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())),
// 失败侧回探 precheck:命中即本次为第 2 次失败,错误包附软提示(第 3 次起由入口拦截包接管)。
let exec_result = match exec_result {
Ok(v) => {
super::tool_failure_memory::record(name, &args_for_memory, true, None);
clear_block_streak(conv_id);
Ok(v)
}
Err(e) => {
super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string()));
let streak_hit = super::tool_failure_memory::precheck(name, &args_for_memory).is_some();
Err(with_streak_hint(e, streak_hit))
}
};
exec_result
}
/// 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行。
/// 工厂形态:同步路径与后台 grace 路径各消费一个实例(同一快照语义)。
///
/// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
/// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
/// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
/// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
/// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
/// 长工具最多再跑 2s 即中止,不再空转到超时上限。
fn abort_when_invalid(
app: AppHandle,
conv_id: String,
tool_call_id: String,
was_pending_at_entry: bool,
) -> impl std::future::Future<Output = String> {
async move {
loop {
// 2s 轮询:短锁纯读(锁不跨 await 持有);中止延迟 ≤2s 与锁竞争频率的权衡
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
if session.deleted_convs.contains(&conv_id) {
return format!("会话 {} 已删除", conv_id);
}
if was_pending_at_entry && !session.pending_approvals.contains_key(&tool_call_id) {
return format!("审批 {} 已不在挂起表", tool_call_id);
}
}
}
}
// ============================================================
// 审批超时主动收敛(定时器) — 治「审批过期后用户不操作则永挂」
// ============================================================
@@ -1631,4 +1808,78 @@ mod tests {
let (reason_0, _) = approval_timeout_park_parts(0);
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
}
/// B5d 连败升级三级:首败裸错误 / 第 2 次失败附软提示 / 第 3 次起拦截包升级强制换路
/// 通牒(含 knowledge_search 引导);成功一次即清零,再败回到首败无提示。
/// 走真失败记忆(进程级静态),键取本测试专属路径防并行互踩。
#[test]
fn failure_streak_hint_escalates_three_levels() {
use crate::commands::ai::tool_failure_memory::{precheck, record};
let args = serde_json::json!({ "path": "/tmp/b5d-escalation-check.txt" });
// 首败:未达拦截阈值,无提示
record("read_file", &args, false, Some("not found".into()));
assert!(!precheck("read_file", &args).is_some(), "首败不拦截");
// 第 2 次失败:记录后回探预检命中,错误包附软提示
record("read_file", &args, false, Some("not found".into()));
let streak_hit = precheck("read_file", &args).is_some();
assert!(streak_hit, "两连败后回探应命中");
let hinted = with_streak_hint(anyhow::anyhow!("工具执行失败"), streak_hit).to_string();
assert!(hinted.contains(HINT_AVOID_RETRY), "第 2 次失败附软提示: {hinted}");
assert!(hinted.contains("工具执行失败"), "原始错误保留: {hinted}");
// 第 3 次起:拦截包升级为强制换路通牒,状态字段保留,文本跨次恒定(L1 断路器
// 按错误文本哈希归 key,变化文本会打散其累计)
let blocked = precheck("read_file", &args).unwrap();
let upgraded = upgrade_blocked_hint(&blocked);
let upgraded_again = upgrade_blocked_hint(&blocked);
assert!(upgraded.contains("blocked_by_failure_memory"), "状态字段保留: {upgraded}");
assert!(upgraded.contains(HINT_MUST_SWITCH), "拦截包附强制换路通牒: {upgraded}");
assert!(upgraded.contains("knowledge_search"), "通牒含知识库检索引导: {upgraded}");
assert_eq!(upgraded, upgraded_again, "升级文本跨次恒定");
// 成功一次即清零:再败回到首败,无提示
record("read_file", &args, true, None);
record("read_file", &args, false, Some("not found".into()));
assert!(!precheck("read_file", &args).is_some(), "成功清零后首败无提示");
assert!(
!with_streak_hint(anyhow::anyhow!("x"), precheck("read_file", &args).is_some())
.to_string()
.contains(HINT_AVOID_RETRY),
"首败错误包不附提示"
);
}
/// B5d 熔断终局:同会话失败记忆拦截连击达 PARK_AFTER_BLOCKS 触发停靠,取走即消费
/// (不重复触发);未达阈不触发。
#[test]
fn blocked_streak_parks_after_five() {
let conv = "conv-b5d-park-check";
clear_block_streak(conv);
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
bump_block_streak(conv);
}
assert!(!take_tool_fail_park(conv), "未达阈不停靠");
bump_block_streak(conv);
assert!(take_tool_fail_park(conv), "拦截连击达 5 触发停靠");
assert!(!take_tool_fail_park(conv), "停靠取走即消费,不重复触发");
}
/// B5d 成功打断连击:达阈前任一工具成功即清零,连击从头再计(会话有产出不停靠)。
#[test]
fn tool_success_clears_block_streak() {
let conv = "conv-b5d-success-clear";
clear_block_streak(conv);
for _ in 0..(PARK_AFTER_BLOCKS - 1) {
bump_block_streak(conv);
}
clear_block_streak(conv);
assert!(!take_tool_fail_park(conv), "成功打断连击,不停靠");
for _ in 0..PARK_AFTER_BLOCKS {
bump_block_streak(conv);
}
assert!(take_tool_fail_park(conv), "清零后重新连击达阈仍停靠");
clear_block_streak(conv);
}
}
@@ -633,6 +633,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".to_string(),
working_context_json: None,
})
.await
.expect("测试会话插入应成功");
+38 -2
View File
@@ -1197,6 +1197,9 @@ pub(crate) async fn inject_runtime_message(
if extended {
conv.round_budget_extra += 1;
}
// 未消化计数(2026-10-14 实测):纯问答轮一轮收敛无下一轮,末轮扩轮机制(loop_max>0 才生效)
// 兜不住;收敛出口凭此计数感知「组装后有注入到达」并续一轮消化。
conv.pending_injects = conv.pending_injects.saturating_add(1);
drop(session);
// 落库(增量 save):与 loop 的 save 走同一 per-conv save 锁串行,幂等无交叉
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
@@ -1204,6 +1207,20 @@ pub(crate) async fn inject_runtime_message(
Ok(true)
}
/// 桌面端补充注入入口(桌面 AiChat 运行中发消息的 u-claw 语义:补充并进当前回答,不开新轮)。
/// 把 inject_runtime_message 暴露为 Tauri command,与飞书渠道(feishu_channel.rs)同源同一实现,
/// 锁纪律/末轮顺延语义收敛在 inject_runtime_message 单一真值,本 command 纯透传零附加逻辑。
/// 返回契约:Ok(true)=已注入(前端不入队,提示已并入当前回答);Ok(false)=会话不存在
/// (前端按普通新消息入队);Err=非活跃/Stopping(前端零破坏回退常规入队)。
#[tauri::command]
pub async fn ai_inject_runtime(
state: State<'_, AppState>,
conversation_id: String,
message: String,
) -> Result<bool, String> {
inject_runtime_message(&state, &conversation_id, &message).await
}
/// 批准/拒绝挂起的工具调用
#[tauri::command]
pub async fn ai_approve(
@@ -1968,6 +1985,9 @@ pub async fn ai_chat_compress_context(
language: Option<String>,
) -> Result<(), String> {
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 压缩可召回开关(KV `df-ai-compress-recall`,默认开):开则压缩标记时原文落盘存档
// + 摘要尾部拼召回句柄;关零行为变更。
let recall_on = super::super::prompt::compress_recall_enabled(&state).await;
const PROTECT_COUNT: usize = 6;
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2083,9 +2103,17 @@ pub async fn ai_chat_compress_context(
let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点。
let anchor_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&keyword_fallback, &p),
None => keyword_fallback.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&anchor_text));
}
conv.messages.set_compressing(false);
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
@@ -2110,8 +2138,16 @@ pub async fn ai_chat_compress_context(
let __lock_t1312 = std::time::Instant::now();
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conv_id);
if recall_on {
conv.messages.set_compress_recall(&state.data_dir, &conv_id);
}
let _compressed = conv.messages.compress_old_messages(compress_end);
conv.messages.insert_at(0, ChatMessage::system(&summary));
// 召回句柄拼在摘要尾部(存档落盘成功才有),只进上下文锚点;前端事件仍用原摘要。
let summary_text = match conv.messages.take_archive_path() {
Some(p) => df_ai::context::archive::with_recall_line(&summary, &p),
None => summary.clone(),
};
conv.messages.insert_at(0, ChatMessage::system(&summary_text));
conv.messages.set_compressing(false);
log_slow_lock(__lock_t1312, "ai_chat_compress_context:1312");
}
@@ -232,14 +232,24 @@ pub async fn ai_conversation_create(
///
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
/// include_archived=true,见 useAiConversations.loadConversations)。
///
/// F4-1(2026-10-17)分页:active_offset/archived_offset 按组跳过前 N 条(默认 0=第一页,
/// 旧调用方零改动)。治「会话列表+搜索硬上限 50」——实测 115 条活跃主会话 57% 在桌面
/// 端零入口;侧栏首屏仍一页(50/组)保启动性能,「加载更多」按 offset 续拉,搜索前前端
/// 逐页拉满(搜索跑在已加载列表上,截断列表=搜索失灵)。offset 超组长度返回该组空页
/// (自然收敛,无需显式 has_more 字段:页内某组满额 limit 即可能还有下一页)。
#[tauri::command]
pub async fn ai_conversation_list(
state: State<'_, AppState>,
limit: Option<usize>,
include_archived: Option<bool>,
active_offset: Option<usize>,
archived_offset: Option<usize>,
) -> Result<Vec<serde_json::Value>, String> {
let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false);
let active_offset = active_offset.unwrap_or(0);
let archived_offset = archived_offset.unwrap_or(0);
let records = state.ai_conversations.list_all().await.map_err(err_str)?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
@@ -273,20 +283,25 @@ pub async fn ai_conversation_list(
"loop_state": r.loop_state,
})
}).collect();
// 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。
// 原单 take(limit) 在 include_archived=true 时归档项混排会占满 50 名额。
let mut active = Vec::with_capacity(limit);
let mut archived = Vec::with_capacity(limit);
for s in summaries {
if s.get("archived").and_then(|v| v.as_bool()) == Some(true) {
if archived.len() < limit { archived.push(s); }
} else if active.len() < limit {
active.push(s);
Ok(paginate_by_group(summaries, limit, active_offset, archived_offset))
}
}
// 活跃在前(侧栏主区),归档在后(归档分组);各自保持 created_at DESC 原序。
active.extend(archived);
Ok(active)
/// F4-1:摘要按组独立分页——活跃/归档各自 skip(offset)+take(limit),活跃在前归档在后,
/// 组内保持传入原序(created_at DESC)。offset=0 与旧「分组截断」口径逐字节等价
/// (回归保护见 paginate_first_page_matches_legacy_grouped_truncation)。
/// 抽纯函数供单测:分组正确性/offset 续拉/越界空页/归档不挤占活跃名额。
fn paginate_by_group(
summaries: Vec<serde_json::Value>,
limit: usize,
active_offset: usize,
archived_offset: usize,
) -> Vec<serde_json::Value> {
let (active, archived): (Vec<_>, Vec<_>) = summaries
.into_iter()
.partition(|s| s.get("archived").and_then(|v| v.as_bool()) != Some(true));
let mut page: Vec<serde_json::Value> = active.into_iter().skip(active_offset).take(limit).collect();
page.extend(archived.into_iter().skip(archived_offset).take(limit));
page
}
/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。
@@ -358,12 +373,23 @@ pub(crate) async fn recover_parked_inner(
if !state.conv_states.can_accept_request(conversation_id) {
return Err("该会话正在生成中,无需恢复".to_string());
}
let message = PARKED_RECOVERY_MESSAGE.to_string();
// 目标快照:按最终有效意图生成恢复引导消息,不重放历史目标流。
// WorkingContext 与 pinned_goals 两列语义化读取;全空回退 PARKED_RECOVERY_MESSAGE。
let working_context = working_context_from_snapshot(record.working_context_json.as_deref());
let pinned = parse_pinned_goals(record.pinned_goals.as_deref());
let message = build_recovery_message(&working_context, &pinned);
// V50 终局态复位:恢复即重新进行,清 incomplete(停靠时已标)。loop 侧收尾单点在 agentic 主循环,
// 此处先复位保证续作期间语义正确;续作后正常收敛/再次停靠会再各自覆写。
if let Err(e) = state.ai_conversations.set_status(conversation_id, "active").await {
tracing::warn!(conv_id = %conversation_id, error = %e, "[ai] 恢复会话复位 status=active 失败");
}
// WorkingContext 随行恢复(V53):重启后 per_conv 若无内存态(引擎自动恢复未先 switch),
// 惰性建 per_conv 并从 DB 快照带回压缩重锚定的决策/未解项;已有内存态(switch 已还原)
// 重解同一快照幂等无害。
{
let mut session = state.ai_session.lock().await;
session.conv(conversation_id).working_context = working_context;
}
spawn_agentic_session(
app,
state,
@@ -380,6 +406,139 @@ pub(crate) async fn recover_parked_inner(
.await
}
/// 恢复产物前缀:恢复链自身生成的引导消息以它开头(恢复产物非用户意图,任何情况下
/// 都不该作为有效目标回放——2026-10-15 目标语义重构约定)。递归套娃根因:恢复消息
/// 经 begin_turn(chat.rs:950/2461)被记为 WorkingContext Active 目标落库,下次恢复
/// 回放时被当有效目标嵌套进新恢复消息,逐层加深(实证 conv 76730ce8 seq 250:
/// 目标文本=上一条恢复消息原文截 200 字符)。防线收敛在恢复链单点:
/// parse_pinned_goals 反解剔除 + build_recovery_message 回放剔除,同判据。
pub(crate) const RECOVERY_ARTIFACT_PREFIX: &str = "[系统恢复]";
/// 恢复产物判定:目标文本以恢复前缀开头即恢复链自产物,不作为有效目标。
fn is_recovery_artifact(text: &str) -> bool {
text.starts_with(RECOVERY_ARTIFACT_PREFIX)
}
/// 注入产物判定(E5):[补充指令] 是运行中用户插话的通道标记,非任务意图本身
/// (实证 434c8c40 active goal=「[补充指令] 123 hi」占位)。与恢复产物同治:
/// 载入侧滤出内存,下轮 save 重写即自愈落库。
fn is_injection_artifact(text: &str) -> bool {
text.starts_with("[补充指令]")
}
/// 引导产物总判定(恢复+注入),双侧(载入/回放)统一用。
fn is_guide_artifact(text: &str) -> bool {
is_recovery_artifact(text) || is_injection_artifact(text)
}
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活;
/// 恢复产物([系统恢复] 前缀)剔除——回放它即递归套娃,历史污染目标在此单点拦截。
/// 快照全空(旧会话无快照/全被过滤)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
pub(crate) fn build_recovery_message(
working_context: &df_ai::context_helpers::WorkingContext,
pinned_goals: &[crate::commands::ai::GoalEntry],
) -> String {
use df_ai::context_helpers::GoalStatus;
let mut seen = std::collections::HashSet::new();
let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals {
if matches!(g.status, GoalStatus::Active)
&& !is_guide_artifact(&g.text)
&& seen.insert(g.text.clone())
{
active.push(&g.text);
}
}
for g in pinned_goals {
if matches!(g.status, crate::commands::ai::GoalStatus::Active)
&& !is_guide_artifact(&g.text)
&& seen.insert(g.text.clone())
{
active.push(&g.text);
}
}
if active.is_empty() && working_context.unresolved.is_empty() {
return PARKED_RECOVERY_MESSAGE.to_string();
}
let mut msg = String::from(
"[系统恢复] 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务(中途已作废或已完成的目标不重放):\n",
);
if !active.is_empty() {
msg.push_str("\n有效目标:\n");
for t in &active {
msg.push_str("- ");
msg.push_str(t);
msg.push('\n');
}
}
if !working_context.unresolved.is_empty() {
msg.push_str("\n未解事项:\n");
for u in &working_context.unresolved {
msg.push_str("- ");
msg.push_str(u);
msg.push('\n');
}
}
msg.push_str("\n若快照不足以判断进度,先向用户确认已完成步骤再继续。");
msg
}
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
/// parked 恢复共用(DRY,单一事实源)。恢复产物([系统恢复] 前缀)剔除——存量
/// 污染目标不进内存,switch 载入后下轮 save 重写 pinned_goals 即自愈落库。
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
match parsed {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| {
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj
.get("status")
.and_then(|s| s.as_str())
.map(|s| {
if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}
})
.unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
})
.filter(|e| !is_guide_artifact(&e.text))
.collect(),
_ => vec![],
}
}
/// WorkingContext 落库快照反解(V53 working_context_json,恢复侧单一入口)。
/// None/空串(老会话列 NULL=无 WorkingContext)与解析失败一律返回空 WorkingContext:
/// 行为同今日的半失忆续跑,坏数据降级不炸恢复链路。与落库侧 working_context_snapshot 成对。
pub(crate) fn working_context_from_snapshot(
json: Option<&str>,
) -> df_ai::context_helpers::WorkingContext {
json.filter(|s| !s.is_empty())
.and_then(|s| match serde_json::from_str(s) {
Ok(wc) => Some(wc),
Err(e) => {
tracing::warn!("working_context_json 反解失败(按空上下文降级): {e}");
None
}
})
.unwrap_or_else(df_ai::context_helpers::WorkingContext::new)
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command]
pub async fn ai_conversation_switch(
@@ -490,35 +649,13 @@ pub async fn ai_conversation_switch(
if !mem_has_unsaved {
conv.messages.restore_from_messages(messages);
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
let raw: Option<serde_json::Value> = record.pinned_goals
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
match raw {
Some(serde_json::Value::Array(arr)) => {
arr.iter().filter_map(|v| {
// 新格式: {"text":"...","status":"active"}
if let Some(obj) = v.as_object() {
let text = obj.get("text")?.as_str()?.to_string();
let status = obj.get("status").and_then(|s| s.as_str())
.map(|s| if s == "completed" {
crate::commands::ai::GoalStatus::Completed
} else {
crate::commands::ai::GoalStatus::Active
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
Some(crate::commands::ai::GoalEntry { text, status })
} else {
// 旧格式: "text" → 转 active
let text = v.as_str()?.to_string();
Some(crate::commands::ai::GoalEntry::new(text))
}
}).collect()
}
_ => vec![],
}
};
conv.pinned_goals = pinned_goals;
// 新旧格式兼容解析收敛到 parse_pinned_goals(与 parked 恢复共享,单一事实源)。
conv.pinned_goals = parse_pinned_goals(record.pinned_goals.as_deref());
// WorkingContext 随行读载(V53 working_context_json):DB 快照还原内存真相源,
// 压缩重锚定的决策/未解项跨重启不断裂。与 pinned_goals 同守卫(内存有未落库
// 新消息时不覆盖,DB 快照只回填干净内存)。
conv.working_context =
working_context_from_snapshot(record.working_context_json.as_deref());
}
// 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。
// 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级
@@ -880,6 +1017,16 @@ pub async fn ai_conversation_delete(
}
};
// 跨会话召回(终态提炼删除挂点):删除动作前后台提炼一次,失败/未完成不阻塞删除
// (删除是用户显式意图,延迟属体验倒退)。挂点内同步取走内存快照(全量 active 消息
// + WorkingContext 决策),LLM 提炼在后台;收尾顺带级联清理压缩存档(先提炼后删文件)。
crate::commands::ai::knowledge_inject::spawn_terminal_extraction(
&state,
&conversation_id,
Some(state.data_dir.clone()),
)
.await;
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
// 顺序:先删数据再摘内存(下方 per_conv.remove)。
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
@@ -1379,6 +1526,73 @@ mod tests {
assert_eq!(back.timestamp, Some(123));
}
// ============================================================
// F4-1 会话列表分页(paginate_by_group:活跃/归档各自 offset+limit)
// ============================================================
fn conv_val(id: &str, archived: bool) -> serde_json::Value {
serde_json::json!({ "id": id, "archived": archived })
}
/// 首页(offset=0)与旧「分组截断」口径等价:各组前 limit 条,活跃在前归档在后。
#[test]
fn paginate_first_page_matches_legacy_grouped_truncation() {
// 60 活跃 + 60 归档,limit 50:旧口径各取 50(归档不挤占活跃名额)
let summaries: Vec<_> = (0..60).map(|i| conv_val(&format!("a{i}"), false))
.chain((0..60).map(|i| conv_val(&format!("r{i}"), true)))
.collect();
let page = paginate_by_group(summaries, 50, 0, 0);
assert_eq!(page.len(), 100, "各组独立取满 50");
assert!(page.iter().take(50).all(|s| s["archived"] == false), "前 50 全活跃");
assert!(page.iter().skip(50).all(|s| s["archived"] == true), "后 50 全归档");
assert_eq!(page[0]["id"], "a0");
assert_eq!(page[49]["id"], "a49");
assert_eq!(page[50]["id"], "r0");
assert_eq!(page[99]["id"], "r49");
}
/// offset 续拉:第二页从各组 offset 处继续,两组独立推进互不影响
/// (115 活跃场景:第一页 a0..a49,第二页 active_offset=50 拉 a50..a99)。
#[test]
fn paginate_offset_advances_each_group_independently() {
let summaries: Vec<_> = (0..115).map(|i| conv_val(&format!("a{i}"), false))
.chain((0..3).map(|i| conv_val(&format!("r{i}"), true)))
.collect();
// 第二页:活跃 offset 50(满页 50 条),归档 offset 0(仅 3 条全回)
let page2 = paginate_by_group(summaries.clone(), 50, 50, 0);
assert_eq!(page2.len(), 53);
assert_eq!(page2[0]["id"], "a50");
assert_eq!(page2[49]["id"], "a99");
assert_eq!(page2[50]["id"], "r0", "归档组从 0 起独立分页,不被活跃 offset 影响");
// 第三页:活跃 offset 100(尾页 15 条),归档 offset 3(越界空)
let page3 = paginate_by_group(summaries, 50, 100, 3);
assert_eq!(page3.len(), 15);
assert_eq!(page3[0]["id"], "a100");
assert_eq!(page3[14]["id"], "a114");
assert!(page3.iter().all(|s| s["archived"] == false), "归档越界返回该组空页");
}
/// offset 超组长度的组返回空页(自然收敛);两组同时越界返回空 Vec。
#[test]
fn paginate_offset_beyond_length_yields_empty() {
let summaries = vec![conv_val("a0", false), conv_val("r0", true)];
assert!(paginate_by_group(summaries.clone(), 50, 5, 0).iter().all(|s| s["archived"] == true));
assert!(paginate_by_group(summaries.clone(), 50, 0, 5).iter().all(|s| s["archived"] == false));
assert!(paginate_by_group(summaries, 50, 5, 5).is_empty());
}
/// 输入混排(归档穿插在活跃间,list_all 全量 DESC 实际形态)时 partition 仍按组归位,
/// 组内保持传入原序。
#[test]
fn paginate_partition_from_interleaved_input() {
let summaries = vec![
conv_val("a0", false), conv_val("r0", true), conv_val("a1", false), conv_val("r1", true),
];
let page = paginate_by_group(summaries, 50, 0, 0);
let ids: Vec<&str> = page.iter().map(|s| s["id"].as_str().unwrap()).collect();
assert_eq!(ids, vec!["a0", "a1", "r0", "r1"], "活跃全量在前(原序),归档全量在后(原序)");
}
// ============================================================
// F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
// ============================================================
@@ -1594,6 +1808,7 @@ mod tests {
boundary_json: None,
loop_state: None,
status: "active".to_string(),
working_context_json: None,
})
.await
.expect("测试会话插入应成功");
@@ -1625,4 +1840,151 @@ mod tests {
// 无子代理的父返空
assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty());
}
// ── 目标快照恢复语义 ──
fn wc_with_goals(texts: &[(&str, df_ai::context_helpers::GoalStatus)]) -> df_ai::context_helpers::WorkingContext {
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.goals = texts
.iter()
.map(|(t, s)| df_ai::context_helpers::GoalItem {
id: format!("g-{}", t),
text: t.to_string(),
status: s.clone(),
})
.collect();
wc
}
#[test]
fn recovery_message_uses_snapshot_and_skips_dead_goals() {
use df_ai::context_helpers::GoalStatus;
// 调整后重启只认最终集:作废(Cancelled)与已完成(Completed)不出现,Active 与钉扎目标全保留
let wc = wc_with_goals(&[
("旧目标已作废", GoalStatus::Cancelled),
("交付 v2", GoalStatus::Active),
("旧目标已完成", GoalStatus::Completed),
]);
let pinned = vec![crate::commands::ai::GoalEntry::new("钉扎:每天跑测试".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert!(msg.contains("交付 v2"), "Active 目标须在快照: {msg}");
assert!(msg.contains("钉扎:每天跑测试"), "钉扎目标须在快照: {msg}");
assert!(!msg.contains("旧目标已作废"), "作废目标不复活: {msg}");
assert!(!msg.contains("旧目标已完成"), "已完成目标不重放: {msg}");
assert!(msg.contains("未解事项") == false || !wc.unresolved.is_empty());
}
#[test]
fn recovery_message_unresolved_included_and_dedup_works() {
use df_ai::context_helpers::GoalStatus;
let mut wc = wc_with_goals(&[("同一目标", GoalStatus::Active)]);
wc.unresolved.push("端口冲突未确认".into());
let pinned = vec![crate::commands::ai::GoalEntry::new("同一目标".to_string())];
let msg = build_recovery_message(&wc, &pinned);
assert_eq!(msg.matches("同一目标").count(), 1, "去重后只出现一次: {msg}");
assert!(msg.contains("端口冲突未确认"), "未解事项须进快照: {msg}");
}
#[test]
fn recovery_message_empty_snapshot_falls_back_to_constant() {
let wc = df_ai::context_helpers::WorkingContext::new();
let msg = build_recovery_message(&wc, &[]);
assert_eq!(msg, PARKED_RECOVERY_MESSAGE, "快照全空回退原常量,行为同今日");
}
#[test]
fn parse_pinned_goals_new_and_legacy_formats() {
// 新格式保 status;旧格式转 Active;非数组/坏 JSON 空降级
let new_fmt = parse_pinned_goals(Some(r#"[{"text":"a","status":"completed"},{"text":"b"}]"#));
assert_eq!(new_fmt.len(), 2);
assert!(matches!(new_fmt[0].status, crate::commands::ai::GoalStatus::Completed));
assert!(matches!(new_fmt[1].status, crate::commands::ai::GoalStatus::Active));
let legacy = parse_pinned_goals(Some(r#"["旧文本"]"#));
assert_eq!(legacy.len(), 1);
assert!(matches!(legacy[0].status, crate::commands::ai::GoalStatus::Active));
assert!(parse_pinned_goals(Some("not-json")).is_empty());
assert!(parse_pinned_goals(None).is_empty());
}
// ── 恢复产物过滤(递归套娃防线,conv 76730ce8 seq 250 实证) ──
/// 构造一条上一代恢复消息全文(截 200 字符,复刻 begin_turn 误记目标的现场形态)。
fn prev_recovery_text() -> String {
let full = format!(
"{} 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务:\n\n有效目标:\n- 检查 各个 u-claw 实例状态\n\n若快照不足以判断进度,先向用户确认已完成步骤再继续。",
RECOVERY_ARTIFACT_PREFIX
);
full.chars().take(200).collect()
}
#[test]
fn recovery_message_zero_nesting_with_polluted_goals() {
use df_ai::context_helpers::GoalStatus;
// 嵌套恢复现场:WorkingContext.goals 与 pinned_goals 都躺着上一条恢复消息全文,
// 两侧 Active——未过滤时它会被当有效目标嵌进新恢复消息(套娃逐层加深)。
let wc = wc_with_goals(&[
(&prev_recovery_text(), GoalStatus::Active),
("交付 v2 登录接口", GoalStatus::Active),
]);
let pinned = vec![
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
crate::commands::ai::GoalEntry::new("补齐单测覆盖".to_string()),
];
let msg = build_recovery_message(&wc, &pinned);
// 输出只允许保留消息头一处恢复前缀(零套娃),正常用户目标不受影响
assert_eq!(
msg.matches(RECOVERY_ARTIFACT_PREFIX).count(),
1,
"恢复产物目标必须被剔除,输出仅消息头一处前缀: {msg}"
);
assert!(msg.contains("交付 v2 登录接口"), "正常用户目标须保留: {msg}");
assert!(msg.contains("补齐单测覆盖"), "正常钉扎目标须保留: {msg}");
assert!(
!msg.contains("有效目标:\n- [系统恢复]"),
"上一代恢复消息不得作为目标行回放: {msg}"
);
}
#[test]
fn recovery_message_all_polluted_falls_back_to_constant() {
use df_ai::context_helpers::GoalStatus;
// 快照只剩恢复产物(历史最深套娃态):全部过滤后回退常量,套娃链在此截断
let wc = wc_with_goals(&[(&prev_recovery_text(), GoalStatus::Active)]);
let pinned = vec![crate::commands::ai::GoalEntry::new(prev_recovery_text())];
assert_eq!(
build_recovery_message(&wc, &pinned),
PARKED_RECOVERY_MESSAGE,
"全污染快照过滤后按空快照回退常量"
);
}
#[test]
fn parse_pinned_goals_drops_recovery_artifacts_idempotent() {
// 存量污染反解剔除:恢复前缀条目不进内存(幂等——清洗后再解析结果不变)
let polluted = serde_json::to_string(&vec![
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
crate::commands::ai::GoalEntry::new("正常目标保留".to_string()),
])
.unwrap();
let parsed = parse_pinned_goals(Some(&polluted));
assert_eq!(parsed.len(), 1, "恢复产物条目须剔除: {parsed:?}");
assert_eq!(parsed[0].text, "正常目标保留");
// 幂等:清洗结果重解析不变
let cleaned = serde_json::to_string(&parsed).unwrap();
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
}
#[test]
fn parse_pinned_goals_drops_injection_artifacts() {
// E5:[补充指令] 是通道标记非任务意图(434c8c40「[补充指令] 123 hi」占位实证),
// 与恢复产物同治——载入侧剔除,下轮 save 重写自愈。
let polluted = serde_json::to_string(&vec![
crate::commands::ai::GoalEntry::new("[补充指令] 123 hi".to_string()),
crate::commands::ai::GoalEntry::new("升级工具链到最新版".to_string()),
])
.unwrap();
let parsed = parse_pinned_goals(Some(&polluted));
assert_eq!(parsed.len(), 1, "注入产物条目须剔除: {parsed:?}");
assert_eq!(parsed[0].text, "升级工具链到最新版");
}
}
+358
View File
@@ -0,0 +1,358 @@
//! 脉络卡数据面(跨会话召回四层架构 L3 用户侧辅助通道):
//! 按 source_project 聚合 published 知识,给前端新会话入口展示「这个项目之前定了什么」。
//!
//! 铁律红线:本通道只喂「用户看的卡」,不进 system、不进消息流——用户点「带入上下文」
//! 才经正常发送路径显式注入(用户决策,非 LLM 自动召回)。
use tauri::State;
use crate::commands::ai::knowledge_inject::resolve_source_project;
/// 单条脉络摘要(字段前端契约,snake_case)
#[derive(serde::Serialize, Clone, Debug)]
pub struct ContextBrief {
pub title: String,
pub summary: String,
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>,
}
/// 单条摘要截断上限(字符):脉络卡是扫一眼的导航,不是全文阅读,超长压到一行半
const BRIEF_SUMMARY_MAX_CHARS: usize = 120;
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
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();
for r in records {
let Some(proj) = r.source_project.clone() else {
continue;
};
let brief = ContextBrief {
title: truncate_chars(&r.title, 60),
summary: truncate_chars(&r.content, BRIEF_SUMMARY_MAX_CHARS),
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);
}
let mut out: Vec<ProjectBriefs> = groups
.into_iter()
.map(|(source_project, mut briefs)| {
briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count));
briefs.truncate(per_project);
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())?;
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)]
mod tests {
use super::*;
fn rec(title: &str, content: &str, project: Option<&str>, reuse: i32) -> df_storage::models::KnowledgeRecord {
df_storage::models::KnowledgeRecord {
id: format!("k-{title}"),
kind: "pitfall".into(),
title: title.into(),
content: content.into(),
tags: None,
status: "published".into(),
confidence: Some("high".into()),
reuse_count: reuse,
verified: true,
source_project: project.map(Into::into),
source_ref: Some("conv:test".into()),
reasoning: None,
embedding_status: None,
created_at: "1700000000".into(),
updated_at: "1700000000".into(),
}
}
#[test]
fn briefs_group_by_project_and_sort_by_reuse() {
let recs = vec![
rec("甲项目低复用", "c1", Some("proj-a"), 1),
rec("甲项目高复用", "c2", Some("proj-a"), 9),
rec("乙项目", "c3", Some("proj-b"), 3),
rec("无归属不入卡", "c4", None, 99),
];
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())));
}
#[test]
fn briefs_truncate_per_project_and_summary() {
let recs: Vec<_> = (0..8)
.map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i))
.collect();
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");
}
}
+118 -1
View File
@@ -267,6 +267,24 @@ async fn save_conversation_serialized(
}
/// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。
/// WorkingContext 落库快照序列化(V53 working_context_json,save 每轮随行写)。
/// 四段全空(无目标/决策/未解项/步骤)返回 None——NULL=无上下文,列语义干净,
/// 老会话恢复行为同旧;序列化失败 warn 降级为 None(仅丢本轮快照,下轮 save 重写)。
fn working_context_snapshot(wc: &df_ai::context_helpers::WorkingContext) -> Option<String> {
if wc.goals.is_empty() && wc.decisions.is_empty() && wc.unresolved.is_empty()
&& wc.current_step.is_none()
{
return None;
}
match serde_json::to_string(wc) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("WorkingContext 序列化失败(本轮快照降级为 NULL): {e}");
None
}
}
}
async fn save_conversation_inner(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
@@ -295,7 +313,7 @@ async fn save_conversation_inner(
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary) = {
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary, conv_working_ctx) = {
let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -335,6 +353,9 @@ async fn save_conversation_inner(
let pinned_goals = conv.pinned_goals.clone();
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
let conv_boundary = conv.boundary.clone();
// WorkingContext 随行落库快照(V53 working_context_json,跨会话召回 L4):
// 压缩重锚定的决策/未解项跨重启不断裂,恢复侧经 working_context_from_snapshot 反解
let conv_working_ctx = working_context_snapshot(&conv.working_context);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
(
provider_id,
@@ -346,6 +367,7 @@ async fn save_conversation_inner(
cloned,
conv_origin,
conv_boundary,
conv_working_ctx,
)
};
let __lock_total = __lock_start.elapsed();
@@ -471,6 +493,8 @@ async fn save_conversation_inner(
rec.boundary_json = conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty());
// WorkingContext 随行持久化(V53):None(全空/序列化失败)保持 NULL
rec.working_context_json = conv_working_ctx;
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
@@ -548,6 +572,7 @@ async fn save_conversation_inner(
loop_state: if conv_is_active { Some("running".to_string()) } else { None },
// V50 终局态:新落库默认 active(进行中);终局由收尾路径 set_status 覆写。
status: "active".to_string(),
working_context_json: conv_working_ctx,
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
@@ -850,4 +875,96 @@ mod tests {
truncate_decision(&mut m);
assert_eq!(m.content, short);
}
// ---------- WorkingContext 持久化(V53,跨会话召回 L4) ----------
/// 保存→重载 WorkingContext 字段一致:goals/decisions/unresolved/current_step/version
/// 全回(save 侧 working_context_snapshot 正典序列化 + 恢复侧 working_context_from_snapshot
/// 正典反解 + repo 落库往返,即 save_conversation/会话载入/parked 恢复三条链共用的两个端点)。
#[tokio::test]
async fn working_context_snapshot_roundtrip_via_repo() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiConversationRepo::new(&db);
let mut wc = df_ai::context_helpers::WorkingContext::new();
wc.version.current_turn = 7;
wc.goals.push(df_ai::context_helpers::GoalItem {
id: "t7".into(),
text: "重构存储层".into(),
status: df_ai::context_helpers::GoalStatus::Active,
});
wc.decisions.push(df_ai::context_helpers::DecisionItem {
question: "迁移号?".into(),
decision: "顺延 V53".into(),
reason: "V50-V52 已占用".into(),
});
wc.unresolved.push("端口未定".into());
wc.current_step = Some(df_ai::context_helpers::StepInfo {
step_name: "迁移".into(),
status: df_ai::context_helpers::StepStatus::InProgress,
});
let snap = working_context_snapshot(&wc).expect("非空上下文应产出快照");
repo.insert(df_storage::models::AiConversationRecord {
id: "c-wc".into(),
title: None,
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: Some(0),
completion_tokens: Some(0),
pinned_goals: None,
pending_approvals: None,
created_at: "0".into(),
updated_at: "0".into(),
origin: None,
boundary_json: None,
loop_state: None,
status: "active".into(),
working_context_json: Some(snap),
})
.await
.expect("insert");
let loaded = repo.get_by_id("c-wc").await.expect("get").expect("row");
let restored = crate::commands::ai::commands::conversation::working_context_from_snapshot(
loaded.working_context_json.as_deref(),
);
assert_eq!(restored.goals.len(), 1, "goals 应回");
assert_eq!(restored.goals[0].text, "重构存储层");
assert_eq!(restored.goals[0].status, df_ai::context_helpers::GoalStatus::Active);
assert_eq!(restored.decisions.len(), 1, "decisions 应回");
assert_eq!(restored.decisions[0].decision, "顺延 V53");
assert_eq!(restored.decisions[0].reason, "V50-V52 已占用");
assert_eq!(restored.unresolved, vec!["端口未定".to_string()], "unresolved 应回");
let step = restored.current_step.expect("current_step 应回");
assert_eq!(step.step_name, "迁移");
assert_eq!(step.status, df_ai::context_helpers::StepStatus::InProgress);
assert_eq!(restored.version.current_turn, 7, "脏标记版本应回");
}
/// 无持久化恢复=空 WorkingContext 不报错:全空上下文 save 侧写 NULL(不产脏 JSON);
/// 旧会话列 NULL/空串/坏 JSON 恢复一律空上下文(行为同今日),坏数据降级不 panic。
#[test]
fn working_context_empty_snapshot_none_and_null_restore_empty() {
let empty = df_ai::context_helpers::WorkingContext::new();
assert!(
working_context_snapshot(&empty).is_none(),
"全空上下文快照应为 None(落 NULL,列语义干净)"
);
use crate::commands::ai::commands::conversation::working_context_from_snapshot;
let restored = working_context_from_snapshot(None);
assert!(restored.goals.is_empty() && restored.decisions.is_empty());
assert!(restored.unresolved.is_empty() && restored.current_step.is_none());
let blank = working_context_from_snapshot(Some(""));
assert!(blank.goals.is_empty(), "空串按无上下文");
let bad = working_context_from_snapshot(Some("not-json"));
assert!(bad.decisions.is_empty(), "坏 JSON 降级为空上下文不 panic");
}
}
+702
View File
@@ -0,0 +1,702 @@
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
//!
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
//!
//! 知识路标子段(知识投影 v2):规则文件只说"该怎么做",路标补"库里有啥"——已发布且
//! (人工验证或复用达标)的知识条目按「同项目档 + 跨项目补充档」铺开成紧凑行,
//! 让 agent 知晓知识库藏、在工具故障/重复失败时想起用 knowledge_search 检索,
//! 而不是死磕。读侧走 df-storage [`KnowledgeRepo::list_waymark`],会话项目归属复用
//! [`resolve_source_project`] 既有机制,不自写 SQL 不造双轨。
//!
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘 + 查一次库,此后复用同一文本,
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
//! 会话粒度取 conv_id 作缓存键:新会话重新组装(拿到文件与库的最新内容),旧会话不受扰动。
//!
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
//!
//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::crud::KnowledgeRepo;
use df_storage::db::Database;
use df_storage::models::KnowledgeRecord;
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;
// ── 知识路标子段参数 ──
//
// 条目数由查询 LIMIT 封顶(项目档 + 补充档),行内标题截断注省略号——双层结构上界
// 使路标块体量有界(约 30 行 × 行宽上限),无须再设整块字符闸。
/// 会话归属项目档上限(条)
const WAYMARK_PROJECT_MAX: usize = 20;
/// 跨项目补充档上限(条)
const WAYMARK_GLOBAL_MAX: usize = 10;
/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态
const WAYMARK_TITLE_MAX_CHARS: usize = 60;
/// 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()))
}
/// 知识库读侧句柄:进程级 AppHandle → AppState.db(生产装配点,与 spawn_agent 全局
/// 同款"注册期无法捕获、运行期读取"形态)。AppHandle 未注入 / AppState 未托管
/// (早期启动、单元测试)→ None,路标子块整体缺席,其余组装零扰动。
fn knowledge_db() -> Option<Arc<Database>> {
use tauri::Manager;
let app = crate::commands::ai::tools::spawn_agent::app_handle()?;
let state = app.try_state::<crate::state::AppState>()?;
Some(state.db.clone())
}
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
///
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
/// 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 {
let kb = knowledge_db();
match projection_section(work_dir, conv_id, kb).await {
Some(seg) => format!("{}\n\n{}", prompt, seg),
None => prompt,
}
}
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘查库);未命中组装一次并入缓存。
///
/// 缓存值空串 = 本会话已知无投影(文件缺失且库无合格条目),同样走缓存避免每轮白查。
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
/// `kb`:知识库读侧句柄(None = 单元测试/未托管态,路标子块整体缺席)。
async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Arc<Database>>) -> 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);
let conv = conv_id.to_string();
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
let (files, waymark) = tokio::join!(
tokio::task::spawn_blocking(move || read_pair(dir)),
async {
match kb {
Some(db) => build_waymark(&db, &conv).await,
None => None,
}
}
);
let (devflow, claude) = files.unwrap_or_else(|_| (None, None));
let (discipline, claiming) = devflow
.as_deref()
.map(extract_partitions)
.unwrap_or((String::new(), String::new()));
let section = combine_sections(build_section(&discipline, &claiming, claude.as_deref()), waymark);
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
}
/// 规则文件投影 + 知识路标子块的拼装(纯函数):路标恒居规则投影之后(同一稳定尾段内);
/// 双双缺席 → None(调用方零追加,零回归)。仅路标在场 → 路标独占投影段(规则文件头语不出现)。
fn combine_sections(rules: Option<String>, waymark: Option<String>) -> Option<String> {
match (rules, waymark) {
(Some(mut rules), Some(wm)) => {
rules.push_str("\n\n");
rules.push_str(&wm);
Some(rules)
}
(Some(rules), None) => Some(rules),
(None, Some(wm)) => Some(format!("## 项目认知投影\n\n{wm}")),
(None, None) => None,
}
}
/// 组装知识路标子块:解析会话归属项目(既有机制) → 两档查询 → 渲染紧凑行。
/// 查询失败降级缺席(warn 留痕,不阻断组装:路标是增强输入,故障不该影响 prompt 主链)。
async fn build_waymark(db: &Arc<Database>, conv_id: &str) -> Option<String> {
let project = crate::commands::ai::knowledge_inject::resolve_source_project(db, conv_id, &[]).await;
match KnowledgeRepo::new(db)
.list_waymark(project.as_deref(), WAYMARK_PROJECT_MAX, WAYMARK_GLOBAL_MAX)
.await
{
Ok(list) if !list.is_empty() => Some(render_waymark(&list)),
Ok(_) => None,
Err(e) => {
tracing::warn!(error = %e, conv_id, "[devflow-md] 知识路标查询失败,子块缺席");
None
}
}
}
/// 渲染路标子块(纯函数):段首说明 + 紧凑行 + 段尾固定提示。
/// 标题压平空白(库内标题可能含换行,直拼会破坏单行形态)并截断注省略号;
/// 空白标题条目跳过(渲染不出可读行的脏数据不入段)。
fn render_waymark(entries: &[KnowledgeRecord]) -> String {
let mut wm = String::from("### 知识库路标\n项目知识库路标(详情用 knowledge_search 按标题关键词检索)");
for e in entries {
let flat = e.title.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.is_empty() {
continue;
}
let count = flat.chars().count();
let mut title: String = flat.chars().take(WAYMARK_TITLE_MAX_CHARS).collect();
if count > WAYMARK_TITLE_MAX_CHARS {
title.push('…');
}
wm.push_str(&format!("\n- {title} | 复用{}次", e.reuse_count));
}
wm.push_str("\n上述经验条目可直接复用;遇到工具故障/重复失败先检索路标");
wm
}
/// 读工作目录下的两份规则文件,内容 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(路标子块在场时由 combine_sections 单独成段)。
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("冲突以"), "单文件不得注明双文件优先级");
}
// ============================================================
// 知识路标子段(kb 句柄经 projection_section 显式注入,不经进程级全局:
// 单测并发零串扰,append_projection 生产路径的句柄解析另由 app_handle 缺席态天然覆盖)
// ============================================================
use df_storage::crud::{AiConversationRepo, ProjectRepo};
use df_storage::models::{AiConversationRecord, KnowledgeRecord};
/// 知识条目 fixture(published,未验证,归属可选)
fn kentry(id: &str, title: &str, reuse: i32, verified: bool, project: Option<&str>) -> KnowledgeRecord {
KnowledgeRecord {
id: id.to_string(),
kind: "lesson".to_string(),
title: title.to_string(),
content: "正文".to_string(),
tags: Some("[]".to_string()),
status: "published".to_string(),
confidence: None,
reuse_count: reuse,
verified,
source_project: project.map(|p| p.to_string()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
/// 插入测试项目(同 knowledge_inject 测试口径)
async fn ins_project(db: &Database, id: &str, path: &str) {
ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: "路标项目".into(),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
/// 插入测试会话,带 boundary_json(dir_allowlist 单目录 → 级 a 归属解析用)
async fn ins_conv_with_boundary(db: &Database, conv_id: &str, allow_dir: &str) {
let boundary = crate::commands::ai::boundary::TaskBoundary {
risk_ceiling: df_ai::ai_tools::RiskLevel::Medium,
dir_allowlist: vec![allow_dir.to_string()],
forbid_destructive: true,
budget: None,
};
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: conv_id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn memory_kb() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.expect("内存库"))
}
// ── 空库零回归:注入空库句柄,投影段与无库时逐字一致 ──
#[tokio::test]
async fn empty_kb_zero_regression() {
let db = memory_kb().await;
let dir = temp_project_dir();
// 无规则文件 + 空库 → None(prompt 逐字不变)
let none = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db.clone())).await;
assert_eq!(none, None, "空库且无规则文件,投影段必须缺席");
// 有规则文件 + 空库 → 规则照常注入,路标子块零出现(其余行为零变化)
write_file(&dir, "CLAUDE.md", "- 规则条目");
let rules_only = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db)).await;
let sec = rules_only.expect("有规则文件必有段");
assert!(sec.contains("### 项目规范(CLAUDE.md)"));
assert!(!sec.contains("知识库路标"), "空库不得出现路标子段");
std::fs::remove_dir_all(&dir).ok();
}
// ── 入选过滤:verified 或复用达标才入段,渲染为紧凑行 + 段首说明 + 段尾固定提示 ──
#[tokio::test]
async fn waymark_selects_verified_or_reused() {
let db = memory_kb().await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("ok-v", "已验证经验", 0, true, None)).await.unwrap();
repo.insert(kentry("ok-r", "复用达标经验", 3, false, None)).await.unwrap();
repo.insert(kentry("no-r", "复用不足应淘汰", 2, false, None)).await.unwrap();
let mut cand = kentry("no-c", "候选未发布应淘汰", 99, false, None);
cand.status = "candidate".to_string();
repo.insert(cand).await.unwrap();
let sec = projection_section(None, &format!("conv-{}", unique_tag()), Some(db))
.await
.expect("有合格条目必有段");
assert!(sec.starts_with("## 项目认知投影"), "仅路标在场时须独占投影段");
assert!(sec.contains("项目知识库路标(详情用 knowledge_search 按标题关键词检索)"), "段首说明行");
assert!(sec.contains("\n- 已验证经验 | 复用0次"), "verified 豁免复用门槛入段");
assert!(sec.contains("\n- 复用达标经验 | 复用3次"), "复用压线达标入段");
assert!(!sec.contains("复用不足应淘汰"), "复用不足不入段");
assert!(!sec.contains("候选未发布应淘汰"), "非 published 不入段");
assert!(
sec.ends_with("上述经验条目可直接复用;遇到工具故障/重复失败先检索路标"),
"段尾固定提示行"
);
}
// ── 项目优先:boundary 解析出归属项目后,项目档在前,跨项目补充档按复用降序 ──
#[tokio::test]
async fn waymark_project_tier_first() {
let db = memory_kb().await;
ins_project(&db, "p-way", "e:/wk-lab/waymark-proj").await;
ins_conv_with_boundary(&db, "conv-way", "e:/wk-lab/waymark-proj").await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("same1", "同项目条目", 5, false, Some("p-way"))).await.unwrap();
repo.insert(kentry("foreign", "异项目高复用条目", 99, false, Some("p-other"))).await.unwrap();
repo.insert(kentry("global1", "全局无归属条目", 10, false, None)).await.unwrap();
let sec = projection_section(None, "conv-way", Some(db)).await.expect("必有段");
let same = sec.find("同项目条目").expect("同项目条目须入项目档");
let global = sec.find("全局无归属条目").expect("全局条目须入补充档");
let foreign = sec.find("异项目高复用条目").expect("异项目条目须入补充档");
assert!(
same < foreign && foreign < global,
"项目档在前;补充档内复用降序(异项目99 先于全局10)"
);
}
// ── 会话缓存:同会话复用首查结果,库内追加不影响;新会话查到新条目 ──
#[tokio::test]
async fn waymark_cached_per_conversation() {
let db = memory_kb().await;
let repo = KnowledgeRepo::new(&db);
repo.insert(kentry("a1", "首批经验", 3, false, None)).await.unwrap();
let conv = format!("conv-{}", unique_tag());
let first = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
assert!(first.contains("首批经验"));
repo.insert(kentry("b2", "次批经验", 3, false, None)).await.unwrap();
let again = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
assert_eq!(first, again, "同会话必须复用缓存(库内追加不影响)");
assert!(!again.contains("次批经验"));
let fresh = projection_section(None, &format!("{conv}-new"), Some(db)).await.expect("必有段");
assert!(fresh.contains("次批经验"), "新会话须重查库拿到新条目");
}
// ── 渲染纯函数:标题压平换行 + 截断注省略号 + 空白标题跳过 ──
#[test]
fn render_waymark_flattens_and_caps_title() {
let mut long = kentry("long", "", 7, false, None);
long.title = format!("长{}标题", "词".repeat(60)); // 63 字,超 60 上限
let mut multiline = kentry("multi", "含\n换行\t与制表", 2, true, None);
multiline.verified = true;
let blank = kentry("blank", " \n ", 1, true, None);
let out = render_waymark(&[long, multiline, blank]);
let line = out.lines().find(|l| l.starts_with("- 长")).expect("截断标题行在场");
assert!(line.ends_with("… | 复用7次"), "超长标题须截断注省略号");
assert!(line.matches('词').count() <= WAYMARK_TITLE_MAX_CHARS, "标题不得超上限");
assert!(out.contains("- 含 换行 与制表 | 复用2次"), "换行/制表压平为单空格");
assert!(!out.contains("- |"), "空白标题条目须跳过");
}
}
+433 -62
View File
@@ -27,7 +27,7 @@ use super::boundary::TaskBoundary;
use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::err_str;
use super::{AiSession};
use super::{AiSession, PerConvState};
// ============================================================
// 知识注入/提炼参数(集中收口,不散魔法数)
@@ -902,6 +902,250 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
fn terminal_extraction_system_prompt() -> String {
format!(
"{}\n6. 本次提炼发生在会话终态(删除前或停靠),只提炼跨会话可复用的经验:决策与依据、\
事实发现(环境约束/结构/坑)、偏好与约定、踩坑;未完成事项、待办、下一步计划是会话\
任务态不是知识,一律不要提炼",
EXTRACTION_SYSTEM_PROMPT
)
}
// ============================================================
// 会话终态提炼(跨会话召回写入收口):删除前 + parked 停靠两挂点
// ============================================================
/// 终态提炼内存快照(挂点时刻):全量 active 消息 + WorkingContext 决策/未解项。
/// 快照即输入——删除链路调用方随即删 per_conv 与 DB 行,后台提炼只认快照不回读。
struct TerminalExtractSnapshot {
/// 全量 active user/assistant 消息(时间序,不受常规提炼尾部窗口限制)
messages: Vec<ChatMessage>,
/// WorkingContext 已确认决策(此刻内存最新态,决策 + 依据拼合)
decisions: Vec<String>,
/// 未解项(仅供提炼理解上下文,提示词已约定不作为知识产出)
unresolved: Vec<String>,
}
impl TerminalExtractSnapshot {
/// 有实质产出才提:active 消息达常规提炼的门槛量,或 WorkingContext 已有沉淀
/// (压缩后 active 消息少但决策/未解项仍在的会话,这些结构化输入本身值得提一次)。
fn has_extractable_input(&self) -> bool {
self.messages.len() >= 4 || !self.decisions.is_empty() || !self.unresolved.is_empty()
}
}
/// 决策条目单行文本:决策 + 依据(依据空则只留决策)。
fn decision_line(d: &df_ai::context_helpers::DecisionItem) -> String {
if d.reason.trim().is_empty() {
d.decision.clone()
} else {
format!("{}(依据: {})", d.decision, d.reason)
}
}
/// 从 per_conv 取终态提炼快照(须在挂点锁内同步调用:删除链路随后即删 per_conv)。
fn take_terminal_snapshot(conv: &PerConvState) -> TerminalExtractSnapshot {
TerminalExtractSnapshot {
messages: conv
.messages
.iter()
.filter(|m| {
matches!(m.role, MessageRole::User | MessageRole::Assistant) && m.is_active()
})
.cloned()
.collect(),
decisions: conv
.working_context
.decisions
.iter()
.map(decision_line)
.collect(),
unresolved: conv.working_context.unresolved.clone(),
}
}
/// 压缩存档级联清理(删除链路):会话删除后其存档文件再无召回方,一并移除。
/// 路径规则只读引用 df-ai 压缩召回存档约定(<data_dir>/context-archive/<conv_id>-archive.jsonl)。
fn remove_conv_archive(data_dir: &std::path::Path, conv_id: &str) {
let path = data_dir
.join("context-archive")
.join(format!("{}-archive.jsonl", conv_id));
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!(conv_id, path = %path.display(), "[knowledge] 会话压缩存档已级联清理"),
// 无存档(该会话未触发过压缩召回)是常态,静默
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(conv_id, error = %e, path = %path.display(), "[knowledge] 会话压缩存档清理失败(非阻断)"),
}
}
/// 会话终态提炼入口(写入收口):删除前 + parked 停靠两挂点共用。
///
/// 与常规提炼的差异:强制清水位(清 Done 节奏水位与哨兵,不受「10 条新用户消息」水位
/// 限制,复用手动提炼的清哨兵语义);提炼范围取全量 active 消息并喂入 WorkingContext
/// 决策/未解项;产出边界限跨会话可复用经验,未完成事项不进知识表。防重走 InFlight
/// 水位,与可能在飞的常规提炼互斥。
///
/// 挂点同步完成的只有快照与水位占位(微秒级),LLM 提炼后台 fire-and-forget,
/// 调用方(删除动作/停靠事件)不被阻塞。`cleanup_archive_dir`: Some = 删除链路,
/// 提炼收尾后级联清理压缩存档(先提炼后删文件);提炼未发生的三类出口
/// (无内存态/占位互斥/provider 不可用)存档照清,防孤儿文件。
pub(crate) async fn spawn_terminal_extraction(
state: &AppState,
conv_id: &str,
cleanup_archive_dir: Option<std::path::PathBuf>,
) {
// 会话标题(生命线溯源展示用):此刻行还在(删除链路调用方在挂点返回后才删行),
// 后台任务不再回读 DB,取不到降级占位。
let conv_title = conv_title_or_placeholder(
AiConversationRepo::new(&state.db)
.get_by_id(conv_id)
.await
.ok()
.flatten()
.and_then(|c| c.title),
);
// 快照 + 强制清水位同一锁临界区(TOCTOU 对齐 maybe_spawn_extraction):快照取此刻
// 内存最新态(含未落库轮次),清哨兵语义与手动提炼一致——只清 Done 节奏水位,
// InFlight 占位保留作互斥(下方占位检查拦截)。
let snapshot = {
let mut session = state.ai_session.lock().await;
let snap = session.conv_read(conv_id).map(take_terminal_snapshot);
if snap.is_some() {
session.conv(conv_id).knowledge_extracted = false;
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::Done(_))) {
wms.remove(conv_id);
}
}
snap
};
// 出口 1:无内存态——会话从未在本进程打开(消息只在 DB),无 WorkingContext 可喂,
// 删除链路即将删行,不做 DB 抢读竞态(设计:未及提炼的随会话消失是可接受损失)。
let Some(snapshot) = snapshot else {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
};
if !snapshot.has_extractable_input() {
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
// provider 先于 InFlight 占位解析:解析失败的出口不再占位(占位先置会因无任务
// 收口而永久卡死该会话的后续提炼)。
let provider_config = match super::prompt::get_active_provider(state).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(conv_id, error = %e, "[knowledge] 终态提炼跳过:无可用 provider");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
};
// 出口 2:InFlight 占位互斥——在飞常规提炼已在覆盖近期内容,不叠加刷 candidate。
{
let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
if matches!(wms.get(conv_id), Some(ExtractWatermark::InFlight)) {
tracing::info!(conv_id, "[knowledge] 终态提炼跳过:已有提炼任务进行中");
if let Some(dir) = &cleanup_archive_dir {
remove_conv_archive(dir, conv_id);
}
return;
}
wms.insert(conv_id.to_string(), ExtractWatermark::InFlight);
}
let db = state.db.clone();
let session_arc = state.ai_session.clone();
let llm_concurrency = state.llm_concurrency.clone();
let conv_id = conv_id.to_string();
spawn_with_inflight_guard(conv_id.clone(), async move {
let result =
extract_terminal_knowledge(&db, &conv_id, &snapshot, &provider_config, &llm_concurrency, &conv_title)
.await;
match &result {
Ok(inserted) => tracing::info!(conv_id = %conv_id, inserted, "[knowledge] 终态提炼完成"),
Err(e) => tracing::warn!(conv_id = %conv_id, error = %e, "[knowledge] 终态提炼失败(非阻断)"),
}
// 水位收口:任意出口记 Done 保节奏(与手动/自动路径同语义);哨兵只在会话仍在
// 内存时置位(get_mut 不惰性重建——删除链路的 per_conv 已随删除消失)。
{
let mut session = session_arc.lock().await;
let rounds = session
.conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter()))
.unwrap_or(0);
if matches!(result, Ok(n) if n > 0) {
if let Some(conv) = session.per_conv.get_mut(&conv_id) {
conv.knowledge_extracted = true;
}
}
EXTRACT_WATERMARKS
.lock()
.expect("提炼水位互斥锁中毒")
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
}
// 删除链路:提炼收尾后级联清理压缩存档(先提炼后删文件)。
if let Some(dir) = cleanup_archive_dir {
remove_conv_archive(&dir, &conv_id);
}
});
}
/// 终态提炼:全量 active 消息 + WorkingContext 决策/未解项喂提炼,产出限跨会话可复用
/// 经验(未完成事项由提示词边界拦截),落库走与常规提炼同一核心。
async fn extract_terminal_knowledge(
db: &Arc<Database>,
conv_id: &str,
snapshot: &TerminalExtractSnapshot,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
) -> anyhow::Result<usize> {
let mut conv_text = String::new();
// WorkingContext 此刻内存最新态:让「本会话定了什么、卡在哪」成为提炼输入而非仅靠
// 消息流重放;未解项标注仅供理解上下文,配合提示词边界不产出为知识。
if !snapshot.decisions.is_empty() {
conv_text.push_str("[本会话已确认的决策]\n");
for d in &snapshot.decisions {
conv_text.push_str(d);
conv_text.push('\n');
}
}
if !snapshot.unresolved.is_empty() {
conv_text.push_str("[本会话未解决的事项(仅供理解上下文,不是知识)]\n");
for u in &snapshot.unresolved {
conv_text.push_str(u);
conv_text.push('\n');
}
}
conv_text.push_str("\n[对话内容]\n");
for m in &snapshot.messages {
let role = match m.role {
MessageRole::User => "用户",
MessageRole::Assistant => "助手",
_ => continue,
};
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
conv_title,
&snapshot.messages,
&terminal_extraction_system_prompt(),
&conv_text,
)
.await
}
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
///
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
@@ -945,7 +1189,9 @@ fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
///
/// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。
/// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。
async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
/// 工具侧(knowledge_search)以空消息复用本函数:注入侧与工具侧同一归属口径,
/// 级 b 依赖消息扫描,工具 handler 无消息入参,空切片下仅级 a boundary 生效。
pub(crate) async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
let project_repo = ProjectRepo::new(db);
let conv_repo = AiConversationRepo::new(db);
@@ -1003,10 +1249,10 @@ fn normalize_dir_for_match(dir: &str) -> String {
s.to_lowercase()
}
/// 从对话中提炼知识,产出 candidate 写入知识库
/// 从对话中提炼知识,产出 candidate 写入知识库(常规自动/手动提炼入口)
///
/// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 →
/// LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate
/// 组装提炼输入 → [`run_extraction_core`] 完成提炼与落库
///
/// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置
/// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。
@@ -1021,12 +1267,7 @@ async fn extract_knowledge_from_conversation(
.get_by_id(conv_id)
.await?
.ok_or_else(|| anyhow::anyhow!("对话不存在: {}", conv_id))?;
// 对话标题(生命线溯源用,空标题降级为占位,避免字节切片风险)
let conv_title = conv
.title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string());
let conv_title = conv_title_or_placeholder(conv.title.clone());
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
// 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退,
@@ -1080,17 +1321,8 @@ async fn extract_knowledge_from_conversation(
return Ok(0); // 太短,不值得提炼(0 条,不置去重标志)
}
// F-260619-04 P1 消息级溯源:取末条 assistant 消息 id(本轮 AI 产出知识的载体)。
// 反向扫描全量 messages(不限 recent 6 条,确保取到对话最新 assistant,即便它不在
// 提炼窗口内也属于本轮 AI 产出)。老数据消息无 id → None,source_ref 降级 None,
// 展示侧兼容 conv: 旧格式 + 无 source_ref。
let last_assistant_msg_id: Option<&str> = messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
// 构造提炼消息: system 指令 + 对话内容(user 角色)
// 构造提炼输入:常规提炼只看尾部窗口(终态提炼走 extract_terminal_knowledge 另入口,
// 全量 active + WorkingContext 上下文一并喂提炼)
let mut conv_text = String::new();
for m in recent.iter().rev() {
let role = match m.role {
@@ -1101,8 +1333,92 @@ async fn extract_knowledge_from_conversation(
conv_text.push_str(&format!("[{}]: {}\n\n", role, m.content));
}
run_extraction_core(
db,
conv_id,
provider_config,
llm_concurrency,
&conv_title,
&messages,
EXTRACTION_SYSTEM_PROMPT,
&conv_text,
)
.await
}
/// 会话标题降级(提炼生命线溯源展示用):空白标题降级占位。
fn conv_title_or_placeholder(title: Option<String>) -> String {
title
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| "未命名对话".to_string())
}
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
fn candidate_record_from_item(
item: &serde_json::Value,
source_project: Option<&str>,
source_ref: Option<String>,
) -> Option<KnowledgeRecord> {
let kind = item.get("kind").and_then(|v| v.as_str())?.to_string();
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
// 空白标题/正文与 kind 缺失同判:残缺条目跳过(与 knowledge_insert 工具入参校验同口径)
if title.trim().is_empty() || content.trim().is_empty() {
return None;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
Some(KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.map(|p| p.to_string()),
source_ref,
reasoning,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
}
/// 提炼核心(常规尾部窗口/终态全量共用):LLM 提炼 → 解析截断打捞 → 归属推断 →
/// candidate 批量落库。提炼输入由调用方组装;归属与消息级溯源口径统一
/// (source_ref 记 conv_msg:{assistant_msg_id},老数据无 id 降级 conv:{conv_id})。
async fn run_extraction_core(
db: &Arc<Database>,
conv_id: &str,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
conv_title: &str,
attribution_messages: &[ChatMessage],
system_prompt: &str,
conv_text: &str,
) -> anyhow::Result<usize> {
// 消息级溯源:末条 assistant 消息 id 是本轮 AI 产出知识的载体(常规路径反向扫全量
// messages,压缩消息也在内,确保取到对话最新 assistant;老数据无 id → 降级对话级)。
let last_assistant_msg_id: Option<&str> = attribution_messages
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|m| m.id.as_deref());
let source_ref = Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
});
let extract_messages = vec![
ChatMessage::system(EXTRACTION_SYSTEM_PROMPT),
ChatMessage::system(system_prompt),
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
];
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
@@ -1177,50 +1493,14 @@ async fn extract_knowledge_from_conversation(
let knowledge_repo = KnowledgeRepo::new(db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
// 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。
let source_project = resolve_source_project(db, conv_id, &messages).await;
let source_project = resolve_source_project(db, conv_id, attribution_messages).await;
if let Some(pid) = &source_project {
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
}
let mut inserted = 0;
for item in &items {
let kind = match item.get("kind").and_then(|v| v.as_str()) {
Some(k) => k.to_string(),
None => continue,
};
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
if title.is_empty() || content.is_empty() {
let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
continue;
}
let tags = item.get("tags").map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
let confidence = item.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_string());
// 回填 AI 判断依据(prompt 要求的 reasoning 字段,此前被丢弃)
let reasoning = item.get("reasoning").and_then(|v| v.as_str()).map(|s| s.to_string());
let now = crate::commands::now_millis();
let record = KnowledgeRecord {
id: new_id(),
kind,
title,
content,
tags,
status: "candidate".to_string(),
confidence,
reuse_count: 0,
verified: false,
// 跨会话召回(D1):归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局
source_project: source_project.clone(),
// F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。
// last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级,
// 展示侧双格式解析兼容。
source_ref: Some(match last_assistant_msg_id {
Some(mid) => format!("conv_msg:{}", mid),
None => format!("conv:{}", conv_id),
}),
reasoning: reasoning.clone(),
embedding_status: None,
created_at: now.clone(),
updated_at: now,
};
match knowledge_repo.insert(record.clone()).await {
Ok(_) => {
@@ -1235,8 +1515,8 @@ async fn extract_knowledge_from_conversation(
.record_extracted(
&record.id,
conv_id,
&conv_title,
reasoning.as_deref().unwrap_or(""),
conv_title,
record.reasoning.as_deref().unwrap_or(""),
last_assistant_msg_id,
)
.await;
@@ -1521,6 +1801,96 @@ mod tests {
assert!(out.contains(&"编译".to_string()));
assert!(out.contains(&"失败".to_string()));
}
// ---------- 会话终态提炼(跨会话召回写入收口) ----------
/// LLM 产出单条 → candidate 记录统一口径:溯源 source_ref 由调用方给定(消息级
/// conv_msg:{id}/对话级 conv:{id}),状态恒 candidate 不自动发布,归属项目透传。
#[test]
fn candidate_record_carries_source_ref_and_candidate_status() {
let item = serde_json::json!({
"kind": "pitfall",
"title": "跨端比对乱名",
"content": "传输层截断假象,两端各算 md5 数值裁决",
"tags": ["proxy"],
"confidence": "medium",
"reasoning": "可复用的排障口径"
});
let rec = candidate_record_from_item(&item, Some("p1"), Some("conv_msg:m-1".into()))
.expect("完整条目应产出记录");
assert_eq!(rec.status, "candidate", "LLM 产出恒为候选,不自动发布");
assert_eq!(rec.source_ref.as_deref(), Some("conv_msg:m-1"));
assert_eq!(rec.source_project.as_deref(), Some("p1"));
assert_eq!(rec.reasoning.as_deref(), Some("可复用的排障口径"));
}
/// kind 缺失或标题/正文空白的条目跳过,不产出残缺记录刷 candidate。
#[test]
fn candidate_record_skips_incomplete_items() {
let no_kind = serde_json::json!({"title": "t", "content": "c"});
assert!(candidate_record_from_item(&no_kind, None, None).is_none());
let blank_title = serde_json::json!({"kind": "pitfall", "title": " ", "content": "c"});
assert!(candidate_record_from_item(&blank_title, None, None).is_none());
}
/// 终态快照:取全量 active user/assistant 消息(压缩/软删消息排除)+ WorkingContext
/// 决策(决策+依据拼合)与未解项。
#[test]
fn terminal_snapshot_takes_active_messages_and_working_context() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("早期问题"));
let mut compressed = ChatMessage::assistant("已压缩回复");
compressed.status = Some(df_ai::provider::MessageStatus::Compressed);
conv.messages.push(compressed);
conv.messages.push(ChatMessage::assistant("最新回复"));
conv.working_context.decisions.push(df_ai::context_helpers::DecisionItem {
question: "用哪个端口".into(),
decision: "统一走 3307 代理".into(),
reason: "防直连风控".into(),
});
conv.working_context.unresolved.push("证书续期未验证".into());
let snap = take_terminal_snapshot(&conv);
assert_eq!(snap.messages.len(), 2, "非 active 消息不进终态提炼");
assert_eq!(snap.decisions, vec!["统一走 3307 代理(依据: 防直连风控)".to_string()]);
assert_eq!(snap.unresolved, vec!["证书续期未验证".to_string()]);
assert!(snap.has_extractable_input());
}
/// 空闲寒暄会话(消息过少且 WorkingContext 无沉淀)不提,省一次无效 LLM 调用。
#[test]
fn terminal_snapshot_chitchat_not_extractable() {
let mut conv = PerConvState::new();
conv.messages.push(ChatMessage::user("在吗"));
conv.messages.push(ChatMessage::assistant("在"));
assert!(!take_terminal_snapshot(&conv).has_extractable_input());
}
/// 存档级联清理:对应会话存档文件消失,他会话不受波及;重复清理幂等(NotFound 静默)。
#[test]
fn remove_conv_archive_deletes_only_target() {
let base = std::env::temp_dir().join(format!("d2a-arch-{}", std::process::id()));
let dir = base.join("context-archive");
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("conv-x-archive.jsonl");
std::fs::write(&target, "{\"role\":\"user\",\"content\":\"历史\"}\n").unwrap();
let other = dir.join("conv-y-archive.jsonl");
std::fs::write(&other, "keep").unwrap();
remove_conv_archive(&base, "conv-x");
assert!(!target.exists(), "对应会话存档应级联清理");
assert!(other.exists(), "他会话存档不受波及");
remove_conv_archive(&base, "conv-x"); // 幂等:文件已不在不报错
let _ = std::fs::remove_dir_all(&base);
}
/// 空白标题降级占位(生命线溯源展示不为空)。
#[test]
fn conv_title_blank_falls_back_to_placeholder() {
assert_eq!(conv_title_or_placeholder(None), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some(" ".into())), "未命名对话");
assert_eq!(conv_title_or_placeholder(Some("标题".into())), "标题");
}
}
#[cfg(test)]
@@ -1616,6 +1986,7 @@ mod salvage_tests {
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
+26
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;
@@ -74,6 +76,8 @@ pub mod unattended;
pub mod outbox;
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
pub mod wake;
// 脉络卡数据面:published 知识按项目聚合,纯读侧喂前端「带入上下文」决策
pub mod context_briefs;
use agentic::conv_state::{ConvState, ConvStateStore};
@@ -313,6 +317,11 @@ pub enum AiChatEvent {
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
AiAutoCompressed { conversation_id: Option<String>, summary: String },
/// E2 压缩失败降级透出(2026-10-18):LLM 压缩失败走关键词摘要兜底时,loop 仍在继续
/// (不发 AiError——其 case 无条件 setStreaming(false) 会误判生成结束),但用户须知晓
/// 上下文已降级(实证 76730ce8 seq166:静默降级→AI 失忆→用户被迫原句重发)。
/// 前端弹一次性状态提示,不中断流。
AiContextDegraded { conversation_id: Option<String>, reason: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
@@ -1061,6 +1070,10 @@ pub struct PerConvState {
/// 原轮次终点时读后清零消费、续跑一轮,让补充指令获得带工具的完整消费轮。
/// 同锁读写(session lock 串行);loop 入口清零防跨 loop 残留。
pub round_budget_extra: usize,
/// 未消化注入计数(2026-10-14 实测):inject_runtime_message 每次 +1;每轮 build_for_request
/// 组装后清零(消息已进本轮请求=消化);收敛出口查 >0 则不收敛续一轮,治「纯问答轮
/// 一轮收敛,生成期间到达的补充指令无人消费只能等下次请求」的丢失。内存态不落库。
pub pending_injects: u32,
/// 当前 agent 循环的语言设置(用于审批后恢复循环)
pub agent_language: Option<String>,
/// 用户指定模型 override(主对话专用,语义同 AiSession.model_override)
@@ -1262,6 +1275,7 @@ impl PerConvState {
iteration_used: 0,
loop_max_rounds: 0,
round_budget_extra: 0,
pending_injects: 0,
agent_language: None,
model_override: None,
session_trust: HashSet::new(),
@@ -1496,4 +1510,16 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
"任务已停靠",
reason,
);
// ④ 跨会话召回(终态提炼停靠挂点):停靠意味着任务未完结,此刻的目标/决策/未解项
// 最有提炼价值。fire-and-forget 不阻塞事件/通知链路,提炼失败仅内部留痕;
// 挂点同步快照在后台任务首段完成(本函数同步返回,不持 session 锁)。
{
use tauri::Manager;
let app = app.clone();
let conv_id = conv_id.to_string();
tauri::async_runtime::spawn(async move {
let state = app.state::<crate::state::AppState>();
knowledge_inject::spawn_terminal_extraction(&state, &conv_id, None).await;
});
}
}
+92 -10
View File
@@ -127,6 +127,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\
- {}\n\
- {}\n\
- Response style: lead with the conclusion and the action/result; omit restating the user's question, pleasantries, and summary closers; prefer bullet points over paragraphs; do not expand on what was not asked. Explain whenever explanation is needed — cut filler, not substance.\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
@@ -160,6 +161,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\
- {}\n\
- {}\n\
- 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
@@ -445,7 +447,10 @@ async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<Str
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
/// - 活跃 project/task 指纹:task 取 id+updated_at;project 取 id+活跃时间(三源取大:
/// tasks.updated_at 最新/project_events 最新 created_at/projects.updated_at,仅未归档未删除,
/// 与「当前项目」清单的 list_active_with_activity 排序输入对齐)——任务更新/归档变化均
/// 反映到指纹,清单排序变了缓存串必失效。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint(
state: &AppState,
@@ -463,7 +468,11 @@ async fn system_prompt_fingerprint(
.unwrap_or_default();
let proj_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
"SELECT p.id, MAX(\
IFNULL((SELECT MAX(t.updated_at) FROM tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL), 0), \
IFNULL((SELECT MAX(e.created_at) FROM project_events e WHERE e.project_id = p.id), 0), \
IFNULL(p.updated_at, 0)\
) FROM projects p WHERE p.deleted_at IS NULL AND p.archived = 0",
)
.await?;
let task_fp = entity_fingerprint(
@@ -586,24 +595,25 @@ async fn build_context_lists(
) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new();
// 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await {
// 附加当前数据上下文。项目段按最近活跃排序(任务活跃为主信号),治创建序误导项目绑定
// (创建序会把早停摆项目排在清单首位,AI 误当最高优先项目锚点)。
if let Ok(projects) = state.projects.list_active_with_activity().await {
if !projects.is_empty() {
out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) {
if excluded_project_ids.iter().any(|id| id == &p.record.id) {
continue;
}
out.push_str(&format!(
"- {} (id: {}) ({}): {}\n",
p.name,
p.id,
p.status.as_str(),
truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS)
p.record.name,
p.record.id,
p.record.status.as_str(),
truncate_desc(&p.record.description, CONTEXT_DESC_MAX_CHARS)
));
if let Some(ref dir) = p.path {
if let Some(ref dir) = p.record.path {
out.push_str(&format!(" 目录: {}\n", dir));
}
}
@@ -681,6 +691,10 @@ fn parse_cache_flag(v: &str) -> bool {
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
///
/// 同处顺带读缓存友好上下文总开关 `df-ai-cache-friendly-context`(默认开,值语义同
/// [`parse_cache_flag`])同步 BP3 摘要链尾断点开关(anthropic_compat 不读本侧 KV,
/// 照 set_anthropic_cache_enabled 进程级同步先例)。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
@@ -691,9 +705,51 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
.unwrap_or_default();
let enabled = parse_cache_flag(&v);
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
let friendly_v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
df_ai::anthropic_compat::set_bp3_enabled(parse_cache_flag(&friendly_v));
enabled
}
/// 读取压缩可召回开关(AppState settings KV `df-ai-compress-recall`,默认开)。
///
/// 值语义:显式 "false"/"0"(设置页开关关)→ 关;缺省/空(从未设置)/"true"/"1"/其余杂值
/// → 开(按规格仅显式关闭生效)。开时调用方在压缩标记前把 data_dir + conv_id 注入
/// ContextManager(`set_compress_recall`):被压原文落盘存档,压缩摘要尾部拼召回句柄行
/// (LLM 经 read_file 回读);关时零行为变更(不落盘、摘要无句柄行)。
pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-compress-recall")
.await
.ok()
.flatten()
.unwrap_or_default();
!(v == "false" || v == "0")
}
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
///
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
/// 开时调用方在 `build_for_request` 前把值注入 ContextManager(`set_cache_friendly`):
/// 超预算裁剪走块状(目标水位+轮边界吸附)+ 存储态 system 锚点 view 层转 user;
/// 关时两者旁路,裁剪与视图与旧路径逐字节等价。
pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-cache-friendly-context")
.await
.ok()
.flatten()
.unwrap_or_default();
parse_cache_flag(&v)
}
/// 构建 system prompt(Anthropic prompt caching 模式)。
///
/// 返回 `(system 稳定段, 易变尾段)`:
@@ -875,6 +931,32 @@ mod tests {
assert!(prefix.contains("聚焦准则"));
}
// M4 回复风格纪律:中文版行为准则含「回复风格」条款(反口水话,不反内容)
#[test]
fn system_prompt_zh_has_response_style_clause() {
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(
prefix.contains("回复风格"),
"中文 system prompt 应含回复风格条款,实际: {}",
prefix
);
// 核心条款抽样:结论先行 + 「省的是空话,不是内容」的度(需要解释时照常解释)
assert!(prefix.contains("结论先行"));
assert!(prefix.contains("省的是空话,不是内容"));
}
// M4 回复风格纪律:英文版 Guidelines 含 Response style 条款
#[test]
fn system_prompt_en_has_response_style_clause() {
let (prefix, _, _) = system_prompt_parts("en");
assert!(
prefix.contains("Response style"),
"英文 system prompt 应含 Response style 条款,实际: {}",
prefix
);
assert!(prefix.contains("cut filler, not substance"));
}
// 文档探索策略段(中/英)存在且含关键引导
#[test]
fn doc_exploration_strategy_section_present_zh_en() {
@@ -25,6 +25,25 @@ struct FailureEntry {
last_error: String,
}
/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout
/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合:
/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀
/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。
static TOOL_TIMEOUT_STREAKS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
/// 超时连败软收敛阈值(跨签名聚合口径)。
pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3;
fn timeout_streaks() -> &'static Mutex<HashMap<String, u32>> {
TOOL_TIMEOUT_STREAKS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 错误摘要是否属超时族(中英特征,大小写不敏感)。
fn is_timeout_error(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("超时") || lower.contains("timeout") || lower.contains("timed out")
}
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
@@ -70,34 +89,50 @@ fn sort_json(v: &serde_json::Value) -> serde_json::Value {
}
}
/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。
/// 未达标/无记忆 → None 放行。
/// 执行前检查:同 key 连败达标 → 拦截;工具级超时连败达标 → 软收敛警告(不拦执行)。
/// 两者都未达标/无记忆 → None 放行。
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
let key = memory_key(tool, args);
let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
let Some(entry) = mem.get(&key) else { return None };
if let Some(entry) = mem.get(&key) {
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
Some(format!(
return Some(format!(
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
entry.consecutive,
entry.last_error.replace('\"', "'"),
entry.consecutive
))
} else {
));
}
}
// E13 工具级超时连败(跨签名聚合):换参数/换档位逃不出工具维度。
let streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
if let Some(streak) = streaks.get(tool) {
if *streak >= TIMEOUT_STREAK_WARN_AFTER {
return Some(format!(
"{{\"status\":\"timeout_streak_warning\",\"tool\":\"{tool}\",\"timeout_streak\":{},\"hint\":\"该工具已连续超时 {} 次(不同参数/档位都超)。继续原路大概率仍超时:①把任务拆小分步执行 ②换更轻量的方式(降低数据量/改后台执行) ③若确实需要长时间运行,向用户说明并确认后再继续。\"}}",
streak, streak
));
}
}
None
}
}
/// 执行后回写:成功清零删除,失败递增并记最近错误。
/// 执行后回写:成功清零删除(签名级+工具级超时计数),失败递增并记最近错误;
/// 失败属超时族时工具级超时连败 +1。
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
let key = memory_key(tool, args);
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
let mut streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
if ok {
mem.remove(&key);
streaks.remove(tool);
} else {
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
e.consecutive += 1;
e.last_error = error.unwrap_or_default().chars().take(200).collect();
if is_timeout_error(&e.last_error) {
*streaks.entry(tool.to_string()).or_insert(0) += 1;
}
}
}
@@ -145,4 +180,38 @@ mod tests {
let a = json!({"command": "cargo test", "working_dir": "E:/x"});
assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
}
#[test]
fn timeout_streak_aggregates_across_signatures_and_soft_warns() {
clear();
timeout_streaks().lock().unwrap().clear();
// 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦;
// 工具级 streak 在第 3 次超时发生后达 3,同工具再调用(含当次迭代)即软警告
for (i, timeout) in ["60", "300", "600"].iter().enumerate() {
let args = json!({"command": format!("cargo build --timeout {timeout}")});
record("run_command", &args, false, Some(format!("命令执行超时({timeout}s)")));
if i < 2 {
assert!(precheck("run_command", &args).is_none(), "签名级 1 次且 streak<3 不拦");
}
}
// 新签名(第 4 档):工具级超时连败 ≥3 → 软收敛警告(放行执行但注入换路引导)
let args4 = json!({"command": "cargo build --timeout 900"});
let warn = precheck("run_command", &args4).unwrap();
assert!(warn.contains("timeout_streak_warning"));
assert!(warn.contains("\"timeout_streak\":3"));
// 同工具任一成功 → 工具级清零
record("run_command", &json!({"command": "ls"}), true, None);
assert!(precheck("run_command", &args4).is_none());
}
#[test]
fn non_timeout_failures_do_not_aggregate_to_timeout_streak() {
clear();
timeout_streaks().lock().unwrap().clear();
for path in ["/a.rs", "/b.rs", "/c.rs"] {
record("read_file", &json!({"path": path}), false, Some("文件不存在".into()));
}
// 3 连败但非超时族 → 无工具级警告
assert!(precheck("read_file", &json!({"path": "/d.rs"})).is_none());
}
}
@@ -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(),
}
@@ -1,14 +1,13 @@
//! D2a 工具结果过期外置 + D2d 90 天归档:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
//! D2a 工具结果过期外置:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
//!
//! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」):
//! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt,
//! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。
//! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧
//! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。
//! - 归档:2026-10-09 用户决策,原设计的 90 天清理已停用,外置文件全部保留不删。
//! 用户明确「外置文件超过 90 天也不能删,暂时都保留」。cleanup_expired 本体与单测保留
//! (未来若需恢复只改 unattended.rs 调用点),但当前无任何调用点,外置文件只增不减。
//! read_offloaded 读回入口已接线(IPC ai_read_offloaded_result),句柄永远可读回。
//! - 归档清理已彻底移除(2026-10-11):原 90 天归档设计(2026-10-06 提出,2026-10-09 停用)
//! 现整体删除,外置文件只增不减、永不清理。read_offloaded 读回入口已接线
//! (IPC ai_read_offloaded_result),句柄永远可读回。
//! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。
use std::path::PathBuf;
@@ -35,11 +34,6 @@ pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String {
/// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。
pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024;
/// 归档阈值(原设计 2026-10-06):外置文件 90 天后可清理。
/// 2026-10-09 用户决策:90 天清理已停用,外置文件全部保留,常量保留供未来恢复。
#[allow(dead_code)]
pub const ARCHIVE_AFTER_DAYS: u64 = 90;
/// 句柄 JSON 字段名(读取降级判据)。
const HANDLE_MARK: &str = "__offloaded__";
@@ -65,7 +59,7 @@ pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str)
return result.to_string();
}
let head: String = result.chars().take(200).collect();
// meta 旁路文件:cleanup_expired 的认领判据(防误删同目录非本模块产物)
// meta 旁路文件:标记本模块自产物(带 offloaded 标记),供溯源识别
let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name });
let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string());
let handle = serde_json::json!({
@@ -87,57 +81,6 @@ pub fn read_offloaded(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}"))
}
/// 清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
/// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删
/// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。
///
/// 2026-10-09 用户决策:已停用,外置文件超过 90 天也不删,暂时全部保留。
/// 调用点已从 unattended.rs 摘除(改为空转)。函数本体与单测保留供未来恢复。
#[allow(dead_code)]
pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) {
let root = base.join("tool-results");
let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) };
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(ARCHIVE_AFTER_DAYS * 86400);
let mut removed = 0usize;
let mut freed = 0u64;
for month in entries.flatten() {
let Ok(files) = std::fs::read_dir(month.path()) else { continue };
for f in files.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
// marker 头校验:句柄 JSON 写入文件首行注释不行(txt 是原文)——改判据:
// 同名 .meta.json 存在且 offloaded=true 才认领。写入侧同步产出 meta。
let meta = p.with_extension("meta.json");
let Ok(meta_str) = std::fs::read_to_string(&meta) else { continue };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta_str) else { continue };
if !v.get(HANDLE_MARK).and_then(|b| b.as_bool()).unwrap_or(false) {
continue;
}
let Ok(md) = f.metadata() else { continue };
let Ok(modified) = md.modified() else { continue };
let mtime = modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if mtime < cutoff {
let size = md.len();
if std::fs::remove_file(&p).is_ok() {
let _ = std::fs::remove_file(&meta);
removed += 1;
freed += size;
}
}
}
}
(removed, freed)
}
#[cfg(test)]
mod tests {
use super::*;
+38 -3
View File
@@ -1098,7 +1098,7 @@ pub fn register(
}
// output_mode 分派返回结构
let result = match output_mode {
let mut result = match output_mode {
"files_with_matches" => {
// 仅返命中文件路径列表(去重,顺序保留首次命中)
let files: Vec<String> = matches_out.iter()
@@ -1183,6 +1183,11 @@ pub fn register(
})
}
};
// 0 命中「阴性存疑」提示:路径/glob 写法/大小写任一不对都静默 0 命中,
// LLM 拿空结果易直接当「不存在」下结论(实测假阴性被当事实断言 3 轮),强制附一行复核提示。
if total == 0 {
result["hint"] = serde_json::json!(negative_hit_hint("路径/glob 写法/大小写 -i"));
}
Ok(result)
}
);
@@ -1228,10 +1233,13 @@ pub fn register(
let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
});
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)+ 阴性存疑提示(0 命中≠不存在)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 {
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
out["hint"] = serde_json::json!(format!(
"{}\n未找到匹配文件。可尝试: ① 补 recursive=true 搜子目录(不传只搜当前层); ② 放宽关键词(更短/字符更少); ③ 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配",
negative_hit_hint("路径/recursive")
));
}
Ok(out)
}
@@ -1675,6 +1683,16 @@ fn file_not_found_error(path: &str) -> anyhow::Error {
anyhow::anyhow!(msg)
}
/// 搜索工具(search_files/grep)0 命中「阴性存疑」提示。
///
/// 治勘察假阴性被当事实断言:search_files 未传 recursive 只搜根目录,
/// 0 命中被 LLM 直接当「功能未实施」连续断言 3 轮。路径/glob/recursive 等范围
/// 参数写错时同样静默 0 命中,故空结果强制附本提示让 LLM 复核后再下结论。
/// 措辞克制一行;`scope` 按工具传入各自的复核点。
fn negative_hit_hint(scope: &str) -> String {
format!("⚠ 0 命中不一定代表不存在——请复核搜索范围({scope})后再下结论")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2003,6 +2021,23 @@ mod tests {
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
}
// ============================================================
// negative_hit_hint — 搜索工具 0 命中阴性存疑提示(治假阴性被当事实断言)
// ============================================================
/// 两搜索工具 0 命中提示共用句式:声明「0 命中≠不存在」+ 按工具各自的复核点。
#[test]
fn negative_hit_hint_states_doubt_and_scope() {
// grep 侧复核点:路径/glob 写法/大小写
let grep_hint = negative_hit_hint("路径/glob 写法/大小写 -i");
assert!(grep_hint.contains("0 命中不一定代表不存在"), "应声明阴性存疑,实际: {grep_hint}");
assert!(grep_hint.contains("复核搜索范围"), "应引导复核,实际: {grep_hint}");
assert!(grep_hint.contains("glob"), "grep 侧应含 glob 复核点,实际: {grep_hint}");
// search_files 侧复核点:recursive(实测根因是未传 recursive 只搜根目录)
let search_hint = negative_hit_hint("路径/recursive");
assert!(search_hint.contains("recursive"), "search_files 侧应含 recursive 复核点,实际: {search_hint}");
}
}
+231 -9
View File
@@ -11,9 +11,12 @@
//! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升
//! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。
//!
//! 溯源说明:工具 handler 只有 args 入参,无会话上下文注入机制,拿不到 conv_id,
//! 故 source_ref 记渠道标记 "ai-chat" 兜底(标识写入来源是 AI 对话,精确到会话待
//! 上下文注入机制具备后补全)。
//! 会话上下文通道:handler 仍只有 args 入参,会话 id 由执行链注入——所有工具执行
//! (process_tool_calls 与审批/授权窗口)统一经 execute_with_heartbeat 包
//! spawn_agent::scope_caller_conv,handler 内 caller_conv() 读取。search 消费:
//! 解析当前会话归属项目接三档软排序,无 scope(直调)或归属推断失败降级全局检索;
//! insert 消费:source_ref 记 conv:{id} 与自动提炼产物同格式,归属项目同口径推断,
//! 无 scope 降级渠道标记 "ai-chat" 兜底溯源。
//!
//! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind
//! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/
@@ -31,7 +34,8 @@ use df_storage::models::KnowledgeRecord;
use df_types::types::new_id;
use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record};
use crate::commands::ai::knowledge_inject::{extract_query_keywords, resolve_source_project, spawn_embedding_for_record};
use crate::commands::ai::tools::spawn_agent::caller_conv;
use crate::commands::knowledge_timeline::KnowledgeTimeline;
use crate::commands::now_millis;
use crate::state::load_knowledge_config;
@@ -52,7 +56,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"knowledge_search",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,当前会话关联项目的知识排前,其后按标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
RiskLevel::Low,
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
args => {
@@ -66,7 +70,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。
// 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。
let keywords = extract_query_keywords(query);
let hits = repo.search_keywords(&keywords, false, limit).await?;
// 会话上下文通道(caller_conv)解析归属项目接三档软排序:同项目排前,全局与
// 异项目仍可召回不淹没。软排序只改顺序不改命中面;无 scope(直调)或归属
// 推断失败降级 project_id=None,与全局检索行为逐字一致,不报错。
let current_project = match caller_conv() {
Some(conv_id) => resolve_source_project(&db, &conv_id, &[]).await,
None => None,
};
let hits = repo
.search_keywords_scoped(&keywords, false, limit, current_project.as_deref())
.await?;
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
if hits.is_empty() {
@@ -123,6 +136,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
.collect();
serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string())
});
// 写入归属(跨会话召回写入收口):会话上下文通道解析当前会话,source_ref 记
// conv:{id} 与自动提炼产物同格式,归属项目同口径推断(boundary 级 a);
// 无 scope(直调)降级渠道标记兜底溯源,与 search 无 scope 降级同风格。
let (source_project, source_ref) = match caller_conv() {
Some(conv_id) => (
resolve_source_project(&db, &conv_id, &[]).await,
Some(format!("conv:{}", conv_id)),
),
None => (None, Some("ai-chat".to_string())),
};
let now = now_millis();
let record = KnowledgeRecord {
id: new_id(),
@@ -135,9 +158,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
confidence: None,
reuse_count: 0,
verified: false,
source_project: None,
// handler 无会话上下文,拿不到 conv_id,记渠道标记兜底溯源
source_ref: Some("ai-chat".to_string()),
source_project,
source_ref,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
@@ -204,3 +226,203 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
}
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::ai::boundary::TaskBoundary;
use crate::commands::ai::tools::spawn_agent::scope_caller_conv;
use serde_json::json;
async fn test_db() -> Arc<Database> {
Arc::new(Database::open_in_memory().await.unwrap())
}
async fn ins_project(db: &Arc<Database>, id: &str, path: &str) {
df_storage::crud::ProjectRepo::new(db)
.insert(df_storage::models::ProjectRecord {
id: id.into(),
name: format!("项目-{id}"),
description: String::new(),
status: df_types::types::ProjectStatus::Planning,
idea_id: None,
path: Some(path.into()),
stack: None,
archived: false,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
})
.await
.unwrap();
}
async fn ins_conv_with_boundary(db: &Arc<Database>, id: &str, dirs: &[&str]) {
let boundary = TaskBoundary {
risk_ceiling: RiskLevel::Medium,
dir_allowlist: dirs.iter().map(|d| d.to_string()).collect(),
forbid_destructive: true,
budget: None,
};
df_storage::crud::AiConversationRepo::new(db)
.insert(df_storage::models::AiConversationRecord {
id: id.into(),
title: Some("t".into()),
messages: "[]".into(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
origin: None,
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
loop_state: None,
status: "active".into(),
working_context_json: None,
})
.await
.unwrap();
}
async fn ins_knowledge(db: &Arc<Database>, title: &str, project: Option<&str>, reuse: i32) {
let now = now_millis();
df_storage::crud::KnowledgeRepo::new(db)
.insert(KnowledgeRecord {
id: new_id(),
kind: "pitfall".into(),
title: title.into(),
content: format!("{title} 正文"),
tags: None,
status: "published".into(),
confidence: None,
reuse_count: reuse,
verified: false,
source_project: project.map(|p| p.into()),
source_ref: None,
reasoning: None,
embedding_status: None,
created_at: now.clone(),
updated_at: now,
})
.await
.unwrap();
}
fn search_titles(out: &serde_json::Value) -> Vec<&str> {
out["results"]
.as_array()
.unwrap()
.iter()
.map(|r| r["title"].as_str().unwrap())
.collect()
}
/// 注入通道端到端 + 项目软排序:scope 内执行 search,按会话 boundary 解析归属项目,
/// 同项目条目排前(异项目即便复用次数更高,项目档位是第一排序键也压不过)。
#[tokio::test]
async fn search_scoped_ranks_same_project_first() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_project(&db, "p2", "e:/wk-lab/proj-b").await;
ins_conv_with_boundary(&db, "conv-1", &["e:/wk-lab/proj-a/"]).await;
// 两标题对查询「部署踩坑」的 2-gram 命中分相同,全局序下复用 9 的 p2 在前;
// scoped 序下 p1 靠项目档位反超。
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-1".to_string(), async {
registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
})
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑实录", "部署踩坑指南"],
"同项目知识应排前(项目档位为第一排序键)"
);
}
/// 无会话上下文(未 scope 直调)降级全局检索:不报错,排序回落标题命中+复用降序,
/// 高复用异项目条目回首位,证明走的是全局序而非 scoped 序。
#[tokio::test]
async fn search_without_scope_falls_back_to_global() {
let db = test_db().await;
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_search", json!({"query": "部署踩坑"}))
.await
.unwrap();
assert_eq!(
search_titles(&out),
vec!["部署踩坑指南", "部署踩坑实录"],
"无项目上下文应回落全局序(复用降序)且不报错"
);
}
/// 注入通道在位性:scope 内 caller_conv 解析出注入的会话 id(search 与 insert 沉淀
/// 通道共用同一读取点,后者写入收口批接入);scope 外读不到(None,直调防御)。
#[tokio::test]
async fn caller_conv_channel_resolves_inside_scope() {
assert!(caller_conv().is_none(), "scope 外应读不到会话 id");
let seen = scope_caller_conv("conv-chan".to_string(), async { caller_conv() }).await;
assert_eq!(seen.as_deref(), Some("conv-chan"), "scope 内应解析出注入的会话 id");
}
/// 写入归属补全(写入收口):scope 内 insert,source_ref 记 conv:{id} 与自动提炼
/// 产物同格式,归属项目按会话 boundary 同口径解析(级 a)。
#[tokio::test]
async fn insert_scoped_records_conv_source_ref() {
let db = test_db().await;
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
ins_conv_with_boundary(&db, "conv-9", &["e:/wk-lab/proj-a/"]).await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = scope_caller_conv("conv-9".to_string(), async {
registry
.execute("knowledge_insert", json!({"title": "端口约定", "content": "数据库统一走 3307 代理"}))
.await
})
.await
.unwrap();
assert_eq!(out["status"], "candidate");
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("conv:conv-9"), "溯源与自动提炼产物同格式");
assert_eq!(rec.source_project.as_deref(), Some("p1"), "归属项目按 boundary 解析");
}
/// 无 scope(直调)降级渠道标记兜底溯源,归属项目留空,不报错。
#[tokio::test]
async fn insert_without_scope_falls_back_to_channel_ref() {
let db = test_db().await;
let mut registry = AiToolRegistry::new();
register(&mut registry, &db);
let out = registry
.execute("knowledge_insert", json!({"title": "要点", "content": "内容"}))
.await
.unwrap();
let rec = df_storage::crud::KnowledgeRepo::new(&db)
.get_by_id(out["id"].as_str().unwrap())
.await
.unwrap()
.expect("insert 返回的 id 应可回读");
assert_eq!(rec.source_ref.as_deref(), Some("ai-chat"));
assert_eq!(rec.source_project, None);
}
}
+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_* 会继承别的会话元数据;
+15 -29
View File
@@ -1044,34 +1044,8 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) {
}
// 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志
run_self_monitor(app, cfg).await;
// 归档清理已停用(2026-10-09 用户决策):外置文件超过 90 天也不删,全部保留。
// 原行为是每日一次清 90 天前外置派生文件。现摘除调用点,cleanup_expired 本体与单测
// 保留,未来若需恢复只改此处。
run_daily_offload_cleanup(app).await;
}
/// 每日归档清理,已停用(2026-10-09 用户决策:外置文件全部保留,不按 90 天删)。
///
/// 保留函数骨架作为恢复点:若未来恢复归档,取消下方注释即可,cleanup_expired 本体
/// 与单测均未删。当前为空转,不触碰任何外置文件。
async fn run_daily_offload_cleanup(_app: &AppHandle) {
// 原逻辑:挂钟桶去重(同一天只跑一次),再调 cleanup_expired 删 90 天前文件。
// 用户决策改为全部保留,故摘除调用点,函数保留为空转。
//
// use std::sync::atomic::AtomicU64;
// static LAST_DAY: AtomicU64 = AtomicU64::new(0);
// let day = std::time::SystemTime::now()
// .duration_since(std::time::UNIX_EPOCH)
// .map(|d| d.as_secs() / 86400)
// .unwrap_or(0);
// if LAST_DAY.swap(day, Ordering::SeqCst) == day {
// return; // 今日已跑
// }
// let data_dir = _app.path().app_data_dir().unwrap_or_default();
// let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir);
// if removed > 0 {
// tracing::info!("归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节");
// }
// 归档清理已彻底移除(2026-10-11):90 天归档设计整体删除,外置文件永不清理,
// 详见 tool_result_offload.rs 头注释。
}
// ============================================================
@@ -1289,10 +1263,22 @@ async fn recover_one_parked(app: &AppHandle) -> Result<String, String> {
if !state.conv_states.can_accept_request(&conv_id) {
return Err(format!("会话 {} 正在生成中,无需恢复", conv_id));
}
// 恢复引导消息与 IPC 正典同源:按目标快照生成,快照全空回退常量。
// 引擎路径同步拿快照语义,防「引擎自动恢复按旧目标跑偏」。
let working_context = crate::commands::ai::commands::conversation::working_context_from_snapshot(
record.working_context_json.as_deref(),
);
let pinned = crate::commands::ai::commands::conversation::parse_pinned_goals(
record.pinned_goals.as_deref(),
);
let message = crate::commands::ai::commands::conversation::build_recovery_message(
&working_context,
&pinned,
);
spawn_agentic_session(
app,
&state,
crate::commands::ai::commands::conversation::PARKED_RECOVERY_MESSAGE.to_string(),
message,
None,
None,
None,
File diff suppressed because it is too large Load Diff
+3
View File
@@ -496,6 +496,8 @@ pub fn run() {
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
commands::ai::ai_inject_runtime,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context,
@@ -523,6 +525,7 @@ pub fn run() {
commands::ai::ai_conversation_peek,
// S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读)
commands::ai::ai_sub_agent_list,
commands::ai::context_briefs::ai_context_briefs,
commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
+46 -6
View File
@@ -98,6 +98,20 @@ impl MediaStore {
media_type: &str,
original_name: Option<&str>,
) -> Result<String, String> {
self.save_with_path(bytes, media_type, original_name)
.await
.map(|(url, _)| url)
}
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
pub async fn save_with_path(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<(String, std::path::PathBuf), String> {
let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir)
@@ -112,17 +126,25 @@ impl MediaStore {
return Err(format!("媒体写入失败: {e}"));
}
}
match &self.oss {
let url = match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)),
Ok(()) => public_url(cfg, &key),
Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path))
local_ref(&path)
}
},
None => Ok(local_ref(&path)),
None => local_ref(&path),
};
Ok((url, path))
}
}
/// 内容 sha256 全量 hex。落盘键与展示名回退共用同一 hash 口径(飞书文件入站无原始名
/// 时展示名取 `file_<hash 前 8>`,与落盘 hash 命名同源可对账)
pub fn content_hash(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(bytes))
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
@@ -130,8 +152,7 @@ impl MediaStore {
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _;
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
let digest = content_hash(bytes)[..16].to_string();
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name
@@ -280,6 +301,25 @@ mod tests {
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[tokio::test]
async fn save_with_path_returns_readable_local_path() {
// 文件消息入站契约:引用 url 之外另给本机路径(模型 read_file 只认本机路径),
// 通用文件落盘字节无损可读
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let (url, path) = store
.save_with_path(b"doc-bytes", "application/octet-stream", Some("合同.pdf"))
.await
.unwrap();
assert!(url.starts_with("file://"));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}_合同.pdf", &content_hash(b"doc-bytes")[..16])
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"doc-bytes");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test]
fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None);
+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(),
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "devflow",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "top.1216.devflow",
"build": {
"beforeDevCommand": "bun dev",
+12 -11
View File
@@ -31,16 +31,16 @@
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
<div class="df-toast-stack">
<div class="app-toast-stack">
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
<transition name="toast">
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
<div v-if="errorMsg" class="app-toast">⚠ {{ errorMsg }}</div>
</transition>
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
告知会话转入后台继续生成、托盘图标可随时唤起,复用 app-toast 形态 -->
<transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
<div v-if="trayNoticeMsg" class="app-toast app-toast--info">{{ trayNoticeMsg }}</div>
</transition>
</div>
@@ -114,7 +114,7 @@ watch(() => projectStore.error, (err) => {
})
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用
// 与错误 toast 同款 app-toast 形态,信息语义走 --info 变体底色,不与错误红混用
const trayNoticeMsg = ref('')
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
let _unlistenTrayHideNotice: (() => void) | null = null
@@ -353,20 +353,21 @@ onUnmounted(() => {
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */
.df-toast-stack {
子项 .app-toast 独立命名(2026-10-18 与 global.css .df-toast 顶部居中族撞名根治,
不再依赖定位覆盖对冲),主题保持根级强信号形态(实底白字)。 */
.app-toast-stack {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px;
z-index: 9999; max-width: 90vw; pointer-events: none;
}
.df-toast {
.app-toast {
background: var(--df-danger); color: #fff;
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; max-width: 90vw;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
box-shadow: var(--df-shadow-md);
}
.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
.df-toast--info { background: var(--df-accent); }
/* 后台运行提示:同 app-toast 形态,信息语义用 accent 底色与错误红区分(实底需白字) */
.app-toast--info { background: var(--df-accent); color: #fff; }
</style>
+9
View File
@@ -418,4 +418,13 @@ export const aiApi = {
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
return invoke('list_task_conversations', { taskId })
},
/**
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
* focusConv 标记来源为当前会话的分组(focused=true 供前端高亮);后端未合版本会忽略该参数。
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。
*/
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 })
},
}
+6 -1
View File
@@ -104,6 +104,8 @@ export interface ProjectRecord {
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
/** 归档标记(归档功能 P2,V48)。true=归档态,退出主列表仅归档箱可见;详情页据此区分「已归档」与「不存在」 */
archived?: boolean
created_at: string
updated_at: string
/**
@@ -537,6 +539,9 @@ export type AiChatEvent = ({
} | {
// 上下文已清空:前端刷新消息列表 + toast 提示
type: 'AiContextCleared'
} | {
// E2 压缩失败降级(关键词摘要兜底,loop 仍在跑):前端 toast 提示不中断流
type: 'AiContextDegraded'; reason: string
}) & {
conversation_id?: ConvId
}
@@ -569,7 +574,7 @@ export interface AiMessage {
* 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中
* 补充注入(961② 轮次分桶判据,归属当前轮不开新轮);'feishu'=飞书入站发起。
*/
source?: { channel: string; message_id: string; chat_id?: string; sender?: string } | null
source?: { channel: string; message_id?: string; chat_id?: string; sender?: string } | null
/**
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
+44 -254
View File
@@ -53,17 +53,9 @@
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
完成聚合语义由 MessageList 的执行轨迹卡承接。
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)。
C4b 拆分至 ai-chat/agentic-progress-bar.vue(纯展示,store 单例直读,零行为变更) -->
<AgenticProgressBar />
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
@@ -75,46 +67,10 @@
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
</div>
</div>
<div class="ai-queue-list">
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
<template v-if="editingQueueIdx === idx">
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<input
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
<!-- 编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) -->
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
</template>
</div>
</div>
</div>
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)。
C4b 拆分至 ai-chat/queue-panel.vue(L2 发送韧性 + V7 排队时钟 + 队列编辑/回填随迁,
confirm 自持同 ChatInput 模式;hasUnsentInput/fillDraft 经 props/emits 接 ChatInput ref) -->
<QueuePanel :has-unsent-input="hasUnsentInput" @fill-draft="onQueueFillDraft" />
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
<ChatInput
ref="chatInputRef"
@@ -132,28 +88,14 @@
:danger-label="confirmState.dangerLabel"
@result="answerConfirm"
/>
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
<Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
<!-- 错误 toast(零依赖 useToast;分离窗口无 App.vue 根 toast,故本组件自管;样式消费全局 .df-toast 族,error 映射 danger) -->
<Transition name="df-toast">
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
</Transition>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
<Teleport to="body">
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
<!-- I12(2026-10-05):键盘快捷键速查。C4b 拆分至 ai-chat/shortcuts-overlay.vue
(Teleport 到 body 防 .ai-panel overflow 裁剪;主窗/分离窗各自实例独立渲染,
入口在 TopBar ... 菜单 emit show-shortcuts;visible 状态留父供 Esc 仲裁) -->
<ShortcutsOverlay :visible="shortcutsVisible" @close="shortcutsVisible = false" />
</div>
</template>
@@ -176,10 +118,13 @@ import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import { useAiContext } from '../composables/ai/useAiContext'
import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow'
import type { AiMessage } from '../api/types'
import type { AiMessage, SkillInfo } from '../api/types'
// C4b 拆分(第五批): Agentic 进度条/待发送队列/快捷键速查 三自有区块抽至 ai-chat/ 子目录
import AgenticProgressBar from './ai-chat/agentic-progress-bar.vue'
import QueuePanel from './ai-chat/queue-panel.vue'
import ShortcutsOverlay from './ai-chat/shortcuts-overlay.vue'
const props = defineProps<{
detached?: boolean
@@ -262,22 +207,7 @@ const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
// 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
// Agentic 循环进度条
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
const total = collectTurnStats(turnMessages(store.state.messages)).total
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
})
// C4b: Agentic 循环进度条(显示条件+文案 computed)已随 ai-chat/agentic-progress-bar.vue 迁出
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
function scrollToFirstPending(): void {
@@ -337,10 +267,7 @@ const projectsStore = useProjectStore()
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,随消息编辑态一并重置。
if (editingQueueIdx.value !== null) cancelEditQueued()
// UX-P3: 队列编辑态重置已随 QueuePanel 迁出(子组件内自 watch activeConversationId)。
})
@@ -485,135 +412,23 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
// ── L2 发送韧性:排队超时检测 + 强制发送 ──
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
const activeQueue = computed(() =>
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
)
// (activeQueue 过滤视图已随 queue-panel.vue 子组件迁移,父侧死副本删除)
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
const nowTick = ref(Date.now())
let queueTickTimer: ReturnType<typeof setInterval> | null = null
// 队列超时横幅/强制发送/清空/等待时钟(V7 nowTick)已随 queue-panel.vue 子组件迁移
// (2026-10-18 UI 重构批,子组件自带 interval 与清理),父侧死副本删除(vue-tsc TS6133;
// 旧父组件时钟在无消费方后仍每秒空转,一并消除)。
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
const queueTimedOut = computed(() => {
void nowTick.value
return store.isQueueTimedOut(store.state.activeConversationId)
})
watch(
() => activeQueue.value.length > 0,
(waiting) => {
if (waiting && queueTickTimer === null) {
nowTick.value = Date.now()
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
} else if (!waiting && queueTickTimer !== null) {
clearInterval(queueTickTimer)
queueTickTimer = null
}
},
{ immediate: true },
)
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
function formatWaitTime(totalSeconds: number): string {
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
}
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
function queueItemWait(item: { enqueuedAt: number }): string {
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
}
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
async function handleForceSend() {
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
}
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
* 一键误触会静默丢掉全部排队消息且不可恢复,复用本组件 ConfirmDialog 先例
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
async function handleClearQueue() {
const convId = store.state.activeConversationId
if (!convId) return
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
if (confirmed) store.clearQueue(convId)
/** QueuePanel emit fill-draft 的父侧转发:写入 ChatInput 草稿(拆分纪律:ChatInput ref 留父) */
function onQueueFillDraft(text: string, skill: SkillInfo | null) {
chatInputRef.value?.fillDraft(text, skill)
}
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */
const editingQueueIdx = ref<number | null>(null)
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
function startEditQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
* 收敛到本函数统一判。 */
function onQueueEditKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
saveEditQueued()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEditQueued()
}
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (chatInputRef.value?.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
}
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
store.cancelQueued(idx)
chatInputRef.value?.fillDraft(item.text, skill)
}
// 队列编辑态(编辑中 index/文本/进取消存取)已随 queue-panel.vue 子组件迁移(2026-10-18
// UI 重构批),父组件死副本删除(vue-tsc TS6133);IME 守卫等行为在子组件内同名实现。
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
@@ -627,8 +442,7 @@ onBeforeUnmount(() => {
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
_listenerRelease?.()
_listenerRelease = null
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
// V7 排队时钟已随 queue-panel.vue 迁移,其 interval 由子组件 onBeforeUnmount 自清。
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
window.removeEventListener('keydown', onGlobalKeydown)
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
@@ -786,16 +600,7 @@ async function toggleAlwaysOnTop() {
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
const shortcutsVisible = ref(false)
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
// 键位表已随 shortcuts-overlay.vue 子组件迁移(C4b),父侧死副本删除(vue-tsc TS6133)。
function showShortcuts() {
shortcutsVisible.value = true
}
@@ -893,6 +698,10 @@ async function initContextEventListeners(): Promise<void> {
onError: (_convId, message) => {
showToast(t('aiChat.compressError', { msg: message }), 'error')
},
// E2 压缩失败降级:仅提示不刷新不打断流(loop 仍在跑,实证静默降级致用户原句重发)
onContextDegraded: (_convId, reason) => {
showToast(t('aiChat.contextDegraded', { reason }), 'warning')
},
})
}
</script>
@@ -902,7 +711,7 @@ async function initContextEventListeners(): Promise<void> {
复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area
容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */
.ai-panel {
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */
position: relative; /* toast 已改全局 .df-toast(fixed 定位);relative 保留兜底其他绝对定位子元素 */
display: flex;
flex-direction: row;
height: 100%;
@@ -924,7 +733,7 @@ async function initContextEventListeners(): Promise<void> {
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */
.ai-auth-bypass-banner {
padding: 5px 14px;
background: var(--df-warning-bg);
@@ -1054,27 +863,8 @@ async function initContextEventListeners(): Promise<void> {
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
.ai-toast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* ToastType 新增 'success' 兜底样式 */
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ═══ 错误 toast:消费 global.css .df-toast 四色族 + .df-toast-enter/leave 过渡
(2026-10-18 整洁波双轨合一,原 .ai-toast-* 本地族删除;error 态映射 --danger) ═══ */
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
@@ -1091,7 +881,7 @@ async function initContextEventListeners(): Promise<void> {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
box-shadow: var(--df-shadow-sm);
width: 320px;
max-width: calc(100vw - 48px);
padding: 14px 16px;
@@ -1116,7 +906,7 @@ async function initContextEventListeners(): Promise<void> {
padding: 2px 4px;
border-radius: 4px;
}
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
.ai-shortcuts-list {
list-style: none;
margin: 0;
@@ -1132,9 +922,9 @@ async function initContextEventListeners(): Promise<void> {
font-size: 12px;
}
.ai-shortcuts-key {
font-family: var(--df-mono, monospace);
font-family: var(--df-font-mono);
font-size: 11px;
background: var(--df-bg-secondary);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: 4px;
padding: 2px 8px;
+72 -50
View File
@@ -21,9 +21,9 @@
<span v-html="toolIcon(tc.name)"></span>
</span>
<div class="ai-tool-header-text">
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span class="ai-tool-name df-mono">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.status === 'running'" class="ai-tool-sub df-badge df-badge--info ai-tool-chip-run">{{ $t('aiTool.running') }}</span>
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed df-badge df-badge--danger" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
</div>
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
@@ -31,7 +31,7 @@
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
<span
v-if="durationText"
class="ai-tool-duration"
class="ai-tool-duration df-mono"
:title="durationTitle"
>{{ durationText }}</span>
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
@@ -56,8 +56,9 @@
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系) -->
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge" :class="'is-' + riskLevel">
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系)
wave2:迁 .df-badge 透明彩底三件套(danger/warning 变体) -->
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge df-badge" :class="riskLevel === 'high' ? 'df-badge--danger' : 'df-badge--warning'">
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
</div>
<div v-if="visibleArgs.length" class="ai-tool-approval-args">
@@ -81,7 +82,7 @@
</div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
<div v-if="tc.diff" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div>
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
- kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir
@@ -415,7 +416,7 @@ const visibleArgs = computed(() => {
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
}
.ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
.ai-tool-card--pending_approval { border-color: var(--df-warning-bd); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
/* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
@@ -424,13 +425,17 @@ const visibleArgs = computed(() => {
.ai-tool-sub--failed { color: var(--df-danger); }
.ai-tool-rejected {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger);
padding: 4px 10px 8px var(--df-icon-gutter); font-size: 11px; color: var(--df-danger);
}
.ai-tool-rejected-icon { font-weight: 500; }
/* wave2:折叠轨迹卡的展开态左边 2px 强调边(对齐 workbench cv.on 范式);
只作用于可折叠卡(completed),pending 的 data-risk 左缘与 running 强制展开态不参与 */
.ai-tool-card--completed:not(.ai-tool-card--collapsed) { border-left: 2px solid var(--df-info); }
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
.ai-tool-card--collapsed .ai-tool-header:hover { background: rgba(255,255,255,0.03); }
.ai-tool-card--collapsed .ai-tool-header:hover { background: var(--df-bg-card-hover); }
.ai-tool-body { overflow: hidden; }
.ai-tool-collapse-hint {
font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0;
@@ -444,9 +449,9 @@ const visibleArgs = computed(() => {
width: 24px; height: 24px; border-radius: var(--df-radius-sm);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); }
.ai-tool-icon-wrap--file-read { background: var(--df-info-bg); color: var(--df-info); }
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); }
.ai-tool-icon-wrap--dir { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
@@ -455,14 +460,40 @@ const visibleArgs = computed(() => {
display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline;
column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0;
}
/* wave2:工具名迁 mono 元数据(df-mono 接管字体,此处 10px dim 收敛视觉) */
.ai-tool-name {
font-size: 12px; font-weight: 500; color: var(--df-text);
font-size: 10px; font-weight: 500; color: var(--df-text-dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
}
.ai-tool-sub {
font-size: 10px; color: var(--df-text-dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* wave2:状态 chip 迁 .df-badge(running=info+脉冲点 / failed=danger);
scoped 双类压过 .ai-tool-sub 灰字,色板与 df-badge 变体对齐(token 化) */
.ai-tool-sub.df-badge {
font-size: 10px;
padding: 1px 6px;
line-height: 16px;
}
.ai-tool-sub.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-tool-sub.df-badge--danger {
background: var(--df-danger-bg);
color: var(--df-danger);
border-color: color-mix(in srgb, var(--df-danger) 30%, transparent);
}
/* running chip 内嵌脉冲点(全局 df-pulse 节奏,对齐 df-dot--run 活性语义) */
.ai-tool-chip-run::before {
content: '';
width: 5px; height: 5px; border-radius: 50%;
background: var(--df-info);
flex-shrink: 0;
animation: df-pulse 1.5s infinite;
}
/* -- Status dot (脉冲动画) -- */
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
@@ -487,16 +518,16 @@ const visibleArgs = computed(() => {
.ai-tool-status-dot--rejected { background: var(--df-danger); }
/* -- Skeleton (running 状态) -- */
.ai-tool-skeleton { padding: 4px 10px 10px 42px; display: flex; flex-direction: column; gap: 5px; }
.ai-tool-skeleton { padding: 4px 10px 10px var(--df-icon-gutter); display: flex; flex-direction: column; gap: 5px; }
.ai-tool-skeleton-line {
height: 8px; border-radius: var(--df-radius-xs);
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite;
background-size: 200% 100%; animation: df-shimmer 1.5s infinite;
}
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
.ai-tool-stream {
padding: 4px 10px 10px 42px;
padding: 4px 10px 10px var(--df-icon-gutter);
display: flex; flex-direction: column; gap: 2px;
max-height: 160px; overflow-y: auto;
}
@@ -507,53 +538,47 @@ const visibleArgs = computed(() => {
white-space: pre-wrap; word-break: break-all;
}
.ai-tool-stream-more {
font-size: 10px; color: var(--df-text-tertiary);
font-size: 10px; color: var(--df-text-dim);
padding-top: 2px; font-style: italic;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* -- 审批参数展示 -- */
.ai-tool-approval-args {
padding: 4px 10px 6px; margin-bottom: 6px;
border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03));
background: var(--df-bg);
}
.ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; }
.ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); }
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text-primary); }
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text); }
.ai-tool-approval-reason {
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
color: var(--df-warning, #d97706); background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
border-radius: var(--df-radius-sm, 4px);
color: var(--df-warning); background: var(--df-warning-bg);
border-radius: var(--df-radius-sm);
}
/* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
.ai-tool-wait-time {
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
font-family: var(--df-font-mono);
color: var(--df-text-dim, #888);
border-radius: var(--df-radius-sm, 4px);
color: var(--df-text-dim);
border-radius: var(--df-radius-sm);
}
.ai-tool-wait-time.is-long {
color: var(--df-warning, #d97706);
color: var(--df-warning);
animation: pulseGlow 1.5s ease-in-out infinite;
}
.ai-tool-wait-time.is-urgent {
color: var(--df-danger, #e5484d);
color: var(--df-danger);
animation: pulseGlow 1s ease-in-out infinite;
}
/* write_file 审批 diff 预览区(红删绿增行级) */
/* write_file 审批 diff 预览区(红删绿增行级);
基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义,此处仅留行配色变体 */
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
.ai-tool-diff-pre {
margin: 0; padding: 6px 0; max-height: 240px; overflow: auto;
font-family: var(--df-font-mono); font-size: 11px; line-height: 1.5; background: var(--df-bg-card);
}
.ai-tool-diff-pre code { display: block; white-space: pre; }
.ai-tool-diff-line { display: block; padding: 0 10px; white-space: pre-wrap; word-break: break-all; }
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
.df-diff-line--ctx { color: var(--df-text-dim); }
/* -- 审批按钮 -- */
.ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; }
@@ -566,11 +591,11 @@ const visibleArgs = computed(() => {
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */
.ai-tool-btn--session {
background: var(--df-accent-bg, rgba(123, 111, 240, 0.12));
color: var(--df-accent, #7b6ff0);
border-color: color-mix(in srgb, var(--df-accent, #7b6ff0) 40%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
}
.ai-tool-btn--session:hover { background: var(--df-accent, #7b6ff0); color: #fff; border-color: var(--df-accent, #7b6ff0); }
.ai-tool-btn--session:hover { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
@@ -587,15 +612,14 @@ const visibleArgs = computed(() => {
.ai-tool-btn-icon :deep(svg) { display: block; }
.ai-tool-btn-spinner {
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite;
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: df-spin 0.7s linear infinite;
}
@keyframes aiToolBtnSpin { to { transform: rotate(360deg); } }
/* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast {
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
box-shadow: var(--df-shadow-sm); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
}
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
@@ -605,15 +629,13 @@ const visibleArgs = computed(() => {
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
/* -- 风险徽章(审批区首行,High 橙红底/Medium 黄底,10px 大写字距) -- */
/* -- 风险徽章(审批区首行):wave2 迁 .df-badge 透明彩底(danger/warning 变体接管配色),
此处只留布局(独立成行+外边距) -- */
.ai-tool-risk-badge {
display: inline-flex; align-items: center; align-self: flex-start;
padding: 2px 8px; margin: 6px 10px 0;
font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
border-radius: var(--df-radius-sm);
align-self: flex-start;
margin: 6px 10px 0;
font-weight: 600; letter-spacing: 0.5px;
}
.ai-tool-risk-badge.is-high { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent); }
.ai-tool-risk-badge.is-medium { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); }
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
.ai-tool-approval { display: flex; flex-direction: column; }
+9 -18
View File
@@ -13,8 +13,8 @@
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
{{ $t('aiTool.rejectAll') }}
</button>
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果 -->
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint">
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果(wave2 迁 .df-badge danger) -->
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint df-badge df-badge--danger">
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
</span>
</div>
@@ -26,8 +26,8 @@
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(红字计数) -->
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(wave2 迁 .df-badge danger) -->
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail df-badge df-badge--danger">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
</div>
<ToolCard
@@ -451,23 +451,17 @@ function groupIcon(name: string): string {
gap: 8px;
}
/* -- 批量操作栏 -- */
/* -- 批量操作栏(wave2:白透明底残留改 token,浅色主题成立) -- */
.ai-batch-approve {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.02);
background: var(--df-bg-card);
}
/* 批量批准将二次确认的 High 风险项数徽标(橙红警示,不抢按钮焦点) */
/* 批量批准将二次确认的 High 风险项数徽标:配色由 df-badge--danger 接管,此处只留布局 */
.ai-batch-high-risk-hint {
font-size: 10px;
color: var(--df-danger);
background: var(--df-danger-bg);
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
white-space: nowrap;
}
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
@@ -512,16 +506,13 @@ function groupIcon(name: string): string {
color: var(--df-text-secondary);
}
.ai-tool-group-header:hover {
background: rgba(255,255,255,0.03);
background: var(--df-bg-card-hover);
}
/* R7 组头失败徽标:失败计数红字,margin-left auto 推到 chevron 之前右对齐 */
/* R7 组头失败徽标:配色由 df-badge--danger 接管,此处只留布局(右对齐推到 chevron 前) */
.ai-tool-group-fail {
margin-left: auto;
margin-right: 2px;
font-size: 10px;
font-weight: 600;
color: var(--df-danger);
opacity: 0.9;
white-space: nowrap;
}
.ai-tool-group-icon {
+29 -47
View File
@@ -120,12 +120,12 @@
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
</div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 df-diff-pre/--add/--del/--ctx 样式)
后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff -->
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar">
@@ -136,7 +136,7 @@
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
@@ -468,7 +468,7 @@ watch(() => props.tc.status, (s) => {
}
.ai-tool-grep-match {
padding: 3px 12px;
border-bottom: 0.5px solid rgba(255,255,255,0.03);
border-bottom: 0.5px solid var(--df-hover);
}
.ai-tool-grep-match:last-child {
border-bottom: none;
@@ -511,7 +511,7 @@ watch(() => props.tc.status, (s) => {
word-break: break-all;
background: rgba(61,219,160,0.05);
padding: 2px 6px;
border-radius: var(--df-radius-xs, 3px);
border-radius: var(--df-radius-xs);
}
.ai-tool-grep-context {
margin: 2px 0 0;
@@ -521,8 +521,8 @@ watch(() => props.tc.status, (s) => {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.02);
border-radius: var(--df-radius-xs, 3px);
background: var(--df-hover);
border-radius: var(--df-radius-xs);
}
.ai-tool-grep-context code {
display: block;
@@ -533,7 +533,7 @@ watch(() => props.tc.status, (s) => {
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
background: var(--df-hover);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
@@ -586,13 +586,13 @@ watch(() => props.tc.status, (s) => {
.ai-tool-dir-entry {
display: flex;
align-items: center;
gap: 7px;
gap: 6px;
padding: 3px 12px;
font-size: 11px;
transition: background 0.1s;
}
.ai-tool-dir-entry:hover {
background: rgba(255,255,255,0.03);
background: var(--df-hover);
}
.ai-tool-dir-entry--directory .ai-tool-dir-name {
font-weight: 500;
@@ -650,8 +650,8 @@ watch(() => props.tc.status, (s) => {
padding: 4px 10px 6px;
margin-bottom: 6px;
font-size: 11px;
color: var(--df-warning, #d97706);
background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
color: var(--df-warning);
background: var(--df-warning-bg);
border-radius: var(--df-radius-sm, 4px);
}
.ai-tool-approval-diff {
@@ -660,29 +660,11 @@ watch(() => props.tc.status, (s) => {
border-radius: var(--df-radius-sm, 4px);
overflow: hidden;
}
.ai-tool-diff-pre {
margin: 0;
padding: 6px 0;
max-height: 240px;
overflow: auto;
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.5;
background: var(--df-bg-card);
}
.ai-tool-diff-pre code {
display: block;
white-space: pre;
}
.ai-tool-diff-line {
display: block;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-all;
}
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
/* diff 基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义(2026-10-18 整洁波
双轨合一,与 ToolCard.vue 审批 diff 同源),此处仅留行配色变体 */
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
.df-diff-line--ctx { color: var(--df-text-dim); }
/* -- Generic Result --
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
@@ -741,12 +723,12 @@ watch(() => props.tc.status, (s) => {
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
font-size: 10.5px;
font-size: 10px;
cursor: pointer;
}
.ai-tool-cmd-toggle:hover {
color: var(--df-text);
background: var(--df-bg-hover, rgba(255,255,255,0.06));
background: var(--df-hover);
}
.ai-tool-cmd-output {
margin: 0;
@@ -803,8 +785,8 @@ watch(() => props.tc.status, (s) => {
}
.ai-tool-http-trunc-warn {
padding: 4px 10px;
font-size: 10.5px;
color: var(--df-warning, #f0c75e);
font-size: 10px;
color: var(--df-warning);
border-top: 0.5px solid var(--df-border);
}
@@ -828,10 +810,10 @@ watch(() => props.tc.status, (s) => {
}
.ai-tool-fetch-mode--static,
.ai-tool-fetch-mode--obscura {
color: var(--df-success, #3ddb9c);
color: var(--df-success);
}
.ai-tool-fetch-mode--static_fallback {
color: var(--df-warning, #f0c75e);
color: var(--df-warning);
}
.ai-tool-fetch-title {
padding: 4px 10px 2px;
@@ -845,8 +827,8 @@ watch(() => props.tc.status, (s) => {
.ai-tool-obscura-hint {
margin: 4px 10px 6px;
padding: 6px 8px;
background: var(--df-warning-bg, rgba(240,199,94,0.08));
border: 0.5px solid var(--df-warning, #f0c75e);
background: var(--df-warning-bg);
border: 0.5px solid var(--df-warning);
border-radius: var(--df-radius-sm, 4px);
display: flex;
flex-direction: column;
@@ -854,8 +836,8 @@ watch(() => props.tc.status, (s) => {
}
.ai-tool-obscura-hint-text {
font-family: var(--df-font-sans);
font-size: 10.5px;
color: var(--df-warning, #f0c75e);
font-size: 10px;
color: var(--df-warning);
line-height: 1.5;
word-break: break-word;
}
@@ -870,7 +852,7 @@ watch(() => props.tc.status, (s) => {
font-weight: 500;
border-radius: var(--df-radius-sm, 4px);
border: none;
background: var(--df-accent, #5b8def);
background: var(--df-accent);
color: #fff;
cursor: pointer;
font-family: var(--df-font-sans);
@@ -891,5 +873,5 @@ watch(() => props.tc.status, (s) => {
padding: 3px 12px;
cursor: pointer;
}
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); }
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); }
</style>
@@ -0,0 +1,72 @@
<template>
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
完成聚合语义由 MessageList 的执行轨迹卡承接。
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。
C4b 拆分自 AiChat.vue(纯展示,store 单例直读,零行为变更)。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { collectTurnStats, turnMessages } from '../../composables/ai/useTurnTrace'
const store = useAiStore()
const { t } = useI18n()
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
const total = collectTurnStats(turnMessages(store.state.messages)).total
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
})
</script>
<style scoped>
/* ═══ Agentic 循环进度条(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
.ai-agentic-progress {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
user-select: none;
}
.ai-agentic-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
}
.ai-agentic-progress-text {
font-family: var(--df-font-mono);
white-space: nowrap;
}
@keyframes ai-agentic-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
+288
View File
@@ -0,0 +1,288 @@
<template>
<!-- ═══ 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)
C4b 拆分自 AiChat.vue 队列区(模板 + L2 发送韧性 + V7 排队时钟 + 队列编辑/回填,零行为变更;
store 单例共享,confirm 自持一份 useConfirm + ConfirmDialog——同 ChatInput/ConversationSidebar
模式:useConfirm 非单例,子组件发起的 confirm 须本组件渲染) ═══ -->
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
</div>
</div>
<div class="ai-queue-list">
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
<template v-if="editingQueueIdx === idx">
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<input
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
<!-- 编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) -->
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
</template>
</div>
</div>
</div>
<!-- 确认弹层(强制发送/清空/回填覆盖二次确认;同 ChatInput 自持模式) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="confirmState.dangerLabel"
@result="answerConfirm"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useConfirm } from '../../composables/useConfirm'
import type { SkillInfo } from '../../api/types'
// 回填链路跨组件:输入框 ref(chatInputRef)在父,本组件经 props 读草稿态(hasUnsentInput)、
// 经 emit 写草稿(fill-draft),由父转发 ChatInput——共享函数留主文件 props/emits 传(拆分纪律)。
const props = defineProps<{
/** 父侧 ChatInput.hasUnsentInput 转发(回填覆盖未发送草稿前的 confirm 守卫) */
hasUnsentInput: () => boolean
}>()
const emit = defineEmits<{
/** 队列项回填:父转发 ChatInput.fillDraft(text, skill) 写入输入框草稿 */
(e: 'fill-draft', text: string, skill: SkillInfo | null): void
}>()
const store = useAiStore()
const { t } = useI18n()
// ── 确认弹层(useConfirm 非单例,子组件自持一份 + 自渲染 ConfirmDialog,同 ChatInput 先例) ──
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
const activeQueue = computed(() =>
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
)
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
const nowTick = ref(Date.now())
let queueTickTimer: ReturnType<typeof setInterval> | null = null
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
const queueTimedOut = computed(() => {
void nowTick.value
return store.isQueueTimedOut(store.state.activeConversationId)
})
watch(
() => activeQueue.value.length > 0,
(waiting) => {
if (waiting && queueTickTimer === null) {
nowTick.value = Date.now()
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
} else if (!waiting && queueTickTimer !== null) {
clearInterval(queueTickTimer)
queueTickTimer = null
}
},
{ immediate: true },
)
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
function formatWaitTime(totalSeconds: number): string {
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
}
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
function queueItemWait(item: { enqueuedAt: number }): string {
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
}
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
async function handleForceSend() {
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
}
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
* 一键误触会静默丢掉全部排队消息且不可恢复,复用 ConfirmDialog 先例
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
async function handleClearQueue() {
const convId = store.state.activeConversationId
if (!convId) return
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
if (confirmed) store.clearQueue(convId)
}
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */
const editingQueueIdx = ref<number | null>(null)
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,切会话即重置(原随父 watch activeConversationId,拆分后本组件自管)。
watch(() => store.state.activeConversationId, () => {
if (editingQueueIdx.value !== null) cancelEditQueued()
})
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
function startEditQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
* 收敛到本函数统一判。 */
function onQueueEditKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
saveEditQueued()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEditQueued()
}
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (props.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
}
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
store.cancelQueued(idx)
emit('fill-draft', item.text, skill)
}
onBeforeUnmount(() => {
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
})
</script>
<style scoped>
/* ═══ 待发送队列(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
.ai-queue {
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
}
.ai-queue--timeout {
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
}
.ai-queue-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
.ai-queue-force {
background: var(--df-warning); border: none; color: #000; cursor: pointer;
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: calc(var(--df-radius) - 2px);
}
.ai-queue-force:hover { filter: brightness(1.1); }
.ai-queue-clear { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 11px; padding: 0; }
.ai-queue-clear:hover { color: var(--df-text); }
.ai-queue-list { display: flex; flex-direction: column; gap: 3px; }
.ai-queue-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--df-bg-card);
border-radius: calc(var(--df-radius) - 2px);
font-size: 12px;
}
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
/* V7 排队时钟:每项已等待时长 */
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
.ai-queue-text {
color: var(--df-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
.ai-queue-x:hover { color: var(--df-danger); }
/* 队列项编辑/立即发送按钮 + 编辑态 input */
.ai-queue-btn {
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
}
.ai-queue-btn:hover { color: var(--df-accent); }
.ai-queue-edit-input {
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
background: var(--df-bg-card); color: var(--df-text);
border: 0.5px solid var(--df-accent);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
</style>
@@ -0,0 +1,117 @@
<template>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts)。
C4b 拆分自 AiChat.vue(visible 状态留父——onGlobalKeydown Esc 仲裁需读写,开/关经 props/emits) -->
<Teleport to="body">
<div v-if="visible" class="ai-shortcuts-mask" @click.self="emit('close')">
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="emit('close')">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
/** 面板显隐(状态留父:TopBar emit show-shortcuts 置 true,onGlobalKeydown Esc 分支仲裁) */
visible: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
// 键位表 shortcuts 与父 AiChat onGlobalKeydown 同族——键区调整时两边同步,防速查与真实键位漂移。
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略)。
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
</script>
<style scoped>
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关;样式随区块自 AiChat.vue 迁入) ═══ */
.ai-shortcuts-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.ai-shortcuts-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
box-shadow: var(--df-shadow-sm);
width: 320px;
max-width: calc(100vw - 48px);
padding: 14px 16px;
}
.ai-shortcuts-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ai-shortcuts-title {
font-size: 13px;
font-weight: 600;
color: var(--df-text);
}
.ai-shortcuts-close {
background: transparent;
border: none;
color: var(--df-text-secondary);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
}
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
.ai-shortcuts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.ai-shortcuts-key {
font-family: var(--df-font-mono);
font-size: 11px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: 4px;
padding: 2px 8px;
color: var(--df-text);
}
.ai-shortcuts-act { color: var(--df-text-secondary); }
</style>
+18 -16
View File
@@ -108,7 +108,9 @@ function onDismiss() {
</script>
<style scoped>
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态
wave2(2026-10-18):danger 系透明彩底范式(待审批=需用户处置的阻断信号)——
弹层底 bg-card+边框+10px 圆角,边框 color-mix 30%+左缘 3px 强调,标题同色 12px/600 */
.approval-overlay {
position: fixed;
top: 16px;
@@ -121,10 +123,10 @@ function onDismiss() {
min-width: 260px;
max-width: 360px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-accent);
border-left: 3px solid var(--df-accent);
border-radius: var(--df-radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft);
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-left: 3px solid var(--df-danger);
border-radius: var(--df-radius-lg);
box-shadow: var(--df-shadow-md), 0 0 0 1px color-mix(in srgb, var(--df-danger) 12%, transparent);
cursor: pointer;
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
user-select: none;
@@ -132,8 +134,8 @@ function onDismiss() {
.approval-overlay:hover {
transform: translateX(-2px);
border-color: var(--df-accent);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
border-color: color-mix(in srgb, var(--df-danger) 55%, transparent);
box-shadow: var(--df-shadow-md), 0 0 0 2px color-mix(in srgb, var(--df-danger) 45%, transparent);
}
.approval-overlay-icon {
@@ -144,8 +146,8 @@ function onDismiss() {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--df-accent-bg);
color: var(--df-accent);
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
color: var(--df-danger);
flex-shrink: 0;
}
@@ -172,15 +174,15 @@ function onDismiss() {
}
.approval-overlay-title {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: var(--df-text);
color: var(--df-danger);
margin-bottom: 2px;
}
.approval-overlay-desc {
font-size: 11px;
color: var(--df-text-dim);
font-size: 12px;
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -190,8 +192,8 @@ function onDismiss() {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: var(--df-h-icon-btn);
height: var(--df-h-icon-btn);
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
@@ -202,7 +204,7 @@ function onDismiss() {
}
.approval-overlay-close:hover {
background: var(--df-sidebar-hover);
background: var(--df-bg-card-hover);
color: var(--df-text);
}
+1 -1
View File
@@ -85,7 +85,7 @@ onMounted(async () => {
border-radius: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--df-bg-secondary);
background: var(--df-bg-card);
font-size: 12px;
}
.ci-header {
+169 -32
View File
@@ -45,6 +45,9 @@
@select="selectMention"
@update:mention-index="mentionIndex = $event"
/>
<!-- 2026-10-18 wave2: 工具行(workbench .inp-row 范式)——textarea 下方分隔线,
左侧功能钮 + 右侧发送/停止钮;纯布局重排,按钮类名/事件/禁用判定零变化 -->
<div class="ai-input-row">
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
<button
type="button"
@@ -115,12 +118,14 @@
class="ai-send-btn"
:class="{ 'ai-send-btn--active': canSend }"
:disabled="!canSend"
:aria-label="$t('aiChat.queueSendNow')"
:title="$t('aiChat.send')"
:aria-label="$t('aiChat.send')"
@click="handleSend"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
<EnrichmentPanel
:has-enrichment="hasEnrichment"
@@ -132,6 +137,12 @@
/>
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
<!-- F2-4(走查-信息UX 面二):示例卡覆盖未发送草稿的二次确认弹层(组件自持,见 setup 注释) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
@result="answerConfirm"
/>
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
</div>
</template>
@@ -145,7 +156,7 @@
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
import { ref, computed, nextTick, watch } from 'vue'
import { ref, computed, nextTick, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
@@ -153,7 +164,7 @@ import { useAppSettingsStore } from '../../stores/appSettings'
import { getConvState } from '../../composables/ai/useAiEvents'
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
import { textIdle } from '../../composables/ai/useAiPendingState'
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeConvDraftAppend, type SendErrorKind } from '../../composables/ai/aiShared'
import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog'
@@ -162,6 +173,8 @@ import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue'
import EnrichmentPanel from './EnrichmentPanel.vue'
import OutboxDrawer from './OutboxDrawer.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useConfirm } from '../../composables/useConfirm'
import { taskApi } from '../../api/task'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
import type { MentionItem } from './MentionItem'
@@ -1078,6 +1091,8 @@ async function handleSend() {
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
inputText.value = ''
// F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿)
persistDraftNow()
pendingSkill.value = null
pendingImages.value = []
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
@@ -1113,12 +1128,20 @@ async function handleSend() {
emit('sent')
}
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致)。
* F2-4(走查-信息UX 面二):输入框已有未发送草稿(文本/图片/技能 chip 任一)时先确认,
* 取消则原样保留草稿——对齐父级 backfillQueued 的覆盖确认语义,不再静默替换。 */
async function sendExamplePrompt(i18nKey: string) {
if (hasUnsentInput() && !(await confirmDialog(t('aiChat.exampleOverwriteConfirm')))) return
inputText.value = t(i18nKey)
await handleSend()
}
// ── F2-4:覆盖确认弹层(仓风格不引 window.confirm;AiChat 的 confirmDialog 状态机在父级
// 经模板绑定,子组件复用需父接线 → 本组件自持一份 useConfirm + ConfirmDialog,
// 行为/样式与父级 backfillQueued / clearQueue 确认同范式) ──
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
@@ -1140,7 +1163,9 @@ function startEditFocus(msg: AiMessage): void {
const len = el.value.length
el.setSelectionRange(len, len)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
// F2-3(走查-信息UX):编辑态回填高度复用 INPUT_MAX_HEIGHT,与 autoResize 同源;
// 原硬编码 120 使长草稿进编辑态首屏被压矮,任一按键才弹回 200
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}
@@ -1155,6 +1180,8 @@ function clearInput(): void {
mentionOpen.value = false
mentionStart.value = -1
if (inputEl.value) inputEl.value.style.height = 'auto'
// F2-2:清空即落盘(取消编辑不应残留旧稿到下次重启)
persistDraftNow()
}
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
@@ -1184,12 +1211,78 @@ function focus(): void {
}
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
// 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像:
// 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
// F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条,
// 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为,
// 解析失败/配额满不阻断输入主链。
const DRAFTS_KEY = 'df-ai-conv-drafts'
const DRAFTS_MAX = 20
function readDraftsStore(): Record<string, string> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(DRAFTS_KEY) || '{}')
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {}
} catch {
return {}
}
}
function persistDraft(convId: string, text: string): void {
const all = readDraftsStore()
if (text) {
// 删后重插把该会话挪到键序末尾,Object 键序=写入序,溢出时从头淘汰即 LRU
delete all[convId]
all[convId] = text
const keys = Object.keys(all)
for (let i = 0; i < keys.length - DRAFTS_MAX; i++) delete all[keys[i]]
} else {
delete all[convId]
}
try {
localStorage.setItem(DRAFTS_KEY, JSON.stringify(all))
} catch {
// 配额满等:放弃本次落盘,内存草稿不受影响
}
}
// 防抖落盘:逐键写盘不必要,300ms 静默期合并;发送/清空/切会话/卸载走立即落盘收口
let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
function persistDraftNow(): void {
if (draftPersistTimer) {
clearTimeout(draftPersistTimer)
draftPersistTimer = null
}
const convId = store.state.activeConversationId
if (convId) persistDraft(convId, inputText.value)
}
watch(inputText, () => {
if (draftPersistTimer) clearTimeout(draftPersistTimer)
draftPersistTimer = setTimeout(persistDraftNow, 300)
})
// F2-2:卸载(面板收起/关窗)前冲刷在途防抖,防最后 300ms 键入的草稿丢失
onBeforeUnmount(() => {
if (draftPersistTimer) persistDraftNow()
})
watch(() => store.state.activeConversationId, (nv, ov) => {
if (ov) setConvDraft(ov, inputText.value)
// immediate:挂载时活跃会话已就位(分离窗/恢复会话直开)也走恢复链,不只在切换时
if (ov) {
setConvDraft(ov, inputText.value)
persistDraft(ov, inputText.value)
}
inputText.value = getConvDraft(nv)
// 内存 Map 无记录时回退落盘稿(重启后 Map 为空;同会话内 Map 恒比落盘新,优先)
if (!inputText.value && nv) {
const persisted = readDraftsStore()[nv]
if (typeof persisted === 'string') inputText.value = persisted
}
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
pendingMentionSpans.value = []
@@ -1205,7 +1298,24 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
mentionStart.value = -1
autoResize()
})
}, { immediate: true })
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
watch(
() => convDraftAppends.has(store.state.activeConversationId ?? ''),
(has) => {
if (!has) return
const convId = store.state.activeConversationId
const chunk = takeConvDraftAppend(convId)
if (!chunk) return
inputText.value = inputText.value ? `${inputText.value}\n${chunk}` : chunk
nextTick(() => {
autoResize()
focus()
})
},
)
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
@@ -1242,32 +1352,33 @@ defineExpose({
零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。
CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ -->
<style scoped>
/* 2026-10-18 wave2: 输入盒迁移 workbench .inp-box 范式——bg-card 底+0.5px 边框+9px 圆角,
focus-within 边框转 info;纵向布局让全宽图片预览行自然换行(替代原 flex-wrap 手法) */
.ai-input-wrap {
position: relative;
display: flex;
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
flex-wrap: wrap;
align-items: center;
gap: 6px;
flex-direction: column;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 6px 8px;
border-radius: 9px;
padding: 9px 12px;
transition: border-color 0.15s;
}
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
.ai-input-wrap:focus-within {
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
border-color: var(--df-info);
}
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
/* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式);
autosize 高度参数(min/height/max-height)为行为契约,禁动 */
.ai-input {
flex: 1;
width: 100%;
background: transparent;
border: none;
outline: none;
@@ -1284,6 +1395,19 @@ defineExpose({
.ai-input::placeholder {
color: var(--df-text-dim);
}
/* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */
.ai-input-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 0.5px solid var(--df-border);
}
/* 发送钮(workbench .isend 范式):info→accent 135° 渐变+深色 icon+600 字重;
禁用态 opacity .4(禁用判定逻辑不动)。原 --active 实心紫规则并入基类,类名绑定保留无害 */
.ai-send-btn {
display: flex;
align-items: center;
@@ -1292,38 +1416,37 @@ defineExpose({
height: 28px;
border: none;
border-radius: var(--df-radius);
background: var(--df-bg-raised);
color: var(--df-text-dim);
background: linear-gradient(135deg, var(--df-info), var(--df-accent));
color: color-mix(in srgb, var(--df-bg) 70%, #000);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
transition: filter 0.15s, opacity 0.15s;
flex-shrink: 0;
margin-left: auto;
}
.ai-send-btn:not(:disabled):hover {
filter: brightness(1.1);
}
/* 功能钮统一 28px 高 bg-card 边框钮(workbench 工具行左侧范式),hover 边框亮 */
.ai-img-pick-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
background: transparent;
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
flex-shrink: 0;
margin-right: 4px;
}
.ai-img-pick-btn:hover {
background: var(--df-bg-raised);
background: var(--df-bg-card-hover);
border-color: var(--df-border-strong);
color: var(--df-text);
}
.ai-send-btn--active {
background: var(--df-accent);
color: #fff;
}
.ai-send-btn--active:hover {
background: var(--df-accent-hover);
}
.ai-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
@@ -1347,20 +1470,34 @@ defineExpose({
.ai-send-btn--retry:hover {
background: #d63a3f;
}
/* 命令提示 chip(workbench .icmd 范式):mono 10px --df-text-dim */
.ai-input-hint {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
text-align: right;
margin-top: 4px;
opacity: 0.6;
}
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
.ai-img-warn-chip {
font-size: 10.5px;
font-size: 10px;
color: var(--df-warning);
padding: 1px 4px;
}
/* ── 2026-10-18 wave2: 草稿/mention 弹层对齐 workbench 弹层范式 ──
SkillMention/MentionPopover 共用 styles/components.css 的 .ai-skill-* 全局基类(禁碰),
经 :deep 在此集中覆写一份(两子组件弹层同源,避免各自 scoped 重复):
8px 圆角+浅阴影;选中项 bg-card-hover+左 2px 强调边(inset 阴影实现,零布局位移) */
.ai-input-wrap :deep(.ai-skill-popover) {
border-radius: var(--df-radius-md);
box-shadow: var(--df-shadow-sm);
}
.ai-input-wrap :deep(.ai-skill-item--active) {
background: var(--df-bg-card-hover);
box-shadow: inset 2px 0 0 var(--df-accent);
}
</style>
+5 -5
View File
@@ -104,16 +104,16 @@ watch(
<style scoped>
.conflict-resolver {
border: 1px solid var(--df-warning, #ffc107);
border: 1px solid var(--df-warning);
border-radius: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--df-bg-secondary);
background: var(--df-bg-card);
}
.conflict-header {
font-weight: 600;
margin-bottom: 8px;
color: var(--df-warning, #ffc107);
color: var(--df-warning);
}
.conflict-item {
border-top: 1px solid var(--df-border);
@@ -153,8 +153,8 @@ watch(
padding: 2px 6px;
border-radius: 4px;
}
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
.badge--file { background: var(--df-warning-bg); color: var(--df-warning); }
.badge--semantic { background: var(--df-danger-bg); color: var(--df-danger); }
.conflict-file code { font-size: 12px; color: var(--df-text); }
.conflict-diffs {
display: grid;
+233
View File
@@ -0,0 +1,233 @@
<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
有源会话的条目标题可点跳回原文,空源或跳转失败静默不打断阅读。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
:title="t('aiChat.contextBriefsToggle')"
@click="toggle"
>
<svg
aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
><polyline points="9 18 15 12 9 6"/></svg>
<span>{{ t('aiChat.contextBriefsTitle') }}</span>
<span v-if="!expanded && totalBriefs > 0" class="ctx-briefs-count">{{ totalBriefs }}</span>
</button>
<div v-if="expanded" class="ctx-briefs-body">
<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">
<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-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"
:title="t('aiChat.contextBriefsBringIn')"
@click="bringIn(g, b)"
>{{ t('aiChat.contextBriefsBringIn') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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)
const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
/** 拉取脉络,按当前会话标注聚焦分组;会话已切走时丢弃过期响应(防旧 focused 污染新列表) */
async function load() {
const convId = store.state.activeConversationId ?? undefined
loading.value = true
try {
const result = await aiApi.listContextBriefs(undefined, convId)
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = result
} catch {
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 以实时内容为基底合并),
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
function bringIn(g: ProjectBriefs, b: ContextBrief) {
const convId = store.state.activeConversationId
if (!convId) return
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
}
</script>
<style scoped>
/* wave2(2026-10-18):旧 token 全量迁 --df-*;统一透明彩底卡范式,accent 系(脉络=系统功能信号)
——边框 color-mix 30% + 底 6% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
.ctx-briefs {
border-bottom: 0.5px solid var(--df-border);
padding: 4px 10px;
font-size: 12px;
}
.ctx-briefs-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 2px;
border: none;
background: none;
color: var(--df-accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ctx-briefs-count {
background: var(--df-accent-soft);
color: var(--df-accent);
border-radius: var(--df-radius-md);
padding: 0 6px;
font-size: 11px;
font-weight: 400;
}
.ctx-briefs-body {
max-height: 220px;
overflow-y: auto;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
margin-bottom: 4px;
}
.ctx-briefs-empty {
color: var(--df-text-dim);
padding: 6px 2px;
}
.ctx-briefs-project {
font-weight: 600;
color: var(--df-text);
font-size: 12px;
padding: 5px 2px 2px;
}
.ctx-briefs-focus-dot {
display: inline-block;
width: 5px;
height: 5px;
margin-right: 4px;
border-radius: 50%;
background: var(--df-accent);
vertical-align: 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 0.5px dashed var(--df-border);
}
.ctx-briefs-item-head {
display: flex;
align-items: baseline;
gap: 6px;
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--df-text);
}
.ctx-briefs-item-link {
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-briefs-item-link:hover {
color: var(--df-accent);
}
.ctx-briefs-item-time {
margin-left: auto;
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
.ctx-briefs-item-summary {
color: var(--df-text-secondary);
font-size: 12px;
margin: 2px 0;
line-height: 1.4;
}
.ctx-briefs-bring {
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
background: transparent;
color: var(--df-accent);
border-radius: var(--df-radius-sm);
padding: 1px 8px;
font-size: 11px;
cursor: pointer;
}
.ctx-briefs-bring:hover {
background: var(--df-accent-soft);
}
</style>
+153 -46
View File
@@ -32,6 +32,8 @@
/>
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
</div>
<!-- 脉络卡:同项目近期决策摘要,纯读侧,「带入上下文」追加进当前会话输入框 -->
<ContextBriefsCard />
<div class="ai-conv-list">
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
<template v-if="searchResults">
@@ -50,6 +52,18 @@
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
<input
@@ -68,7 +82,6 @@
@dblclick.stop="startRename(conv)"
>
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
@@ -143,6 +156,16 @@
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<input
v-if="editingConvId === conv.id"
:ref="setEditEl"
@@ -166,8 +189,6 @@
class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><polyline points="20 6 9 17 4 12"/></svg>
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
@@ -223,6 +244,8 @@
@keydown.space.prevent="onRecover(conv.id)"
>
<div class="ai-conv-item-main">
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
<span class="ai-conv-item-title">
{{ conv.title || t('aiChat.newConversation') }}
<span class="ai-conv-recover-badge">⏸</span>
@@ -258,9 +281,18 @@
@keydown="onItemKeydown($event, conv.id)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
@@ -294,6 +326,17 @@
</div>
</template>
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
<div
v-if="convListPaging.hasMore"
class="ai-conv-load-more"
role="button"
tabindex="0"
@click="onLoadMore"
@keydown.enter.prevent="onLoadMore"
@keydown.space.prevent="onLoadMore"
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
{{ t('aiChat.emptyConversation') }}
</div>
@@ -336,7 +379,8 @@ import { convPendingApprovals } from '../../composables/ai/aiShared'
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
// F4-1: 会话列表分页游标(reactive,绑「加载更多」显隐/loading)+ 续拉/拉满动作
import { convOpFailNotice, convListPaging, loadMoreConversations, loadAllConversations } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast'
import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types'
@@ -660,6 +704,28 @@ const searchResults = computed<AiConversationSummary[] | null>(
() => store.filteredConversations.value,
)
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
function onLoadMore(): void {
void loadMoreConversations()
}
// 搜索输入即拉满:搜索过滤(stores.filteredConversations)跑在已加载列表上,未拉满时
// 第 51 条起的会话搜不到(硬 50 截断=搜索失灵)——输入非空且还有未拉页时一次拉尽,
// 之后搜索自然覆盖全量;已拉满(hasMore=false)零开销不触发。
watch(() => store.state.searchQuery, (q) => {
if (q.trim() && convListPaging.hasMore && !convListPaging.loading) {
void loadAllConversations()
}
})
// 加载更多/搜索拉满追加的新会话补拉边界徽标镜像(A10 空洞随列表加深扩大;
// hydrate 对已入镜像的会话跳过,常规刷新时零 IPC)
watch(() => store.state.conversations.length, () => {
void hydrateBoundaryBadges()
})
// ── 侧栏宽度拖拽 ──
const resizeState = { startX: 0, startWidth: 0 }
function onResizeMove(e: MouseEvent) {
@@ -754,7 +820,7 @@ defineExpose({
transition: background 0.15s var(--df-ease);
}
.ai-sidebar-resize-handle:hover {
background: var(--df-accent, #4f8cff);
background: var(--df-accent);
opacity: 0.5;
}
/* 拖拽中全局锁 cursor + 禁选中文本(挂在 body 上,跨元素生效) */
@@ -771,18 +837,26 @@ body.ai-sidebar-resizing * {
border-bottom: 0.5px solid var(--df-border);
}
.ai-conv-sidebar-title {
font-size: 12px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
color: var(--df-text);
}
/* UX-06 §3.1: 对话搜索框(紧贴 header 下,样式跟随周围输入控件) */
/* UX-06 §3.1: 对话搜索框(wave2 u-agent .sb-search 范式:容器成框,卡底+focus 边框 info) */
.ai-conv-search {
position: relative;
display: flex;
align-items: center;
padding: 6px 10px;
border-bottom: 0.5px solid var(--df-border);
margin: 8px 10px;
padding: 0 8px;
height: 30px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
transition: border-color 0.15s var(--df-ease);
}
.ai-conv-search:focus-within {
border-color: var(--df-info);
}
.ai-conv-search-icon {
flex: none;
@@ -798,8 +872,8 @@ body.ai-sidebar-resizing * {
font-size: 12px;
color: var(--df-text);
}
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* 聚焦可见环:容器已随 :focus-within 变 info 边框,键盘环同色补强(scoped 特异性须盖全局兜底) */
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-info); outline-offset: 1px; }
.ai-conv-search-input::placeholder {
color: var(--df-text-dim);
}
@@ -817,12 +891,15 @@ body.ai-sidebar-resizing * {
.ai-conv-list {
flex: 1;
overflow-y: auto;
padding: 4px;
padding: 4px 6px; /* u-agent .sb-list 范式;滚动条走 global ::-webkit-scrollbar token 体系 */
}
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
.ai-conv-group-title {
padding: 8px 8px 3px;
padding: 10px 8px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--df-text-dim);
user-select: none;
}
@@ -849,7 +926,10 @@ body.ai-sidebar-resizing * {
.ai-conv-group-count {
margin-left: 2px;
font-weight: 400;
opacity: 0.7;
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
font-size: 10px;
letter-spacing: 0;
opacity: 0.9;
}
.ai-conv-group-empty {
padding: 2px 8px 4px 20px;
@@ -857,40 +937,48 @@ body.ai-sidebar-resizing * {
color: var(--df-text-dim);
font-style: italic;
}
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
.ai-conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
padding: 8px;
border-radius: var(--df-radius);
cursor: pointer;
transition: background 0.15s;
gap: 4px;
gap: 6px;
}
.ai-conv-item:hover {
background: var(--df-sidebar-hover);
background: var(--df-bg-card-hover);
}
.ai-conv-item--active {
background: var(--df-sidebar-active);
background: var(--df-bg-card-hover);
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
.ai-conv-item--switching {
background: var(--df-sidebar-active);
background: var(--df-bg-card-hover);
opacity: 0.7;
}
.ai-conv-item--archived .ai-conv-item-title {
opacity: 0.6;
}
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
.ai-conv-item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
flex-direction: row;
align-items: center;
gap: 6px;
}
.ai-conv-item-title {
font-size: 11px;
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--df-text);
white-space: nowrap;
overflow: hidden;
@@ -899,7 +987,6 @@ body.ai-sidebar-resizing * {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
}
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
.ai-conv-item-title--flash {
@@ -914,34 +1001,35 @@ body.ai-sidebar-resizing * {
to { opacity: 1; transform: translateX(0); }
}
.ai-conv-item-edit {
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
min-width: 0;
width: auto;
font-size: 11px;
color: var(--df-text);
width: 100%;
height: 18px;
padding: 0 4px;
border: 1px solid var(--df-accent);
border-radius: var(--df-radius-sm);
background: var(--df-bg, #fff);
background: var(--df-bg);
outline: none;
box-sizing: border-box;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
.ai-conv-item-time {
font-size: 9px;
flex: none;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
/* 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */
.ai-conv-gen-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-conv-gen-pulse 1.4s ease-in-out infinite;
}
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
.ai-conv-status--danger { background: var(--df-danger); }
.ai-conv-status--paused { background: var(--df-warning); }
@keyframes ai-conv-gen-pulse {
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
@@ -952,7 +1040,7 @@ body.ai-sidebar-resizing * {
height: 6px;
border-radius: 50%;
margin-right: 5px;
background: var(--df-warning, #f0c75e);
background: var(--df-warning);
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
}
.ai-conv-item--recoverable {
@@ -963,7 +1051,7 @@ body.ai-sidebar-resizing * {
margin-left: 2px;
font-size: 10px;
line-height: 1;
color: var(--df-warning, #f0c75e);
color: var(--df-warning);
cursor: help;
}
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
@@ -972,7 +1060,7 @@ body.ai-sidebar-resizing * {
height: 12px;
padding: 0 3px;
border-radius: 6px;
background: var(--df-warning, #f0c75e);
background: var(--df-warning);
color: #000;
font-size: 9px;
font-weight: 600;
@@ -1029,9 +1117,13 @@ body.ai-sidebar-resizing * {
color: var(--df-text);
}
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
}
.ai-conv-item--active.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* ── 「更多」操作菜单(置顶/归档/删除)──
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
@@ -1053,8 +1145,8 @@ body.ai-sidebar-resizing * {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--df-bg, #fff);
border: 1px solid var(--df-border, #e2e2e2);
background: var(--df-bg);
border: 1px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
@@ -1085,6 +1177,21 @@ body.ai-sidebar-resizing * {
font-size: 11px;
color: var(--df-text-dim);
}
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
.ai-conv-load-more {
margin: 4px 2px;
padding: 6px 8px;
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
text-align: center;
cursor: pointer;
user-select: none;
}
.ai-conv-load-more:hover {
background: var(--df-bg-card-hover);
color: var(--df-text);
}
/* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast,
但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */
@@ -1099,7 +1206,7 @@ body.ai-sidebar-resizing * {
z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */
max-width: 85%;
overflow-wrap: anywhere;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
box-shadow: var(--df-shadow-sm);
background: var(--df-danger-bg);
color: var(--df-danger);
border: 0.5px solid var(--df-danger);
+2 -2
View File
@@ -154,12 +154,12 @@ watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
border-radius: var(--df-radius);
}
.ai-dir-auth-text {
font-size: 11.5px;
font-size: 11px;
font-weight: 600;
color: var(--df-warning);
}
.ai-dir-auth-hint {
font-size: 10.5px;
font-size: 10px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-all;
+6 -5
View File
@@ -34,7 +34,7 @@ const examplePrompts = [
<div class="ai-empty">
<template v-if="!hasProvider">
<div class="ai-empty-icon">
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
@@ -46,7 +46,7 @@ const examplePrompts = [
</template>
<template v-else>
<div class="ai-empty-icon">
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
@@ -73,7 +73,8 @@ const examplePrompts = [
justify-content: center;
gap: 8px;
}
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; }
/* wave2:空态图标统一 dim 色(经 currentColor 接管 stroke),引导文字 secondary 层级 */
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; color: var(--df-text-dim); }
.ai-empty-text {
font-size: 14px;
font-weight: 500;
@@ -81,7 +82,7 @@ const examplePrompts = [
}
.ai-empty-sub {
font-size: 12px;
color: var(--df-text-dim);
color: var(--df-text-secondary);
text-align: center;
line-height: 1.5;
}
@@ -117,7 +118,7 @@ const examplePrompts = [
border-radius: var(--df-radius);
background: var(--df-bg-card);
color: var(--df-text-secondary);
font-size: 11.5px;
font-size: 11px;
line-height: 1.4;
text-align: left;
cursor: pointer;
+14 -14
View File
@@ -74,30 +74,30 @@ function handleSelectOption(opt: string): void {
</script>
<style scoped>
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(断路器熔断=最高警示级)——
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
.ai-help-required {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-radius: var(--df-radius);
}
.ai-help-required-text {
font-size: 11.5px;
font-size: 12px;
font-weight: 600;
color: var(--df-warning);
color: var(--df-danger);
}
.ai-help-required-hint {
font-size: 10.5px;
color: var(--df-text-dim);
opacity: 0.9;
font-size: 12px;
color: var(--df-text-secondary);
word-break: break-all;
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-family: var(--df-font-mono);
max-height: 200px;
overflow-y: auto;
}
@@ -109,15 +109,15 @@ function handleSelectOption(opt: string): void {
margin-top: 2px;
}
.ai-help-required-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
color: var(--df-danger);
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease);
}
.ai-help-required-btn:hover { filter: brightness(1.1); }
.ai-help-required-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
</style>
+2 -2
View File
@@ -84,7 +84,7 @@ const emit = defineEmits<{
text-overflow: ellipsis;
white-space: nowrap;
}
/* 类型标签小色块(项目/任务区分) */
/* 类型标签小色块(项目/任务区分)。2026-10-18: 原底色引用未定义 token 静默失效,修正为 bg-card-hover */
.ai-mention-item-type {
flex-shrink: 0;
padding: 1px 5px;
@@ -92,7 +92,7 @@ const emit = defineEmits<{
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
background: var(--df-bg-hover);
background: var(--df-bg-card-hover);
color: var(--df-text-dim);
}
.ai-mention-item-type--project {
+19 -14
View File
@@ -149,29 +149,34 @@ const imageParts = computed(() => msgImageParts(props.msg))
align-items: flex-start;
flex-direction: row-reverse;
}
/* 头像通用 */
/* 头像通用 — workbench .av 范式:24px 圆角方块渐变,用户 warning→danger 系,
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
.ai-msg-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
width: 24px;
height: 24px;
border-radius: var(--df-radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
}
.ai-msg-avatar--user {
background: var(--df-info-bg, rgba(100,181,246,0.15));
color: var(--df-info, #64b5f6);
background: linear-gradient(135deg, color-mix(in srgb, var(--df-warning) 70%, var(--df-danger)), var(--df-danger));
color: var(--df-bg);
}
/* 气泡通用 */
/* 气泡通用 — workbench 聊天范式:用户气泡=bg-card 底+0.5px 边框+9px 圆角。
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
.ai-msg-bubble {
border-radius: 10px;
padding: 8px 12px;
border-radius: 9px;
padding: 9px 13px;
word-break: break-word;
max-width: 80%;
}
.ai-msg-bubble--user {
background: var(--df-info-bg, rgba(100,181,246,0.1));
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
color: var(--df-text);
}
/* chip */
@@ -181,13 +186,13 @@ const imageParts = computed(() => msgImageParts(props.msg))
border-radius: 4px;
font-size: 11px;
margin: 0 2px;
background: var(--df-accent-bg, rgba(108,99,255,0.1));
color: var(--df-accent, #6c63ff);
background: var(--df-accent-bg);
color: var(--df-accent);
}
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
.ai-msg-chip--skill {
background: var(--df-accent-bg, rgba(108,99,255,0.18));
color: var(--df-accent, #6c63ff);
background: var(--df-accent-bg);
color: var(--df-accent);
font-weight: 600;
}
/* 图片 */
+144 -165
View File
@@ -45,7 +45,7 @@ const emit = defineEmits<{
}>()
const store = useAiStore()
const { t } = useI18n()
const { t, locale } = useI18n()
// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ──
// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中
@@ -676,23 +676,28 @@ const _decoCache = new Map<string, string>()
const DECO_CACHE_LIMIT = 800
function decorateCodeHtml(raw: string): string {
if (!raw || !raw.includes('<pre><code')) return raw
const hit = _decoCache.get(raw)
// F1-1:复制按钮文案走 i18n(v-html 渲染层拿不到 $t,经参数注入);locale 入缓存键,
// 否则切语言后命中旧缓存,按钮文案停留在旧语言。locale 读取同时建立响应式依赖,
// 切语言时调用方(computed/渲染函数)重算拿到新语言装饰结果。
const cacheKey = locale.value + '\u0000' + raw
const hit = _decoCache.get(cacheKey)
if (hit !== undefined) {
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
_decoCache.delete(raw)
_decoCache.set(raw, hit)
_decoCache.delete(cacheKey)
_decoCache.set(cacheKey, hit)
return hit
}
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
const copyLabel = t('aiChat.copyCode')
const out = raw.replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
(_m: string, attrs: string, body: string) => {
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
const langAttr = lang ? ` data-lang="${lang}"` : ''
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">${copyLabel}</button><pre><code${attrs}>${body}</code></pre></div>`
},
)
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
@@ -700,7 +705,7 @@ function decorateCodeHtml(raw: string): string {
const oldest = _decoCache.keys().next().value
if (oldest !== undefined) _decoCache.delete(oldest)
}
_decoCache.set(raw, out)
_decoCache.set(cacheKey, out)
return out
}
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
@@ -744,7 +749,7 @@ async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
}
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
const prev = btn.textContent
btn.textContent = ok ? '已复制' : '复制失败'
btn.textContent = ok ? t('aiChat.copied') : t('aiChat.copyFailed')
btn.classList.add(ok ? 'is-ok' : 'is-err')
setTimeout(() => {
btn.textContent = prev
@@ -924,13 +929,12 @@ function collapsedPreviewHtml(msg: AiMessage): string {
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
// 展开态仍复用既有 aiChat.collapse 键。
// 文案走 i18n(F1-1 补键);展开态仍复用既有 aiChat.collapse 键。
const CHAR_PER_TOKEN_EST = 2.5
function collapsedLongLabel(msg: AiMessage): string {
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
return t('aiChat.foldedSummary', { chars: folded, tokens })
}
/**
@@ -1226,7 +1230,13 @@ const renderItems = computed<RenderItem[]>(() => {
bucket = []
if (msgs.length === 0) return
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
// F1-3(10-17 走查):执行中判定补充「桶内存在 running/pending_approval 工具」。
// 仅靠「桶末=store 末条」时,生成中注入补充消息会在 store 末尾 push 本地 user 气泡,
// 判定瞬间失配 → 执行桶被误判为完成态,降级成默认折叠 trace 卡,正在跑/待审批的
// 工具卡跌进折叠体不可见(下一轮 assistant push 才自愈)。桶内工具仍活跃即说明该轮
// 未收敛,保持 step 形态内联可见;工具全部收敛后回到末条比对,历史桶不受影响(BUG-261001)。
const hasLiveTool = msgs.some(m => (m.toolCalls ?? []).some(tc => tc.status === 'running' || tc.status === 'pending_approval'))
const executing = isViewingGenerating.value && (msgs[msgs.length - 1].id === lastStoreId || hasLiveTool)
// 961③: 轮号=当前发起计数(与轨迹卡是否产出解耦,单消息桶不吞号)
const round = currentRound
const built = buildBucket(msgs, round, executing, lastTs)
@@ -1566,7 +1576,7 @@ watch(() => store.state.activeConversationId, (newId, oldId) => {
onBeforeUnmount(() => {
cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
document.removeEventListener('keydown', closeAgentTraceOnEsc)
document.removeEventListener('keydown', closeOverlaysOnEsc)
})
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
@@ -1575,11 +1585,14 @@ function closeTokenPopoverOnOutsideClick(): void {
}
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
// 浮层 Esc 关闭:子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅外点关闭,
// 键盘用户开了面板只能点别处)。两者同时开着时逐层关(先浮层后 popover),Esc 不冒泡旁路。
function closeOverlaysOnEsc(e: KeyboardEvent): void {
if (e.key !== 'Escape') return
if (agentPeekVisible.value) closeAgentTrace()
else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null
}
document.addEventListener('keydown', closeAgentTraceOnEsc)
document.addEventListener('keydown', closeOverlaysOnEsc)
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。
@@ -2141,7 +2154,7 @@ defineExpose({
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
box-shadow: var(--df-shadow-sm);
z-index: 15;
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
@@ -2161,7 +2174,7 @@ defineExpose({
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--df-warning, #e8a33d);
background: var(--df-warning);
animation: ai-btb-pulse 2s ease-in-out infinite;
}
@keyframes ai-btb-pulse {
@@ -2172,14 +2185,8 @@ defineExpose({
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
/* -- Message Layout -- */
.ai-msg-user {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */
margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */
}
/* 用户消息行 .ai-msg-user 随 MessageItem.vue 抽离(自带 scoped,本组件 scoped 不渗透其内部),
原重复定义删除(2026-10-18 整洁波,死样式验证:模板零引用+scoped 隔离永不命中) */
.ai-msg-ai {
position: relative;
display: flex;
@@ -2197,7 +2204,8 @@ defineExpose({
gap: 8px;
}
/* -- Avatar -- */
/* -- Avatar -- workbench .av 范式:24px 圆角方块渐变(用户 warning→danger 系 / AI info→accent 系),
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
.ai-msg-avatar {
width: 24px;
height: 24px;
@@ -2206,41 +2214,44 @@ defineExpose({
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
}
.ai-msg-avatar--user {
background: var(--df-info-bg);
color: var(--df-info);
}
/* .ai-msg-avatar--user 随 MessageItem.vue 抽离(自带 scoped) */
.ai-msg-avatar--ai {
background: var(--df-accent-bg);
color: var(--df-accent);
background: linear-gradient(135deg, color-mix(in srgb, var(--df-info) 70%, var(--df-accent)), var(--df-accent));
color: var(--df-bg);
}
/* -- Bubble -- */
/* -- Bubble -- workbench 聊天范式:用户=bg-card 底+0.5px 边框+9px 圆角;AI=无气泡(透明底直接排版)。
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
.ai-msg-bubble {
position: relative;
font-size: 13px;
line-height: 1.6;
max-width: var(--df-msg-max-width);
padding: 8px 12px;
border-radius: var(--df-radius-lg);
padding: 9px 13px;
border-radius: 9px;
overflow-wrap: anywhere;
}
.ai-msg-bubble--user {
background: var(--df-accent);
color: #fff;
border-bottom-right-radius: var(--df-radius-sm);
}
/* 用户气泡 .ai-msg-bubble--user 随 MessageItem.vue 抽离(自带 scoped,原「同范式兜底」
实为 scoped 隔离永不命中的死规则,2026-10-18 整洁波删除) */
.ai-msg-bubble--ai {
background: var(--df-bg-card);
background: transparent;
border: none;
padding: 0;
color: var(--df-text);
border: 0.5px solid var(--df-border);
border-bottom-left-radius: var(--df-radius-sm);
}
/* 错误气泡对齐 workbench .blocked-card 范式:30% 红边框+6% 红底+12px 字;
AI 无气泡后本卡自含 padding/边框/圆角(排在 --ai 后覆盖) */
.ai-msg-bubble--error {
background: var(--df-danger-bg);
padding: 10px 13px;
border: 1px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-radius: 9px;
background: color-mix(in srgb, var(--df-danger) 6%, transparent);
color: var(--df-danger);
border-color: rgba(240,101,101,0.3);
font-size: 12px;
margin-top: 6px;
}
/* 修正 的 display:block 方案——display:block 让 table 失去表格
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
@@ -2255,8 +2266,8 @@ defineExpose({
overflow-x: auto;
}
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景,
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
AI 气泡已透明(workbench 范式):渐变淡出到页面背景 --df-bg,视觉上内容「沉入」页面
而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
.ai-msg-bubble--collapsed {
max-height: 400px;
overflow: hidden;
@@ -2269,7 +2280,7 @@ defineExpose({
left: 0;
right: 0;
height: 48px;
background: linear-gradient(transparent, var(--df-bg-card));
background: linear-gradient(transparent, var(--df-bg));
pointer-events: none; /* 让点击穿透到下方展开按钮 */
}
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
@@ -2293,10 +2304,12 @@ defineExpose({
background: var(--df-bg-card-hover);
}
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:弱化灰字 / 11px /
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。
F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。 ── */
.ai-msg-json {
max-height: 80px;
overflow-y: auto;
@@ -2307,7 +2320,7 @@ defineExpose({
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.5;
color: #b0b0b0;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
}
@@ -2325,27 +2338,8 @@ defineExpose({
border-color: var(--df-accent);
}
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
.ai-msg-chip {
display: inline-block;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--df-radius);
font-size: 0.9em;
font-weight: 600;
line-height: 1.4;
background: rgba(255, 255, 255, 0.22);
color: #fff;
vertical-align: baseline;
white-space: nowrap;
}
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── mention chip(.ai-msg-chip 族)随 MessageItem.vue 抽离(自带 scoped,含 --skill 变体;
此处原 kind 变体因 scoped 隔离永不命中,2026-10-18 整洁波删除) ── */
/* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
@@ -2368,10 +2362,13 @@ defineExpose({
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层);
AI 无气泡后本卡自含 padding 保持卡感(普通 AI 消息仍无气泡) ── */
.ai-msg-bubble--answer {
border-left: 2px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card));
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-accent) 4%, transparent);
padding: 8px 12px;
}
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
@@ -2415,8 +2412,8 @@ defineExpose({
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: var(--df-h-icon-btn);
height: var(--df-h-icon-btn);
border: none;
border-radius: var(--df-radius-sm);
background: rgba(0, 0, 0, 0.35);
@@ -2426,14 +2423,13 @@ defineExpose({
transition: opacity 0.2s;
flex-shrink: 0;
}
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-ai:hover .ai-copy-btn--ai {
opacity: 1;
}
.ai-copy-btn:hover {
background: rgba(0, 0, 0, 0.55);
}
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */
/* 图片复制按钮排在文本复制按钮左侧(right 让位 26px 按钮 + 4px 间距) */
.ai-copy-btn--image {
right: 30px;
}
@@ -2443,6 +2439,23 @@ defineExpose({
.ai-md :deep(.md-code-block) {
position: relative;
}
/* workbench .code 范式:比 --df-bg 更深一档的底 + 边框 + md 圆角 + mono 11px,
覆盖全局 ai-md.css 的 .ai-md pre(现有 hljs github-dark 语法高亮色不动)。
浅色主题降为 8% 黑:45% 全量在浅底上会压垮正文/高亮对比,违背双主题成立铁律。 */
.ai-md :deep(pre) {
background: color-mix(in srgb, var(--df-bg), #000 45%);
border: 1px solid var(--df-border);
border-radius: var(--df-radius-md);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-md :deep(pre code) {
font-size: 11px;
line-height: 1.6;
}
[data-theme='light'] .ai-md :deep(pre) {
background: color-mix(in srgb, var(--df-bg), #000 8%);
}
.ai-md :deep(.md-code-copy) {
position: absolute;
top: 6px;
@@ -2468,8 +2481,8 @@ defineExpose({
}
.ai-md :deep(.md-code-copy.is-ok) {
opacity: 1;
color: var(--df-success, #4caf50);
border-color: var(--df-success, #4caf50);
color: var(--df-success);
border-color: var(--df-success);
}
.ai-md :deep(.md-code-copy.is-err) {
opacity: 1;
@@ -2519,7 +2532,7 @@ defineExpose({
/* Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮,
说明为何无重试入口,引导走「去设置」。 */
.ai-msg-not-retryable {
font-size: 10.5px;
font-size: 10px;
color: var(--df-text-dim);
font-style: italic;
align-self: center;
@@ -2528,12 +2541,12 @@ defineExpose({
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
padding: 2px 6px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
font-size: 10.5px;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
transition: background 0.15s, color 0.15s;
@@ -2565,11 +2578,11 @@ defineExpose({
margin-top: 4px;
padding: 3px 8px;
border-radius: var(--df-radius-sm);
background: color-mix(in srgb, var(--df-warning, #ffc107) 10%, transparent);
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
width: fit-content;
}
.ai-msg-truncated-text {
font-size: 10.5px;
font-size: 10px;
line-height: 1.4;
color: var(--df-text-dim);
}
@@ -2600,7 +2613,7 @@ defineExpose({
padding-top: 6px;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 9px;
font-size: 10px;
color: var(--df-text-dim);
opacity: 0.7;
}
@@ -2618,8 +2631,9 @@ defineExpose({
cursor: pointer;
opacity: 1;
}
/* workbench .round-meta 范式:元数据 hover 变 --df-info */
.ai-token-usage-trigger:hover {
color: var(--df-text);
color: var(--df-info);
opacity: 1;
}
.ai-token-sep {
@@ -2627,7 +2641,7 @@ defineExpose({
}
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
.ai-token-est {
color: var(--df-warning, #f0c75e);
color: var(--df-warning);
opacity: 0.85;
}
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
@@ -2638,7 +2652,7 @@ defineExpose({
margin-top: 4px;
padding: 8px 10px;
min-width: 200px;
background: var(--df-bg-card, var(--df-bg));
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
@@ -2678,23 +2692,7 @@ defineExpose({
font-weight: 500;
}
/* 用户气泡内的图片(多模态消息渲染) */
.ai-msg-images {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.ai-msg-image {
max-width: 200px;
max-height: 200px;
width: auto;
height: auto;
border-radius: var(--df-radius);
object-fit: contain;
display: block;
background: var(--df-bg);
}
/* 用户气泡内图片(.ai-msg-images/.ai-msg-image)随 MessageItem.vue 抽离(自带 scoped) */
/* ═══ 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
@@ -2773,7 +2771,7 @@ defineExpose({
.ai-trace-head {
display: flex;
align-items: center;
gap: 7px;
gap: 8px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
@@ -2806,6 +2804,11 @@ defineExpose({
font-size: 10px;
color: var(--df-text-dim);
}
/* workbench .round-meta 范式:轮次/时间/token 小元数据 hover 变 --df-info(折叠交互不动) */
.ai-trace-head:hover .ai-trace-agg,
.ai-trace-head:hover .ai-trace-tokens {
color: var(--df-info);
}
/* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */
.ai-trace-task-chip {
flex-shrink: 0;
@@ -2826,7 +2829,8 @@ defineExpose({
}
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。
workbench .tr 范式:1px 连线用 --df-border。 ── */
.ai-trace-body {
position: relative;
padding: 2px 10px 8px 0;
@@ -2838,7 +2842,7 @@ defineExpose({
top: 0;
bottom: 0;
width: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
background: var(--df-border);
}
.ai-trace-round {
position: relative;
@@ -2852,10 +2856,10 @@ defineExpose({
position: relative;
display: flex;
align-items: center;
gap: 7px;
gap: 6px;
width: 100%;
min-height: 24px;
padding: 3px 8px 3px 38px;
padding: 3px 8px 3px var(--df-icon-gutter);
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
@@ -2872,16 +2876,16 @@ defineExpose({
}
.ai-think-ic {
position: absolute;
left: 10px;
left: 11px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border-radius: var(--df-radius-sm);
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--df-accent-bg);
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
color: var(--df-accent);
flex-shrink: 0;
}
@@ -2896,14 +2900,14 @@ defineExpose({
flex: 0 0 auto;
white-space: nowrap;
font-family: var(--df-font-mono);
font-size: 9.5px;
font-size: 10px;
color: var(--df-text-dim);
opacity: 0.75;
margin-left: auto;
}
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
.ai-think-full {
margin: 2px 0 6px 38px;
margin: 2px 0 6px var(--df-icon-gutter);
padding: 8px 12px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
@@ -2916,30 +2920,14 @@ defineExpose({
}
/* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron,
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变 */
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变。
(.ai-trace-toggle--running/.ai-step-line/.ai-step-text 旧单行摘要骨架已废:step 卡
改用 trace-card 同构骨架,模板零引用,2026-10-18 整洁波删除) */
.ai-trace-card--running {
border-style: solid;
border-color: color-mix(in srgb, var(--df-accent) 28%, transparent);
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
}
.ai-trace-toggle--running {
color: var(--df-text-dim);
opacity: 0.9;
}
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
.ai-step-line {
display: flex;
align-items: center;
gap: 6px;
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-step-dot {
width: 6px;
height: 6px;
@@ -2948,13 +2936,6 @@ defineExpose({
flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite;
}
.ai-step-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes ai-step-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
@@ -2969,7 +2950,7 @@ defineExpose({
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
background: color-mix(in srgb, var(--df-warning) 8%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
@@ -2978,13 +2959,13 @@ defineExpose({
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-warning, #f0c75e);
background: var(--df-warning);
flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite;
}
.ai-retry-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -3018,10 +2999,10 @@ defineExpose({
flex-shrink: 0;
}
.ai-agent-fin--warn {
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
background: color-mix(in srgb, var(--df-warning) 8%, transparent);
}
.ai-agent-fin--warn .ai-agent-fin-dot {
background: var(--df-warning, #f0c75e);
background: var(--df-warning);
}
.ai-agent-fin--err {
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
@@ -3044,7 +3025,7 @@ defineExpose({
flex-shrink: 0;
}
.ai-agent-fin--warn .ai-agent-fin-status {
color: var(--df-warning, #f0c75e);
color: var(--df-warning);
}
.ai-agent-fin--err .ai-agent-fin-status {
color: var(--df-danger);
@@ -3057,7 +3038,8 @@ defineExpose({
.ai-agent-fin-meta {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
font-size: 10px;
color: var(--df-text-dim);
}
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
.ai-agent-fin-rejected {
@@ -3068,9 +3050,9 @@ defineExpose({
white-space: nowrap;
padding: 0 6px;
border-radius: 999px;
font-size: 10.5px;
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
color: var(--df-warning, #f0c75e);
font-size: 10px;
background: color-mix(in srgb, var(--df-warning) 16%, transparent);
color: var(--df-warning);
}
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
.ai-agent-fin--notrace {
@@ -3080,7 +3062,7 @@ defineExpose({
.ai-agent-fin--notrace:hover {
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
}
/* peek 拉取中的加载微标(挂在具体卡右端) */
/* peek 拉取中的加载微标(挂在具体卡右端);旋转动画消费 global.css @keyframes df-spin */
.ai-agent-fin-spin {
flex-shrink: 0;
width: 10px;
@@ -3088,10 +3070,7 @@ defineExpose({
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
border-top-color: var(--df-text-secondary);
border-radius: 50%;
animation: ai-agent-fin-spin 0.8s linear infinite;
}
@keyframes ai-agent-fin-spin {
to { transform: rotate(360deg); }
animation: df-spin 0.8s linear infinite;
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
@@ -3112,7 +3091,7 @@ defineExpose({
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
box-shadow: var(--df-shadow-md);
overflow: hidden;
}
.ai-agent-peek-head {
@@ -3124,7 +3103,7 @@ defineExpose({
flex-shrink: 0;
}
.ai-agent-peek-title {
font-size: 12.5px;
font-size: 12px;
font-weight: 600;
color: var(--df-text);
}
@@ -3169,7 +3148,7 @@ defineExpose({
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
font-size: 10px;
font-weight: 600;
color: var(--df-text-secondary);
margin-bottom: 4px;
@@ -3193,7 +3172,7 @@ defineExpose({
.ai-agent-peek-msg--tool .ai-agent-peek-text,
.ai-agent-peek-msg--system .ai-agent-peek-text {
color: var(--df-text-secondary);
font-size: 11.5px;
font-size: 11px;
}
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
.ai-agent-peek-head-text {
@@ -3214,7 +3193,7 @@ defineExpose({
.ai-agent-peek-agent {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
font-size: 10px;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
+25 -10
View File
@@ -73,7 +73,7 @@
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
import { useAiStore } from '../../stores/ai'
@@ -96,6 +96,20 @@ watch(() => props.open, (v) => {
if (v) void refresh()
})
// F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。
// 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡,
// 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果;
// IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫)。
function onEscClose(e: KeyboardEvent): void {
if (!props.open || e.key !== 'Escape') return
if (e.isComposing || e.keyCode === 229) return
e.preventDefault()
e.stopPropagation()
emit('close')
}
document.addEventListener('keydown', onEscClose)
onBeforeUnmount(() => document.removeEventListener('keydown', onEscClose))
// ── 列表展示 ──
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
@@ -230,7 +244,8 @@ async function onCreate(): Promise<void> {
.outbox-panel {
width: min(420px, 92vw);
height: 100%;
background: var(--df-bg-card);
/* wave2:抽屉底 bg-raised(比 bg-card 抬一级,与遮罩分层) */
background: var(--df-bg-raised);
border-left: 0.5px solid var(--df-border);
display: flex;
flex-direction: column;
@@ -259,7 +274,7 @@ async function onCreate(): Promise<void> {
border-radius: var(--df-radius);
}
.outbox-close:hover {
background: var(--df-bg-raised);
background: var(--df-bg-card-hover);
color: var(--df-text);
}
.outbox-list {
@@ -309,8 +324,8 @@ async function onCreate(): Promise<void> {
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
}
.outbox-badge--failed {
color: #e5484d;
border-color: color-mix(in srgb, #e5484d 40%, transparent);
color: var(--df-danger);
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
}
.outbox-text {
font-size: 12px;
@@ -336,7 +351,7 @@ async function onCreate(): Promise<void> {
transition: background 0.15s, color 0.15s;
}
.outbox-act:hover:not(:disabled) {
background: var(--df-bg-raised);
background: var(--df-bg-card-hover);
color: var(--df-text);
}
.outbox-act:disabled {
@@ -353,12 +368,12 @@ async function onCreate(): Promise<void> {
color: #fff;
}
.outbox-act--danger:hover:not(:disabled) {
color: #e5484d;
border-color: color-mix(in srgb, #e5484d 40%, transparent);
color: var(--df-danger);
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
}
.outbox-dt {
border: 0.5px solid var(--df-border);
background: var(--df-bg-raised);
background: var(--df-bg-card);
color: var(--df-text);
font-size: 11px;
padding: 3px 6px;
@@ -382,7 +397,7 @@ async function onCreate(): Promise<void> {
box-sizing: border-box;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
background: var(--df-bg-raised);
background: var(--df-bg-card);
color: var(--df-text);
font-size: 12px;
font-family: var(--df-font-sans);
+21 -16
View File
@@ -276,7 +276,8 @@ const echoSummary = computed(() => {
</script>
<style scoped>
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力)
wave2(2026-10-18):info 系透明彩底范式(授权边界=信息/配置信号,非警示) */
.ai-boundary {
margin-bottom: 4px;
font-size: 11px;
@@ -292,7 +293,7 @@ const echoSummary = computed(() => {
color: var(--df-text-dim);
font-size: 11px;
cursor: pointer;
border-radius: var(--df-radius-sm, 4px);
border-radius: var(--df-radius-sm);
max-width: 100%;
}
.ai-boundary-toggle:hover { color: var(--df-text); }
@@ -301,26 +302,29 @@ const echoSummary = computed(() => {
flex-shrink: 0;
}
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
.ai-boundary-toggle-label { font-weight: 600; }
/* 标题同色(info)12px 600 */
.ai-boundary-toggle-label { font-weight: 600; font-size: 12px; color: var(--df-info); }
.ai-boundary-echo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-accent);
color: var(--df-info);
opacity: 0.85;
}
.ai-boundary-body {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 7px;
gap: 8px;
padding: 8px;
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
background: color-mix(in srgb, var(--df-info) 6%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-info) 30%, transparent);
border-radius: var(--df-radius);
font-size: 12px;
color: var(--df-text-secondary);
}
.ai-boundary-locked {
font-size: 10.5px;
font-size: 10px;
color: var(--df-warning);
}
.ai-boundary-loadfail {
@@ -328,7 +332,7 @@ const echoSummary = computed(() => {
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
font-size: 10px;
color: var(--df-danger);
}
.ai-boundary-row {
@@ -350,7 +354,7 @@ const echoSummary = computed(() => {
color: var(--df-text-dim);
white-space: nowrap;
}
.ai-boundary-unlimited input { accent-color: var(--df-accent); margin: 0; }
.ai-boundary-unlimited input { accent-color: var(--df-info); margin: 0; }
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
.ai-boundary-input {
flex: 1;
@@ -363,7 +367,7 @@ const echoSummary = computed(() => {
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-info) 40%, transparent); }
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-input--num { flex: 0 0 90px; }
.ai-boundary-mini-btn {
@@ -413,10 +417,11 @@ const echoSummary = computed(() => {
align-items: center;
gap: 8px;
}
/* 一次授权钮:info 系透明底描边钮(对应色操作钮范式) */
.ai-boundary-confirm {
border: none;
background: var(--df-accent);
color: #fff;
border: 0.5px solid color-mix(in srgb, var(--df-info) 40%, transparent);
background: color-mix(in srgb, var(--df-info) 10%, transparent);
color: var(--df-info);
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
@@ -424,7 +429,7 @@ const echoSummary = computed(() => {
border-radius: calc(var(--df-radius) - 2px);
transition: background 0.15s var(--df-ease);
}
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
.ai-boundary-confirm:hover:not(:disabled) { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-boundary-clear {
border: 0.5px solid var(--df-border);
@@ -442,7 +447,7 @@ const echoSummary = computed(() => {
.ai-boundary-field.is-error span { color: var(--df-danger); }
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
.ai-boundary-dirty {
font-size: 10.5px;
font-size: 10px;
color: var(--df-warning);
}
</style>
+20 -17
View File
@@ -99,15 +99,16 @@ async function handleStopArchive(): Promise<void> {
</script>
<style scoped>
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(非正常终局停靠=需用户拍板信号)——
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
.ai-task-parked {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
border-radius: var(--df-radius);
}
.ai-task-parked-head {
@@ -115,37 +116,39 @@ async function handleStopArchive(): Promise<void> {
align-items: center;
gap: 6px;
}
/* stop_kind 标签:透明 danger 描边 chip;fatal 连续失败重入耗尽=实底警示级最高 */
.ai-task-parked-tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: calc(var(--df-radius) - 2px);
background: var(--df-warning);
color: #000;
background: color-mix(in srgb, var(--df-danger) 12%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
color: var(--df-danger);
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.ai-task-parked-tag--fatal {
background: var(--df-danger);
border-color: var(--df-danger);
color: #fff;
}
.ai-task-parked-title {
font-size: 11.5px;
font-size: 12px;
font-weight: 600;
color: var(--df-warning);
color: var(--df-danger);
}
.ai-task-parked-reason {
font-size: 11px;
color: var(--df-text-dim);
opacity: 0.9;
font-size: 12px;
color: var(--df-text-secondary);
word-break: break-word;
white-space: pre-wrap;
}
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
.ai-task-parked-preserved {
font-size: 10.5px;
font-size: 11px;
color: var(--df-text-dim);
opacity: 0.8;
opacity: 0.85;
}
.ai-task-parked-actions {
display: flex;
@@ -155,17 +158,17 @@ async function handleStopArchive(): Promise<void> {
margin-top: 2px;
}
.ai-task-parked-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
color: var(--df-danger);
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
}
.ai-task-parked-btn:hover { filter: brightness(1.1); }
.ai-task-parked-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-task-parked-btn--muted {
background: transparent;
+109 -78
View File
@@ -14,7 +14,7 @@
<button
v-if="pendingApprovalCount > 0"
type="button"
class="ai-approval-badge"
class="ai-approval-badge df-badge df-badge--danger"
:title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')"
>
@@ -24,7 +24,7 @@
<button
v-if="unattendedStatus?.breaker_tripped"
type="button"
class="ai-unattended-badge ai-unattended-badge--tripped"
class="ai-unattended-badge df-badge df-badge--danger"
:title="$t('aiChat.unattendedResetHint')"
@click="resetUnattendedBreaker"
>
@@ -33,7 +33,7 @@
<button
v-else-if="unattendedStatus?.enabled"
type="button"
class="ai-unattended-badge"
class="ai-unattended-badge df-badge df-badge--accent"
:title="$t('aiChat.unattendedBadgeHint')"
@click="jumpToUnattendedSettings"
>
@@ -50,7 +50,7 @@
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
@click="cycleProvider"
>
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="df-dot provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</button>
<div class="ai-provider-bar ai-provider-empty" v-else>
@@ -182,7 +182,7 @@
</div>
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) -->
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
<button type="button" class="ai-running-tasks-badge ai-tool-badge" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
<button type="button" class="ai-running-tasks-badge ai-tool-badge df-badge df-badge--info" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
<span class="ai-running-tasks-pulse"></span>
{{ runningTasks.length }}
</button>
@@ -236,6 +236,8 @@
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
</div>
<!-- F6-1:目标消息在折叠段内未渲染,面板不收 + 行内提示 -->
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
</div>
</Transition>
</div>
@@ -251,6 +253,8 @@
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
<!-- F6-1:摘要项同享定位未命中提示(压缩段折叠时 system 消息同样不在 DOM) -->
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
</div>
</Transition>
</div>
@@ -413,6 +417,7 @@ onMounted(() => { void refreshUnattended() })
onBeforeUnmount(() => {
unattendedDisposed = true
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
if (locateMissTimer) { clearTimeout(locateMissTimer); locateMissTimer = null }
})
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
@@ -768,8 +773,23 @@ const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
const historyExpanded = ref(false)
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
// F6-1(走查-信息UX 面六):目标消息在折叠段(已归档/已压缩)内时,折叠态不渲染
// [data-msg-id] 元素,querySelector 必空。原实现先收面板再查 DOM,未命中即静默无反应;
// 现改为面板保持展开 + 行内提示引导展开分隔条。展开折叠段的状态在 MessageList 内持有,
// 本组件不可达,提示是最小闭环(不跨组件撬状态)。
const locateMissHint = ref('')
let locateMissTimer: ReturnType<typeof setTimeout> | null = null
function showLocateMiss(): void {
locateMissHint.value = t('aiChat.historyLocateInSegment')
if (locateMissTimer) clearTimeout(locateMissTimer)
locateMissTimer = setTimeout(() => {
locateMissHint.value = ''
locateMissTimer = null
}, 5000)
}
function scrollToMessage(msgId: string) {
historyExpanded.value = false
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
// msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
@@ -787,8 +807,13 @@ function scrollToMessage(msgId: string) {
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
}
if (el) {
locateMissHint.value = ''
historyExpanded.value = false
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
// 未命中:不收面板,提示替代静默
showLocateMiss()
}
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
@@ -824,6 +849,7 @@ const contextExpanded = ref(false)
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
locateMissHint.value = ''
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
const willOpen = !refs[panel].value
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
@@ -965,56 +991,37 @@ onBeforeUnmount(() => {
white-space: nowrap;
max-width: 200px;
}
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡;div→button 后归零默认样式) */
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
.ai-approval-badge {
display: inline-flex;
align-items: center;
cursor: pointer;
background: var(--df-warning-bg);
color: var(--df-warning);
padding: 2px 8px;
border-radius: var(--df-radius-sm);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
transition: opacity 0.15s var(--df-ease);
font-family: inherit;
appearance: none;
/* review ③:div→button 后 UA outset 边框需显式归零(appearance:none 不含 border) */
border: none;
}
.ai-approval-badge:hover {
opacity: 0.85;
}
.ai-approval-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
/* 挂机引擎态徽章(开启=accent 弱底;熔断=红底红字,点击复位) */
/* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
.ai-unattended-badge {
display: inline-flex;
align-items: center;
cursor: pointer;
background: var(--df-accent-bg);
color: var(--df-accent);
padding: 2px 8px;
border-radius: var(--df-radius-sm);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
font-family: inherit;
appearance: none;
border: none;
transition: opacity 0.15s var(--df-ease);
}
.ai-unattended-badge:hover {
opacity: 0.85;
}
.ai-unattended-badge--tripped {
background: var(--df-danger-bg);
color: var(--df-danger);
border: 0.5px solid var(--df-danger);
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
@@ -1053,25 +1060,28 @@ onBeforeUnmount(() => {
display: flex;
gap: 4px;
}
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)── */
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)
2026-10-18 wave2:统一 workbench .hd-btn 范式——bg-card 底+边框+md 圆角+hover 边框亮 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
background: var(--df-bg-card);
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
@@ -1080,7 +1090,8 @@ onBeforeUnmount(() => {
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
background: transparent;
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
@@ -1094,7 +1105,7 @@ onBeforeUnmount(() => {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
box-shadow: var(--df-shadow-sm);
z-index: 100;
display: flex;
flex-direction: column;
@@ -1113,16 +1124,13 @@ onBeforeUnmount(() => {
cursor: pointer;
white-space: nowrap;
}
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-hover, rgba(0,0,0,0.06)); color: var(--df-text); }
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
/* Transition:淡入 + 下滑 */
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
/* 压缩中 spinner 图标旋转动画(@keyframes ai-btn-spin 在父级 AiChat.vue 全局定义,
scoped 不重命名 @keyframes,此处按名引用) */
.ai-btn-spinner {
animation: ai-btn-spin 0.9s linear infinite;
}
/* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
/* 底部工具区(标题左 + 工具右) */
.ai-bottom-tools {
display: flex;
@@ -1192,6 +1200,10 @@ onBeforeUnmount(() => {
.ai-task-progress--warn .ai-task-progress-dot {
animation-duration: 3.2s;
}
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
.ai-task-progress--warn .ai-task-progress-dot {
background: var(--df-warning);
}
.ai-task-progress-stage {
font-size: 11px;
color: var(--df-text-dim);
@@ -1200,39 +1212,45 @@ onBeforeUnmount(() => {
font-size: 11px;
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
}
/* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
/* ═══ Provider Bar (header 中间区)
2026-10-18 wave2:对齐 workbench .session-status pill 范式——bg-card 底+边框+8px 圆角+12px 字,
状态点迁 .df-dot(7px 尺寸由全局公共类提供);div→button 后归零默认样式 ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
gap: 6px;
font-size: 12px;
color: var(--df-text-secondary);
white-space: nowrap;
font-family: inherit;
appearance: none;
background: none;
border: none;
padding: 0;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
padding: 4px 12px;
transition: border-color 0.15s var(--df-ease);
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
color: var(--df-text-primary);
border-color: var(--df-border-strong);
color: var(--df-text);
}
.ai-provider-bar--switchable:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline: 1.5px solid var(--df-accent);
outline-offset: 2px;
border-radius: 2px;
border-radius: var(--df-radius-md);
}
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; }
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError);
尺寸/圆角走全局 .df-dot,此处只管色 */
.provider-dot { flex-shrink: 0; transition: background .2s; background: var(--df-text-dim); }
.provider-dot--ok { background: var(--df-success); }
.provider-dot--error { background: var(--df-danger); }
.provider-name {
font-size: 11px;
font-size: 12px;
font-family: var(--df-font-mono);
color: var(--df-text-dim);
color: var(--df-text-secondary);
}
.ai-provider-empty {
background: var(--df-danger-bg);
@@ -1265,7 +1283,7 @@ onBeforeUnmount(() => {
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent, #4a9eff);
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
@@ -1291,11 +1309,17 @@ onBeforeUnmount(() => {
align-items: center;
gap: 4px;
}
/* wave2:后台会话徽章迁 .df-badge--info(透明彩底 info 系);scoped 需显式覆写压过 .ai-tool-badge 灰底 */
.ai-running-tasks-badge.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-running-tasks-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success, #34c77b);
background: var(--df-success);
animation: ai-running-pulse 2s ease-in-out infinite;
}
@keyframes ai-running-pulse {
@@ -1311,9 +1335,9 @@ onBeforeUnmount(() => {
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg);
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
}
@@ -1331,14 +1355,14 @@ onBeforeUnmount(() => {
color: var(--df-text);
}
.ai-running-task-item:hover {
background: var(--df-hover, rgba(128,128,128,0.08));
background: var(--df-bg-card-hover);
}
.ai-running-task-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success, #34c77b);
background: var(--df-success);
}
.ai-running-task-title {
flex: 1;
@@ -1349,7 +1373,7 @@ onBeforeUnmount(() => {
.ai-running-task-round {
flex-shrink: 0;
font-size: 11px;
color: var(--df-text-secondary, #888);
color: var(--df-text-secondary);
}
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
.ai-running-tasks-sep {
@@ -1358,7 +1382,7 @@ onBeforeUnmount(() => {
background: var(--df-border);
}
.ai-running-task-dot--parked {
background: var(--df-warning, #e6a23c);
background: var(--df-warning);
}
/* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
.ai-running-task-item--parked {
@@ -1385,21 +1409,21 @@ onBeforeUnmount(() => {
color: var(--df-text);
}
.ai-running-task-main:hover {
background: var(--df-hover, rgba(128,128,128,0.08));
background: var(--df-bg-card-hover);
}
.ai-running-task-recover {
flex-shrink: 0;
margin-right: 10px;
padding: 2px 8px;
border: 1px solid var(--df-warning, #e6a23c);
border-radius: 4px;
border: 1px solid var(--df-warning);
border-radius: var(--df-radius-sm);
background: none;
cursor: pointer;
font-size: 11px;
color: var(--df-warning, #e6a23c);
color: var(--df-warning);
}
.ai-running-task-recover:hover {
background: var(--df-warning, #e6a23c);
background: var(--df-warning);
color: #fff;
}
.ai-history-inline-list {
@@ -1411,9 +1435,9 @@ onBeforeUnmount(() => {
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg);
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
@@ -1454,6 +1478,13 @@ onBeforeUnmount(() => {
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
.ai-locate-miss {
padding: 6px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--df-warning);
}
/* ═══ 上下文摘要(底部) ═══ */
.ai-summary-inline {
@@ -1470,9 +1501,9 @@ onBeforeUnmount(() => {
max-width: 420px;
max-height: 360px;
overflow-y: auto;
background: var(--df-bg);
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 6px 0;
margin-top: 2px;
@@ -1518,9 +1549,9 @@ onBeforeUnmount(() => {
z-index: 100;
min-width: 280px;
max-width: 420px;
background: var(--df-bg);
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 2px;
}
@@ -1571,9 +1602,9 @@ onBeforeUnmount(() => {
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg, #fff);
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
@@ -1601,7 +1632,7 @@ onBeforeUnmount(() => {
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
.ai-goal-remove:hover {
color: var(--df-danger, #e5484d);
color: var(--df-danger);
}
.provider-hint {
font-size: 11px;
@@ -0,0 +1,913 @@
<template>
<!-- ═══ 会话列表体(拆自 ConversationSidebar.vue C4a,零行为变更) ═══ -->
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
<template v-if="searchResults">
<div
v-for="conv in searchResults"
:key="'s-' + conv.id"
class="ai-conv-item"
:class="{
'ai-conv-item--active': conv.id === store.state.activeConversationId,
'ai-conv-item--switching': conv.id === pendingSwitchId,
'ai-conv-item--archived': conv.archived,
'ai-conv-item--pinned': conv.pinned,
}"
tabindex="0"
@click="onSelectSearchResult(conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
<input
v-if="editingConvId === conv.id"
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keydown="onRenameKeydown($event, conv.id)"
@blur="commitRename(conv.id)"
@click.stop
/>
<span
v-else
class="ai-conv-item-title"
:title="conv.title || t('aiChat.doubleClickToRename')"
@dblclick.stop="startRename(conv)"
>
{{ conv.title || t('aiChat.newConversation') }}
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
<div v-if="searchResults.length === 0" class="ai-conv-empty">
{{ t('aiChat.searchEmpty') }}
</div>
</template>
<!-- 非搜索态:原时间分组(活跃) + 归档分组 -->
<template v-else>
<!-- 活跃对话:按 今天/昨天/更早 分组 -->
<template v-for="group in groupedActive" :key="'g-' + group.key">
<div
class="ai-conv-group-title ai-conv-group-title--fold"
role="button"
tabindex="0"
:aria-expanded="!store.state.foldedGroups[group.key]"
@click="store.toggleGroupFold(group.key)"
@keydown.enter.prevent="store.toggleGroupFold(group.key)"
@keydown.space.prevent="store.toggleGroupFold(group.key)"
>
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }">▸</span>
{{ group.label }}
<span class="ai-conv-group-count">({{ group.items.length }})</span>
</div>
<div
v-if="group.key === 'today' && !group.items.length"
v-show="!store.state.foldedGroups[group.key]"
class="ai-conv-group-empty"
>{{ t('aiChat.groupEmpty') }}</div>
<div
v-for="conv in group.items"
v-show="!store.state.foldedGroups[group.key]"
:key="conv.id"
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id, true)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<input
v-if="editingConvId === conv.id"
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keydown="onRenameKeydown($event, conv.id)"
@blur="commitRename(conv.id)"
@click.stop
/>
<span
v-else
class="ai-conv-item-title"
:class="{ 'ai-conv-item-title--flash': titleFlash && conv.id === store.state.activeConversationId }"
@dblclick.stop="startRename(conv)"
:title="conv.title || t('aiChat.doubleClickToRename')"
>
{{ conv.title || t('aiChat.newConversation') }}
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
<svg aria-hidden="true"
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><polyline points="20 6 9 17 4 12"/></svg>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
{{ t('aiChat.archive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
</template>
<!-- G5 可恢复分组(loop_state='parked',断点续跑闭环读侧;置于活跃分组后、归档前) -->
<template v-if="recoverable.length">
<div class="ai-conv-group-title">
<span class="ai-conv-recover-dot" aria-hidden="true"></span>
{{ t('aiChat.recoverableCount', { n: recoverable.length }) }}
</div>
<div
v-for="conv in recoverable"
:key="'r-' + conv.id"
class="ai-conv-item ai-conv-item--recoverable"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
tabindex="0"
:title="t('aiChat.recoverableHint')"
@click="onRecover(conv.id)"
@keydown.enter.prevent="onRecover(conv.id)"
@keydown.space.prevent="onRecover(conv.id)"
>
<div class="ai-conv-item-main">
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
<span class="ai-conv-item-title">
{{ conv.title || t('aiChat.newConversation') }}
<span class="ai-conv-recover-badge">⏸</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
</div>
</template>
<!-- 归档分组(可折叠) -->
<template v-if="archivedConvs.length">
<div
class="ai-conv-group-title ai-conv-group-title--fold"
role="button"
tabindex="0"
:aria-expanded="!store.state.archivedCollapsed"
@click="store.toggleArchivedFold()"
@keydown.enter.prevent="store.toggleArchivedFold()"
@keydown.space.prevent="store.toggleArchivedFold()"
>
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.archivedCollapsed }">▸</span>
{{ t('aiChat.archivedCount', { n: archivedConvs.length }) }}
</div>
<div
v-for="conv in archivedConvs"
v-show="!store.state.archivedCollapsed"
:key="'a-' + conv.id"
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item ai-conv-item--archived"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
>
<div class="ai-conv-item-main">
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
<span
class="df-dot ai-conv-status"
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
aria-hidden="true"
></span>
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span>
</span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
<div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
{{ t('aiChat.unarchive') }}
</button>
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
</template>
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
<div
v-if="convListPaging.hasMore"
class="ai-conv-load-more"
role="button"
tabindex="0"
@click="onLoadMore"
@keydown.enter.prevent="onLoadMore"
@keydown.space.prevent="onLoadMore"
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
{{ t('aiChat.emptyConversation') }}
</div>
</template>
</template>
<script setup lang="ts">
/**
* 会话列表体 — 拆自 ConversationSidebar.vue(C4a 大文件拆分,零行为变更)。
*
* 职责:侧栏列表两态渲染(搜索态平铺 / 时间分组+可恢复+归档)及项级自治状态:
* 重命名(editingConvId/editText)、更多操作菜单(actionsOpenId + document 级外部点击/Esc 关闭)、
* 切换过渡(pendingSwitchId)、项 DOM ref 收集(M3 展开折叠组后 scrollIntoView)、
* 标题生成淡入(titleFlash)、时间分组归桶(groupedActive/archivedConvs)。
*
* 跨文件依赖(策略同侧栏第一批:store 共享走 useAiStore,不 props 传 store):
* - props.pendingCountOf:V1 待审批计数(主文件 computed + 2.5s 轮询兜底锚定,函数透传保响应式);
* - props.recoverable:G5 可恢复清单(主文件 onMounted 拉取,侧栏开合不重复 IPC),恢复动作 emit('recover');
* - emit('confirm-delete'):删除需父级 ConfirmDialog,侧栏主文件继续上抛 AiChat。
* 边界徽标只读消费 getConvBoundary(reactive Map,模板内读取可追踪),直接 import 同源 composable。
*/
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { getConvBoundary } from '../../../composables/ai/useTaskBoundary'
import { convListPaging, loadMoreConversations } from '../../../composables/ai/useAiConversations'
import { formatRelative } from '../../../utils/time'
import type { AiConversationSummary } from '../../../api/types'
/** G5 可恢复会话条目(主文件数据源透传,字段口径同 aiApi.listRecoverable) */
interface RecoverableConv { id: string; title: string | null; model: string | null; updated_at: string }
defineProps<{
/** V1 跨会话待审批计数(主文件 computed:三响应式源锚定 + 轮询兜底;函数透传,模板调用可追踪) */
pendingCountOf: (convId: string) => number
/** G5 可恢复分组数据(主文件拉取与防重,本组件纯渲染 + emit 恢复动作) */
recoverable: RecoverableConv[]
}>()
const emit = defineEmits<{
/** 删除对话(需二次确认),上抛侧栏主文件继续交 AiChat 弹 ConfirmDialog */
(e: 'confirm-delete', id: string): void
/** G5 恢复 parked 会话,主文件持 recoveringId 防重 + toast 反馈 */
(e: 'recover', id: string): void
}>()
const { t } = useI18n()
const store = useAiStore()
// ── 标题生成淡入(UX-2025-20 §7.4):监听当前对话标题从空→非空触发 ──
const titleFlash = ref(false)
let _titleFlashTimer: ReturnType<typeof setTimeout> | null = null
const activeConv = computed(() =>
store.state.conversations.find(c => c.id === store.state.activeConversationId) || null,
)
watch(
() => activeConv.value?.title,
(newTitle, oldTitle) => {
// 标题从空→非空才触发淡入,其他变化不闪
if (!newTitle || oldTitle) return
titleFlash.value = true
if (_titleFlashTimer) clearTimeout(_titleFlashTimer)
_titleFlashTimer = setTimeout(() => { titleFlash.value = false }, 320)
},
)
// ── 对话项 inline 重命名 ──
const editingConvId = ref<string | null>(null)
const editText = ref('')
let editEl: HTMLInputElement | null = null
function setEditEl(el: Element | { $el?: Element } | null): void {
editEl = (el as HTMLInputElement) || null
}
function startRename(conv: { id: string; title: string | null }) {
editingConvId.value = conv.id
editText.value = conv.title || ''
nextTick(() => {
editEl?.focus()
editEl?.select()
})
}
async function commitRename(id: string) {
const title = editText.value.trim()
if (title && editingConvId.value === id) {
await store.renameConversation(id, title)
}
editingConvId.value = null
}
function cancelRename() {
editingConvId.value = null
}
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
function onRenameKeydown(e: KeyboardEvent, convId: string) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
commitRename(convId)
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
beginSwitch(convId)
} else if (renamable && e.key === 'F2') {
e.preventDefault()
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) startRename(conv)
}
}
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref<string | null>(null)
function toggleActionsMenu(id: string) {
actionsOpenId.value = actionsOpenId.value === id ? null : id
}
function closeActionsMenu() {
actionsOpenId.value = null
}
// 点击外部关闭更多操作菜单
function onActionsOutsideClick(e: MouseEvent) {
if (!actionsOpenId.value) return
const target = e.target as HTMLElement | null
if (target?.closest('.ai-conv-item-more')) return
actionsOpenId.value = null
}
// UX走查 P2-6: 操作菜单原仅外部点击可关,键盘用户开菜单后无 Esc 逃逸;IME 守卫防组词取消误关
function onActionsEscClose(e: KeyboardEvent) {
if (!actionsOpenId.value) return
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Escape') actionsOpenId.value = null
}
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
const pendingSwitchId = ref<string | null>(null)
function beginSwitch(id: string) {
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
if (id === store.state.activeConversationId) {
void store.switchConversation(id)
return
}
pendingSwitchId.value = id
void store.switchConversation(id).finally(() => {
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
if (pendingSwitchId.value === id) pendingSwitchId.value = null
})
}
/**
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
*/
function onSelectSearchResult(id: string) {
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
beginSwitch(id)
store.state.searchQuery = ''
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
void nextTick(() => ensureActiveVisible())
}
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
const itemEls = new Map<string, Element>()
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
if (!el) {
itemEls.delete(id)
return
}
// 组件实例取 $el,但本项是普通 div(el 即 Element)
itemEls.set(id, el as Element)
}
/**
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
*/
function ensureActiveVisible(): void {
const id = store.state.activeConversationId
if (!id) return
const conv = store.state.conversations.find(c => c.id === id)
if (!conv) return
// 先按归档态/时间桶决定应展开哪个组折叠态
if (conv.archived) {
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
} else {
const bucket = timeBucket(conv.updated_at)
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
}
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
void nextTick(() => {
const el = itemEls.get(id)
if (el) {
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
function hasBoundary(convId: string): boolean {
return getConvBoundary(convId) !== null
}
// ── 对话分组:今天 / 昨天 / 更早 ──
const bucketLabels = computed<Record<string, string>>(() => ({
today: t('aiChat.today'),
yesterday: t('aiChat.yesterday'),
earlier: t('aiChat.earlier'),
}))
function timeBucket(ts: string): 'today' | 'yesterday' | 'earlier' {
const t = parseInt(ts, 10)
if (!Number.isFinite(t)) return 'earlier'
const d = new Date(t)
const now = new Date()
const sameDay = d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
if (sameDay) return 'today'
const yest = new Date(now)
yest.setDate(now.getDate() - 1)
if (d.getFullYear() === yest.getFullYear()
&& d.getMonth() === yest.getMonth()
&& d.getDate() === yest.getDate()) return 'yesterday'
return 'earlier'
}
const groupedActive = computed(() => {
const map = new Map<string, AiConversationSummary[]>()
for (const c of store.state.conversations) {
if (c.archived) continue
const b = timeBucket(c.updated_at)
if (!map.has(b)) map.set(b, [])
map.get(b)!.push(c)
}
for (const items of map.values()) {
items.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|| Number(b.updated_at) - Number(a.updated_at))
}
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
for (const key of ['today', 'yesterday', 'earlier']) {
const items = map.get(key) || []
if (key === 'today' || items.length) groups.push({ key, label: bucketLabels.value[key], items })
}
return groups
})
const archivedConvs = computed(() =>
store.state.conversations
.filter(c => c.archived)
.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|| Number(b.updated_at) - Number(a.updated_at)),
)
const searchResults = computed<AiConversationSummary[] | null>(
() => store.filteredConversations.value,
)
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
function onLoadMore(): void {
void loadMoreConversations()
}
/** G5 恢复动作透传主文件(主文件持 recoveringId 防重与 toast 反馈),模板零改动 */
function onRecover(id: string) {
emit('recover', id)
}
onMounted(() => {
// 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick)
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
document.addEventListener('keydown', onActionsEscClose)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick)
document.removeEventListener('keydown', onActionsEscClose)
// 释放标题淡入计时器
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
})
</script>
<!-- ═══ 会话列表体 scoped 样式(拆自 ConversationSidebar.vue,零视觉变更;列表滚动容器
.ai-conv-sidebar/.ai-conv-list 等壳层样式留主文件,本组件只持项/分组/菜单/徽标层) ═══ -->
<style scoped>
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
.ai-conv-group-title {
padding: 10px 8px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--df-text-dim);
user-select: none;
}
.ai-conv-group-title--fold {
cursor: pointer;
display: flex;
align-items: center;
gap: 3px;
}
.ai-conv-group-title--fold:hover {
color: var(--df-text);
}
.ai-conv-group-arrow {
display: inline-block;
transition: transform 0.15s;
font-size: 9px;
}
.ai-conv-group-arrow.is-folded {
transform: rotate(0deg);
}
.ai-conv-group-arrow:not(.is-folded) {
transform: rotate(90deg);
}
.ai-conv-group-count {
margin-left: 2px;
font-weight: 400;
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
font-size: 10px;
letter-spacing: 0;
opacity: 0.9;
}
.ai-conv-group-empty {
padding: 2px 8px 4px 20px;
font-size: 10px;
color: var(--df-text-dim);
font-style: italic;
}
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
.ai-conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: var(--df-radius);
cursor: pointer;
transition: background 0.15s;
gap: 6px;
}
.ai-conv-item:hover {
background: var(--df-bg-card-hover);
}
.ai-conv-item--active {
background: var(--df-bg-card-hover);
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
.ai-conv-item--switching {
background: var(--df-bg-card-hover);
opacity: 0.7;
}
.ai-conv-item--archived .ai-conv-item-title {
opacity: 0.6;
}
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
.ai-conv-item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.ai-conv-item-title {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--df-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 3px;
}
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
.ai-conv-item-title--flash {
animation: ai-title-flash 0.3s var(--df-ease);
}
.ai-conv-item-title-badge {
flex-shrink: 0;
color: var(--df-success);
}
@keyframes ai-title-flash {
from { opacity: 0; transform: translateX(-3px); }
to { opacity: 1; transform: translateX(0); }
}
.ai-conv-item-edit {
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
min-width: 0;
width: auto;
font-size: 11px;
color: var(--df-text);
height: 18px;
padding: 0 4px;
border: 1px solid var(--df-accent);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
outline: none;
box-sizing: border-box;
}
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
.ai-conv-item-time {
flex: none;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
.ai-conv-status--danger { background: var(--df-danger); }
.ai-conv-status--paused { background: var(--df-warning); }
@keyframes ai-conv-gen-pulse {
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
/* G5 可恢复分组(parked 会话,amber 系提示「需注意/可继续」语义;⏸ 徽标+分组标题小圆点) */
.ai-conv-recover-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 5px;
background: var(--df-warning);
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
}
.ai-conv-item--recoverable {
cursor: pointer;
}
.ai-conv-recover-badge {
flex-shrink: 0;
margin-left: 2px;
font-size: 10px;
line-height: 1;
color: var(--df-warning);
cursor: help;
}
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
.ai-conv-approve-badge { flex-shrink: 0;
min-width: 12px;
height: 12px;
padding: 0 3px;
border-radius: 6px;
background: var(--df-warning);
color: #000;
font-size: 9px;
font-weight: 600;
line-height: 12px;
text-align: center;
cursor: help;
}
/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */
.ai-conv-boundary-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
color: var(--df-accent);
cursor: help;
}
.ai-conv-item-actions {
display: none;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.ai-conv-item:hover .ai-conv-item-actions {
display: flex;
}
/* UX走查 P2-6: 键盘 Tab 进会话项(项自身 tabindex=0,或焦点在项内按钮/输入框)时同样浮出
操作按钮——原仅 :hover 渲染,键盘用户永远够不到「...」菜单触发点 */
.ai-conv-item:focus-within .ai-conv-item-actions {
display: flex;
}
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
display: flex;
}
.ai-conv-item-act {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
flex-shrink: 0;
}
.ai-conv-item-act:hover {
background: var(--df-sidebar-hover);
color: var(--df-text);
}
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
}
.ai-conv-item--active.ai-conv-item--pinned {
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* ── 「更多」操作菜单(置顶/归档/删除)──
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
.ai-conv-item-more {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.ai-conv-actions-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 2px;
z-index: 20;
min-width: 104px;
padding: 2px;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--df-bg);
border: 1px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.ai-conv-actions-opt {
display: flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
text-align: left;
padding: 5px 8px;
font-size: 11px;
color: var(--df-text);
cursor: pointer;
border-radius: var(--df-radius-sm);
white-space: nowrap;
}
.ai-conv-actions-opt:hover {
background: var(--df-sidebar-hover);
}
.ai-conv-actions-opt--danger:hover {
background: var(--df-danger-bg);
color: var(--df-danger);
}
.ai-conv-empty {
padding: 16px 8px;
text-align: center;
font-size: 11px;
color: var(--df-text-dim);
}
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
.ai-conv-load-more {
margin: 4px 2px;
padding: 6px 8px;
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
text-align: center;
cursor: pointer;
user-select: none;
}
.ai-conv-load-more:hover {
background: var(--df-bg-card-hover);
color: var(--df-text);
}
</style>
+50 -62
View File
@@ -114,7 +114,7 @@
</div>
</div>
<div v-else class="eval-report eval-report--muted">
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
<button class="btn btn-primary" :disabled="evaluating" @click="$emit('evaluate')">
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
</button>
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
@@ -690,7 +690,7 @@ watch(() => props.idea.id, loadHistory)
gap: 12px;
margin-bottom: var(--df-gap-head);
}
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
.detail-title { font-size: var(--df-fs-title); font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
/* P0-② 标题行右侧操作组:立项按钮 + 状态 badge 一行排布 */
.detail-header-actions {
display: flex;
@@ -752,7 +752,7 @@ watch(() => props.idea.id, loadHistory)
padding: 14px 16px;
background: var(--df-accent-bg);
border-left: 3px solid var(--df-accent);
border-radius: 0 8px 8px 0;
border-radius: 0 var(--df-radius-md) var(--df-radius-md) 0;
}
/* 弱化态(空数据/加载中占位,原 inline opacity:0.5 统一为 class) */
.eval-report--muted { opacity: 0.5; }
@@ -764,47 +764,48 @@ watch(() => props.idea.id, loadHistory)
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 1rem;
margin-bottom: 1rem;
padding: 13px;
margin-bottom: 13px;
}
.debate-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
gap: 13px;
margin-bottom: 20px;
}
.debate-column {
padding: 1rem;
padding: 13px;
border-radius: var(--df-radius);
position: relative;
}
/* 正反方论据栏底/边走语义 token(双主题自适应) */
.debate-column.positive {
background: rgba(61, 219, 160, 0.08);
border: 0.5px solid rgba(61, 219, 160, 0.2);
background: var(--df-success-bg);
border: 0.5px solid var(--df-success-bd);
}
.debate-column.negative {
background: rgba(240, 101, 101, 0.08);
border: 0.5px solid rgba(240, 101, 101, 0.2);
background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger-bd);
}
.debate-column h4 {
font-size: 14px;
font-weight: 500;
margin: 0 0 0.5rem;
margin: 0 0 7px;
display: flex;
align-items: center;
gap: 0.5rem;
gap: 7px;
}
.confidence-bar {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--df-radius-xs);
margin-bottom: 0.25rem;
margin-bottom: 3px;
overflow: hidden;
}
@@ -820,13 +821,13 @@ watch(() => props.idea.id, loadHistory)
.confidence-text {
font-size: 11px;
color: var(--df-text-dim);
margin-bottom: 0.5rem;
margin-bottom: 7px;
}
.debate-column .thesis {
font-size: 12px;
color: var(--df-text);
margin-bottom: 0.5rem;
margin-bottom: 7px;
font-weight: 500;
}
@@ -839,16 +840,16 @@ watch(() => props.idea.id, loadHistory)
.debate-column li {
font-size: 11px;
color: var(--df-text-secondary);
margin-bottom: 0.25rem;
padding-left: 0.5rem;
margin-bottom: 3px;
padding-left: 7px;
}
.analyst-conclusion {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 1rem;
margin-bottom: 1rem;
padding: 13px;
margin-bottom: 13px;
}
/* assessment-badge 系列已提取到 styles/components.css 全局 */
@@ -857,39 +858,39 @@ watch(() => props.idea.id, loadHistory)
font-size: 14px;
font-weight: 500;
color: var(--df-text);
margin: 0.5rem 0;
margin: 7px 0;
}
.net-sentiment {
font-size: 12px;
padding: 4px 8px;
font-size: var(--df-fs-ctrl);
padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs);
display: inline-block;
margin: 0.5rem 0;
margin: 7px 0;
}
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
.net-sentiment.positive { background: var(--df-success-bg); color: var(--df-success); }
.net-sentiment.negative { background: var(--df-danger-bg); color: var(--df-danger); }
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
.summary {
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 0.5rem 0;
margin: 7px 0;
}
.action-recommendations {
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 1rem;
padding: 13px;
}
.action-recommendations h4 {
font-size: 14px;
font-weight: 500;
margin: 0 0 0.5rem;
margin: 0 0 7px;
}
.action-recommendations ul {
@@ -901,12 +902,12 @@ watch(() => props.idea.id, loadHistory)
.action-recommendations li {
font-size: 12px;
color: var(--df-text-secondary);
margin-bottom: 0.25rem;
padding-left: 0.5rem;
margin-bottom: 3px;
padding-left: 7px;
}
/* P0-① 详细论据折叠切换条 */
.debate-toggle-row { margin-bottom: 0.75rem; }
.debate-toggle-row { margin-bottom: 10px; }
.debate-toggle-btn {
background: transparent;
border: none;
@@ -924,23 +925,10 @@ watch(() => props.idea.id, loadHistory)
transition: transform 0.15s;
}
.debate-toggle-arrow.is-open { transform: rotate(180deg); }
.debate-detail { margin-top: 0.5rem; }
.debate-detail .debate-container { margin-bottom: 0.75rem; }
.debate-detail { margin-top: 7px; }
.debate-detail .debate-container { margin-bottom: 10px; }
.btn-evaluate {
background: var(--df-accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: var(--df-radius);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-evaluate:hover {
background: var(--df-accent-hover);
}
/* 「开始评估」按钮并轨全局 .btn .btn-primary(30px 主档),本地副本删除 */
/* P1-⑥ 重试样式:错误提示与重试按钮分行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) */
.eval-error {
@@ -952,11 +940,11 @@ watch(() => props.idea.id, loadHistory)
.btn-retry { margin-top: 4px; }
.eval-mode-tag {
font-size: 11px;
font-size: var(--df-fs-aux);
font-weight: 400;
padding: 1px 8px;
padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.15);
background: var(--df-warning-bg);
color: var(--df-warning);
margin-left: 6px;
vertical-align: middle;
@@ -991,26 +979,26 @@ watch(() => props.idea.id, loadHistory)
font-size: 12px;
}
.version-badge {
font-size: 11px;
font-size: var(--df-fs-aux);
font-weight: 500;
padding: 2px 8px;
padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs);
background: var(--df-accent-soft);
color: var(--df-accent);
}
.latest-tag {
font-size: 10px;
font-size: var(--df-fs-meta);
font-weight: 500;
padding: 1px 6px;
padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs);
background: rgba(100, 255, 218, 0.15);
background: var(--df-success-bg);
color: var(--df-success);
}
.history-eval-mode {
font-size: 11px;
padding: 1px 6px;
font-size: var(--df-fs-aux);
padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.12);
background: var(--df-warning-bg);
color: var(--df-warning);
}
.history-score {
@@ -1046,7 +1034,7 @@ watch(() => props.idea.id, loadHistory)
/* ===== 标签 ===== */
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
.tag {
font-size: 12px; padding: 4px 10px;
font-size: var(--df-fs-ctrl); padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs);
background: var(--df-accent-bg);
color: var(--df-accent);
@@ -1055,7 +1043,7 @@ watch(() => props.idea.id, loadHistory)
/* ===== 关联灵感 ===== */
.related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.related-chip {
font-size: 12px; padding: 4px 10px;
font-size: var(--df-fs-ctrl); padding: var(--df-pad-badge);
border-radius: var(--df-radius-xs);
background: var(--df-accent-bg);
color: var(--df-accent);
+9 -8
View File
@@ -400,13 +400,14 @@ watch(renderedContent, () => {
/* 状态徽章 / 置信度 */
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
/* 状态/置信度徽章底统一走语义 token(双主题自适应,与 Knowledge.vue 同规) */
.st-candidate { background: var(--df-warning-bg); color: var(--df-warning); }
.st-pending_review { background: var(--df-info-bg); color: var(--df-info); }
.st-published { background: var(--df-success-bg); color: var(--df-success); }
.st-archived { background: var(--df-neutral-bg); color: var(--df-text-dim); }
.conf-high { background: var(--df-success-bg); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-medium { background: var(--df-warning-bg); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: var(--df-danger-bg); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
/* 溯源 */
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
@@ -439,7 +440,7 @@ watch(renderedContent, () => {
}
.detail-content.content-collapsed::after {
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px;
background: linear-gradient(to bottom, transparent, var(--df-bg-panel, var(--df-bg-card)));
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
pointer-events: none;
}
.detail-content { position: relative; }
+8 -8
View File
@@ -94,7 +94,7 @@ const knowledgeStore = useKnowledgeStore()
const appSettings = useAppSettingsStore()
// ── 侧边栏收缩状态(持久化到 appSettings,小窗口自动收缩) ──
// 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
// 展开 220px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
// 优先级:用户手动设置 > 小窗口自动收缩(≤768px)
const COLLAPSE_THRESHOLD = 768
// useSetting 响应式读(loadAll 异步填充缓存后 watch 自动同步刷新)——
@@ -172,8 +172,8 @@ const secondaryNav = [
<style scoped>
.df-sidebar {
width: 192px;
min-width: 192px;
width: 220px;
min-width: 220px;
background: var(--df-sidebar-bg);
border-right: 0.5px solid var(--df-border);
display: flex;
@@ -219,8 +219,8 @@ const secondaryNav = [
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
padding: 1px 6px;
background: var(--df-hover);
padding: var(--df-pad-chip);
border-radius: var(--df-radius-xs);
}
@@ -323,7 +323,7 @@ const secondaryNav = [
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
padding: var(--df-pad-chip);
border-radius: var(--df-radius);
min-width: 18px;
text-align: center;
@@ -360,8 +360,8 @@ const secondaryNav = [
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
padding: 1px 5px;
background: var(--df-hover);
padding: var(--df-pad-chip);
border-radius: var(--df-radius-sm);
}
+7 -6
View File
@@ -167,7 +167,7 @@ async function onInstall() {
color: var(--df-accent);
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
box-shadow: var(--df-shadow-sm);
transition: filter 0.15s;
}
.update-badge-btn:hover {
@@ -193,7 +193,7 @@ async function onInstall() {
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
background: var(--df-bg-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
box-shadow: var(--df-shadow-sm);
}
.update-pop-head {
font-size: 13px;
@@ -213,11 +213,12 @@ async function onInstall() {
.update-pop-progress {
margin-bottom: 12px;
}
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
.update-pop-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
background: var(--df-neutral-bg);
overflow: hidden;
}
.update-pop-fill {
@@ -234,10 +235,10 @@ async function onInstall() {
.update-pop-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
border-radius: var(--df-radius-md);
background: var(--df-danger-bg);
font-size: 12px;
color: var(--df-danger, #dc3545);
color: var(--df-danger);
line-height: 1.5;
word-break: break-all;
}
+4 -16
View File
@@ -64,7 +64,7 @@
</div>
<!-- M31: 审批失败反馈(store approveHumanApproval 内部 catch 吞错,失败时 pendingApproval 不清空)
对话框保持开启,用户可看到错误并重试 -->
<div v-if="toast.visible" class="ad-toast" :class="'ad-toast-' + toast.type">{{ toast.msg }}</div>
<div v-if="toast.visible" class="df-toast df-toast--bottom" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
</div>
</template>
@@ -162,19 +162,7 @@ watch(() => pendingApproval.value, (newApproval) => {
</script>
<style scoped>
/* M31: 审批失败 toast(scoped 自治,不引入全局样式;沿用 Projects.vue .toast 视觉规范) */
.ad-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
padding: 10px 18px;
border-radius: var(--df-radius-md);
font-size: 13px;
font-weight: 500;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.ad-toast-error { background: var(--df-danger); color: #fff; }
/* M31: 审批失败 toast(样式走全局 .df-toast 族+--bottom 底部变体;
scoped 仅保留 z-index 覆写——本组件浮在弹层(z≈10000)之上,toast 须更高) */
.df-toast { z-index: 10001; }
</style>
+5 -4
View File
@@ -442,7 +442,7 @@ function bindEdgeDeleteButtons() {
graph.on('edge:mouseleave', onEdgeLeave)
}
/** 依赖类型 → 边颜色 */
/** 依赖类型 → 边颜色(X6 画布演示色板·品牌/演示豁免,不入 token) */
function depTypeColor(depType: string): string {
switch (depType) {
case 'api': return '#5378e8'
@@ -490,7 +490,7 @@ onBeforeUnmount(() => {
height: 100%;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
border-radius: var(--df-radius-lg);
overflow: hidden;
position: relative;
}
@@ -593,11 +593,12 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
z-index: 200;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
box-shadow: var(--df-shadow-md);
transition: background 0.15s;
pointer-events: auto;
}
/* hover 由 danger 派生加深,替换孤色 */
.dep-edge-delete-btn:hover {
background: #c24040;
background: color-mix(in srgb, var(--df-danger) 75%, #000);
}
</style>
+18 -68
View File
@@ -488,11 +488,8 @@ onUnmounted(() => { _unlistenDataChanged?.() })
async function onScanSubmodules() {
moduleDropdownOpen.value = false
try {
const n = await moduleApi.scanProjectModules(props.projectId)
await moduleApi.scanProjectModules(props.projectId)
await loadModules()
if (n > 0) {
console.log('[FileExplorer] 扫描到新工程:', n)
}
} catch (e) {
Message.error(t('common.unknownError'))
console.error('[FileExplorer] 扫描子仓库失败:', e)
@@ -594,7 +591,7 @@ async function onRemoveModule() {
max-height: 100%;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
border-radius: var(--df-radius-lg);
overflow: hidden;
}
@@ -625,7 +622,7 @@ async function onRemoveModule() {
padding: 5px 10px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
border-radius: var(--df-radius-sm);
color: var(--df-text);
font-size: 12px;
cursor: pointer;
@@ -651,12 +648,11 @@ async function onRemoveModule() {
max-width: 320px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
border-radius: var(--df-radius-sm);
box-shadow: var(--df-shadow-sm);
z-index: 20;
max-height: 280px;
overflow-y: auto;
scrollbar-width: thin;
}
.module-dropdown-item {
@@ -665,10 +661,10 @@ async function onRemoveModule() {
gap: 2px;
padding: 8px 12px;
cursor: pointer;
border-bottom: 0.5px solid rgba(255,255,255,0.04);
border-bottom: 0.5px solid var(--df-hover);
}
.module-dropdown-item:last-child { border-bottom: none; }
.module-dropdown-item:hover { background: rgba(255,255,255,0.04); }
.module-dropdown-item:hover { background: var(--df-hover); }
.module-dropdown-item.active {
background: rgba(255,255,255,0.06);
border-left: 2px solid var(--df-accent);
@@ -778,7 +774,7 @@ async function onRemoveModule() {
.view-tab:hover {
color: var(--df-text);
background: rgba(255,255,255,0.03);
background: var(--df-hover);
}
.view-tab.active {
@@ -809,7 +805,9 @@ async function onRemoveModule() {
overflow-y: auto;
overflow-x: hidden;
padding: 4px 4px 4px 0;
scrollbar-width: thin;
/* 滚动条样式走全局 ::-webkit-scrollbar(5px + var(--df-scrollbar) 主题色):
此处不得设 scrollbar-width —— Chromium 121+ 一旦设了标准 scrollbar-* 属性,
该元素 ::-webkit-scrollbar 定制即失效,文件列表会回退成原生样式滚动条(不随主题) */
}
.explorer-changes {
@@ -817,13 +815,6 @@ async function onRemoveModule() {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scrollbar-width: thin;
}
.explorer-changes .gc-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.tree-loading {
@@ -865,8 +856,8 @@ async function onRemoveModule() {
.tree-loading .spinner {
width: 20px;
height: 20px;
border: 2px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border: 2px solid var(--df-border);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
}
@@ -888,43 +879,8 @@ async function onRemoveModule() {
min-height: 200px;
}
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
.explorer-tree ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-tree ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-tree ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-tree ::-webkit-scrollbar-track {
background: transparent;
}
/* 滚动条统一样式(styles/global.css 全局 ::-webkit-scrollbar:5px + var(--df-scrollbar))已覆盖,
此处不重复声明 —— 原局部副本硬编码白色系 rgba,浅色主题下 thumb 不可见 */
/* 窄窗口自适应 */
@media (max-width: 900px) {
@@ -946,20 +902,14 @@ async function onRemoveModule() {
}
}
/* spinner(刷新按钮内) */
/* spinner(刷新按钮内);df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删) */
.spinner {
width: 10px;
height: 10px;
border: 1.5px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
display: inline-block;
}
@keyframes df-spin {
to {
transform: rotate(360deg);
}
}
</style>
+24 -36
View File
@@ -171,7 +171,7 @@
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
import { moduleApi, type OutlineSymbol } from '@/api/module'
import hljs from 'highlight.js/lib/common'
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
import { useRendered } from '@/composables/useMarkdown'
import { escapeHtml } from '@/utils/html'
const props = defineProps<{
@@ -184,7 +184,6 @@ const props = defineProps<{
externalDiff?: string
}>()
const { } = useMarkdown()
const content = ref('')
const htmlContent = ref('')
const loading = ref(false)
@@ -469,12 +468,12 @@ const outlineGroups = computed(() => {
.map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! }))
})
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12.5px × 1.55)。 */
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12px × 1.55)。 */
function gotoOutlineLine(line: number) {
activeOutlineLine.value = line
const el = codeScrollRef.value
if (!el) return
const lineHeight = 12.5 * 1.55
const lineHeight = 12 * 1.55
const topPad = 14 // 对齐 .preview-code / .preview-line-numbers 的 padding-top
el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' })
}
@@ -732,11 +731,6 @@ onUnmounted(() => {
padding: 24px;
}
.binary-icon {
font-size: 40px;
opacity: 0.8;
}
.binary-title {
font-size: 15px;
font-weight: 600;
@@ -748,7 +742,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(255, 255, 255, 0.04);
background: var(--df-hover);
border-radius: 8px;
padding: 12px 16px;
min-width: 280px;
@@ -771,7 +765,7 @@ onUnmounted(() => {
.binary-meta-value {
color: var(--df-text-secondary);
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 12.5px;
font-size: 12px;
word-break: break-all;
}
@@ -785,7 +779,7 @@ onUnmounted(() => {
}
.preview-error { color: var(--df-danger); }
.binary-icon { font-size: 32px; opacity: 0.5; }
.binary-icon { font-size: 32px; opacity: 0.5; } /* 2026-10-18 合并重复定义:40px/0.8 旧版被本行完全覆盖,已删 */
.preview-image {
display: flex;
@@ -799,7 +793,7 @@ onUnmounted(() => {
.preview-image img {
max-width: 100%;
height: auto;
border-radius: var(--df-radius-sm, 4px);
border-radius: var(--df-radius-sm);
}
/* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */
@@ -814,7 +808,7 @@ onUnmounted(() => {
margin: 0;
padding: 14px 16px;
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
font-size: 12.5px;
font-size: 12px;
line-height: 1.55;
white-space: pre;
overflow: visible;
@@ -838,7 +832,7 @@ onUnmounted(() => {
padding: 14px 0;
min-width: 44px;
text-align: right;
background: rgba(255, 255, 255, 0.03);
background: var(--df-hover);
border-right: 0.5px solid var(--df-border);
user-select: none;
position: sticky;
@@ -850,7 +844,7 @@ onUnmounted(() => {
.preview-line-num {
padding: 0 10px 0 8px;
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12.5px;
font-size: 12px;
line-height: 1.55;
color: var(--df-text-dim);
opacity: 0.5;
@@ -859,24 +853,25 @@ onUnmounted(() => {
.preview-line-num.is-active {
color: var(--df-accent);
opacity: 1;
background: rgba(255, 255, 255, 0.05);
background: var(--df-hover);
}
/* Diff 视图 — 在自身区域滚动,不溢出到父级 */
/* Diff 视图 — 在自身区域滚动,不溢出到父级(2026-10-18 合并文末重复定义) */
.preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
line-height: 1.55;
overflow: auto;
min-height: 0;
height: 100%;
flex: 1;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border: 2px solid var(--df-border);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
display: inline-block;
@@ -890,7 +885,7 @@ onUnmounted(() => {
padding: 4px 10px;
font-size: 11px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
cursor: pointer;
@@ -927,7 +922,7 @@ onUnmounted(() => {
padding: 4px 9px;
border: 0.5px solid var(--df-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
background: var(--df-hover);
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
@@ -958,7 +953,7 @@ onUnmounted(() => {
background: rgba(20, 22, 34, 0.95);
border: 0.5px solid var(--df-border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
box-shadow: var(--df-shadow-md);
overflow: hidden;
}
@@ -1031,7 +1026,7 @@ onUnmounted(() => {
gap: 6px;
padding: 2px 6px;
border-radius: 6px;
font-size: 11.5px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
color: var(--df-text-secondary);
@@ -1039,7 +1034,7 @@ onUnmounted(() => {
transition: background 0.15s;
}
.outline-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--df-text); }
.outline-item:hover { background: var(--df-hover); color: var(--df-text); }
.outline-item.is-active {
background: rgba(255, 255, 255, 0.09);
@@ -1047,7 +1042,7 @@ onUnmounted(() => {
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫(语法色板·演示豁免,不入 token) */
.outline-item.grp-type .outline-kind { color: #4FC3F7; }
.outline-item.grp-fn .outline-kind { color: #81C784; }
.outline-item.grp-var .outline-kind { color: #FFB74D; }
@@ -1066,6 +1061,7 @@ onUnmounted(() => {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-line {
@@ -1076,15 +1072,6 @@ onUnmounted(() => {
opacity: 0.5;
}
/* Diff 视图 */
.preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
font-size: 12px;
line-height: 1.55;
overflow: auto;
height: 100%;
}
.diff-line {
display: flex;
padding: 0 8px;
@@ -1115,6 +1102,7 @@ onUnmounted(() => {
overflow: hidden;
}
/* Diff 行底(差异色·演示豁免,与 git 语义 token 并行的语法高亮族) */
.diff-add {
background: rgba(40, 160, 70, 0.12);
}
@@ -1137,7 +1125,7 @@ onUnmounted(() => {
color: var(--df-text);
}
@keyframes df-spin { to { transform: rotate(360deg); } }
/* df-spin keyframes 消费 global.css 全局定义(原本地同名重复定义已删,2026-10-18 整洁波) */
</style>
<!-- highlight.js github-dark 主题(全局,非 scoped) -->
+8 -13
View File
@@ -384,16 +384,16 @@ watch(
gap: 6px;
padding: 3px 8px;
cursor: pointer;
border-radius: var(--df-radius-sm, 4px);
border-radius: var(--df-radius-sm);
transition: background 0.12s;
}
.tree-row:hover {
background: var(--df-bg-card, rgba(255, 255, 255, 0.04));
background: var(--df-bg-card);
}
.tree-row-file.is-selected {
background: var(--df-accent, #3a8);
background: var(--df-accent);
color: #fff;
}
@@ -415,8 +415,8 @@ watch(
display: inline-block;
width: 9px;
height: 9px;
border: 1.5px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
vertical-align: middle;
@@ -544,19 +544,14 @@ watch(
color: #e05050;
}
/* spinner;df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删,2026-10-18 整洁波) */
.spinner {
width: 12px;
height: 12px;
border: 1.5px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
display: inline-block;
}
@keyframes df-spin {
to {
transform: rotate(360deg);
}
}
</style>
+17 -43
View File
@@ -422,7 +422,6 @@ async function loadStatus() {
const showBranchPicker = ref(false)
const branchList = ref<{ name: string; is_current: boolean }[]>([])
const branchLoading = ref(false)
const currentBranch = ref('')
async function onSwitchBranch() {
if (showBranchPicker.value) {
@@ -435,7 +434,6 @@ async function onSwitchBranch() {
try {
const res = await moduleApi.listBranches(props.moduleId)
branchList.value = res.branches
currentBranch.value = res.current
} catch (e) {
branchList.value = []
actionError.value = String(e)
@@ -607,7 +605,7 @@ watch(() => props.refreshKey, () => {
}
.branch-switch-btn:hover {
color: var(--df-text);
background: rgba(255,255,255,0.05);
background: var(--df-hover);
}
/* Tabs */
@@ -620,14 +618,14 @@ watch(() => props.refreshKey, () => {
.gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); }
.gc-tab:hover { color: var(--df-text); }
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; scrollbar-width: thin; }
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.gc-changes { min-height: 0; }
/* 分组 */
.gc-group-header {
padding: 6px 14px; font-size: 11px; font-weight: 600;
color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(255,255,255,0.02); border-bottom: 0.5px solid var(--df-border);
background: var(--df-hover); border-bottom: 0.5px solid var(--df-border);
position: sticky; top: 0; z-index: 1;
}
@@ -645,8 +643,8 @@ watch(() => props.refreshKey, () => {
.gc-search-icon { color: var(--df-text-dim); flex-shrink: 0; }
.gc-search-input {
flex: 1; min-width: 0;
background: rgba(255,255,255,0.04); border: 0.5px solid var(--df-border);
border-radius: 4px; padding: 4px 8px;
background: var(--df-hover); border: 0.5px solid var(--df-border);
border-radius: 4px; height: var(--df-h-input); padding: 0 8px;
color: var(--df-text); font-size: 12px; outline: none;
}
.gc-search-input:focus { border-color: var(--df-accent); }
@@ -658,13 +656,13 @@ watch(() => props.refreshKey, () => {
.gc-search-clear:hover { color: var(--df-text); }
/* 提交列表 */
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; }
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; }
.gc-commit-row {
display: flex; flex-direction: column; gap: 2px;
padding: 6px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03);
padding: 6px 14px; border-bottom: 0.5px solid var(--df-hover);
cursor: pointer; transition: background 0.1s;
}
.gc-commit-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-row:hover { background: var(--df-hover); }
.gc-commit-row.selected { background: rgba(255,255,255,0.06); }
.gc-commit-line1 {
display: flex; align-items: center; gap: 8px;
@@ -696,7 +694,7 @@ watch(() => props.refreshKey, () => {
}
.gc-commit-detail-header {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 14px; background: rgba(255,255,255,0.03);
padding: 8px 14px; background: var(--df-hover);
border-bottom: 0.5px solid var(--df-border);
}
.gc-commit-detail-hash {
@@ -720,14 +718,14 @@ watch(() => props.refreshKey, () => {
/* 提交的文件列表 */
.gc-commit-files {
border-bottom: 0.5px solid var(--df-border);
overflow-y: auto; max-height: 140px; scrollbar-width: thin; flex-shrink: 0;
overflow-y: auto; max-height: 140px; flex-shrink: 0;
}
.gc-commit-file-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s;
border-bottom: 0.5px solid rgba(255,255,255,0.02);
border-bottom: 0.5px solid var(--df-hover);
}
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-file-row:hover { background: var(--df-hover); }
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
/* 滑出动画 */
@@ -757,7 +755,7 @@ watch(() => props.refreshKey, () => {
display: flex; align-items: center; gap: 8px;
cursor: pointer; transition: background 0.1s;
}
.gc-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-file-row:hover { background: var(--df-hover); }
.gc-file-row.selected { background: rgba(255,255,255,0.06); }
/* 加载更多 */
@@ -766,7 +764,7 @@ watch(() => props.refreshKey, () => {
color: var(--df-accent); font-size: 12px;
transition: background 0.1s; flex-shrink: 0;
}
.gc-load-more:hover { background: rgba(255,255,255,0.04); }
.gc-load-more:hover { background: var(--df-hover); }
/* 分支列表弹窗 */
.gc-branch-picker {
@@ -790,7 +788,6 @@ watch(() => props.refreshKey, () => {
.gc-branch-list {
max-height: 200px;
overflow-y: auto;
scrollbar-width: thin;
}
.gc-branch-item {
display: flex;
@@ -827,13 +824,9 @@ watch(() => props.refreshKey, () => {
.gc-skeleton-row {
height: 24px;
border-radius: 4px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
background: linear-gradient(90deg, var(--df-hover) 0%, rgba(255,255,255,0.08) 50%, var(--df-hover) 100%);
background-size: 200% 100%;
animation: gc-shimmer 1.5s infinite linear;
}
@keyframes gc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
animation: df-shimmer 1.5s infinite linear; /* 消费 global.css df-shimmer(原本地 gc-shimmer 重复定义已删) */
}
/* 通用 */
@@ -853,24 +846,5 @@ watch(() => props.refreshKey, () => {
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
}
@keyframes df-spin { to { transform: rotate(360deg); } }
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
.git-changes ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.git-changes ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.git-changes ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.git-changes ::-webkit-scrollbar-track {
background: transparent;
}
/* df-spin keyframes 消费 global.css 全局定义(原本地同名重复定义已删,2026-10-18 整洁波) */
</style>
+2 -6
View File
@@ -242,13 +242,9 @@ onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
width: 18px;
height: 12px;
border-radius: 3px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
background: linear-gradient(90deg, var(--df-hover) 0%, rgba(255,255,255,0.08) 50%, var(--df-hover) 100%);
background-size: 200% 100%;
animation: pc-shimmer 1.5s infinite linear;
}
@keyframes pc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
animation: df-shimmer 1.5s infinite linear; /* 消费 global.css df-shimmer(原本地 pc-shimmer 重复定义已删) */
}
.card-top-actions { display: flex; align-items: center; gap: 8px; }
+5 -9
View File
@@ -91,8 +91,8 @@
<div class="update-progress-text">{{ progressText }}</div>
</div>
<!-- 错误提示 -->
<div v-if="installError" class="update-error">{{ installError }}</div>
<!-- 错误提示:视觉走全局 .df-note--danger -->
<div v-if="installError" class="df-note df-note--danger update-error">{{ installError }}</div>
<div class="update-card-actions">
<button
@@ -373,11 +373,12 @@ async function onIntervalChange() {
.update-progress {
margin-bottom: 12px;
}
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
.update-progress-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
background: var(--df-neutral-bg);
overflow: hidden;
}
.update-progress-fill {
@@ -391,14 +392,9 @@ async function onIntervalChange() {
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
}
/* 安装错误提示 */
/* 安装错误提示:底色/边/圆角/字号走全局 .df-note--danger(token 直引无回退),此处仅留排版 */
.update-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5;
word-break: break-all;
}
+7 -12
View File
@@ -86,11 +86,12 @@
</div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
<Transition name="confirm">
<!-- 按钮行/过渡消费 global.css .df-confirm-actions + df-confirm(2026-10-18 整洁波并轨) -->
<Transition name="df-confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<div class="df-confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div>
@@ -252,8 +253,9 @@ function onApprovalTimeoutChange() {
.setting-select/.setting-textarea + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control)。本 Section 仅保留 all 二次确认弹层特有 scoped 样式。
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
F-#97 all 二次确认弹层:mask/box/msg 本地特有样式;actions 按钮行与过渡已并轨
global.css .df-confirm-actions + df-confirm 过渡(2026-10-18 整洁波双轨合一)。
此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
.data-dir-path {
flex: 1;
min-width: 0;
@@ -284,8 +286,6 @@ function onApprovalTimeoutChange() {
flex-direction: column;
align-items: stretch;
}
.prompt-row .setting-info { align-items: flex-start; }
.prompt-row .setting-control { align-items: flex-start; }
/* select 类设置行(自定义布局,不复用 SettingRow):label 与 select 同一行靠左顶对齐,
desc 换行到整行下方。
┌───────────────────────────────┐
@@ -326,12 +326,7 @@ function onApprovalTimeoutChange() {
padding: 20px;
min-width: 280px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
box-shadow: var(--df-shadow-md);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>
+8 -10
View File
@@ -87,12 +87,13 @@
</section>
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行) -->
<Transition name="confirm">
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行;
按钮行/过渡消费 global.css .df-confirm-actions + df-confirm,2026-10-18 整洁波并轨) -->
<Transition name="df-confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<div class="df-confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div>
@@ -296,8 +297,10 @@ onMounted(load)
.script-safety-desc {
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
}
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
.script-safety-input {
min-width: 280px;
width: 280px;
max-width: 100%;
font-family: var(--df-font-mono, monospace);
}
.script-safety-note {
@@ -321,12 +324,7 @@ onMounted(load)
padding: 20px;
min-width: 280px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
box-shadow: var(--df-shadow-md);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>
+7 -10
View File
@@ -263,13 +263,10 @@ onMounted(loadConnections)
<style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/setting-input
已迁移到 settings.css 全局。
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列/
btn-link 及 form-field 内输入控件布局覆盖等。 */
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列
及 form-field 内输入控件布局覆盖等。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* 链接按钮 .btn-link/.btn-link-danger 已并轨 global.css(样式大重构去重) */
/* ===== 标题组(panel-header 内 标题+badge 一组,与右侧按钮 space-between) ===== */
.panel-title-group { display: flex; align-items: center; gap: 8px; }
@@ -307,10 +304,10 @@ onMounted(loadConnections)
border-radius: var(--df-radius-sm);
font-weight: 500;
}
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
.type-mysql { background: var(--df-info-bg); color: var(--df-info); }
.type-ssh { background: var(--df-accent-soft); color: var(--df-accent); }
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); }
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
.type-redis { background: var(--df-danger-bg); color: var(--df-danger); }
.type-mongo { background: var(--df-success-bg); color: var(--df-success); }
.conn-host, .conn-user {
font-size: 12px;
@@ -318,7 +315,7 @@ onMounted(loadConnections)
font-family: var(--df-font-mono);
}
.conn-user {
background: rgba(90,99,128,0.2);
background: var(--df-neutral-bg);
padding: 1px 6px;
border-radius: var(--df-radius-sm);
}
+4 -2
View File
@@ -142,9 +142,11 @@ async function save() {
<style scoped>
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.feishu-settings { display: flex; flex-direction: column; gap: 4px; }
.feishu-input { width: 280px; }
/* 定宽输入补 max-width,窄窗收缩防横向溢出 */
.feishu-input { width: 280px; max-width: 100%; }
.feishu-whitelist {
width: 320px;
max-width: 100%;
min-height: 64px;
resize: vertical;
font-family: var(--df-font-mono, monospace);
@@ -153,7 +155,7 @@ async function save() {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.5;
color: var(--df-text-tertiary, #888);
color: var(--df-text-dim);
}
.actions { display: flex; gap: 8px; margin-top: 8px; }
</style>
+1 -1
View File
@@ -38,7 +38,7 @@
</select>
</SettingRow>
<SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px; max-width:100%" />
</SettingRow>
</div>
</section>
+35 -409
View File
@@ -5,55 +5,9 @@
<h2>{{ $t('settings.panelAiProvider') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div>
<div class="provider-list" v-if="aiProviders.length > 0">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in aiProviders" :key="p.id">
<div class="provider-top">
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
<div class="detail-row detail-row--pool">
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
<div class="detail-value detail-value--control">
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
<div class="detail-value detail-value--control">
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
:value="p.weight ?? 50"
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
<span class="pool-weight-hint">0-100</span>
</div>
</div>
</div>
</div>
</div>
<!-- 卡片列表 + 负载均衡池控件拆至 provider-panel/provider-list.vue(C4c) -->
<ProviderList v-if="aiProviders.length > 0" ref="providerListRef" :providers="aiProviders"
@edit="openProviderForm" @delete="deleteProvider" @set-default="setDefaultProvider" @toast="relayToast" />
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
</section>
@@ -127,8 +81,8 @@
</div>
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
用户看到错误同时表单保持编辑态,改完点保存即可重试。 -->
<div v-if="providerForm.saveError" class="save-error-banner">
用户看到错误同时表单保持编辑态,改完点保存即可重试。视觉走全局 .df-note--danger。 -->
<div v-if="providerForm.saveError" class="df-note df-note--danger">
<pre class="save-error-msg">{{ providerForm.saveError }}</pre>
</div>
<div class="form-actions">
@@ -140,59 +94,10 @@
</button>
</div>
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ -->
<!-- ═══ 拉取到的模型列表(F-01 阶段6;表格+标签标注拆至 provider-panel/model-table.vue)═══ -->
<div class="form-field form-field--full" v-if="providerForm.models.length > 0">
<label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label>
<table class="model-table">
<thead>
<tr>
<th class="col-enabled">{{ $t('settings.colEnabled') }}</th>
<th class="col-model">{{ $t('settings.colModel') }}</th>
<th class="col-ability">{{ $t('settings.colAbility') }}</th>
<th class="col-weight">{{ $t('settings.labelWeight') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="m in providerForm.models" :key="m.model_id">
<td class="col-enabled">
<label class="toggle toggle--sm">
<input type="checkbox" v-model="m.enabled" />
<span class="toggle-slider"></span>
</label>
</td>
<td class="col-model">
<span class="model-id">{{ m.model_id }}</span>
</td>
<td class="col-ability">
<!-- 能力/模态标签(可编辑:点 × 删除;点 + 添加;手动改后标为「手动」) -->
<div class="model-tags">
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="removeModality(m, md)">×</span>
</span>
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="removeCapability(m, cap)">×</span>
</span>
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
<span class="tag-add" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="startAddTag(m)">+</span>
</div>
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
<span v-for="opt in addableTags(m)" :key="opt.kind + '-' + opt.key"
class="tag" :class="opt.kind === 'modality' ? 'tag-modality' : 'tag-capability'"
@click.stop="addTag(m, opt.key, opt.kind)">{{ opt.label }}</span>
<span v-if="addableTags(m).length === 0" class="add-tag-empty">{{ $t('settings.addTagNone') }}</span>
</div>
</td>
<td class="col-weight">
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
</td>
</tr>
</tbody>
</table>
<ModelTable :models="providerForm.models" />
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
@@ -205,11 +110,17 @@
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
import type { AiProviderConfig, ModelConfig } from '@/api/types'
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
import ProviderList from './provider-panel/provider-list.vue'
import ModelTable from './provider-panel/model-table.vue'
// ============================================================
// AI 提供商域 — 真实后端 CRUD + c 负载均衡池(enabled / weight)
// C4c 拆分:卡片列表+池控件 → provider-panel/provider-list.vue;
// 模型表格+能力标签标注 → provider-panel/model-table.vue。
// 本件保留:表单状态机(校验红框/温度与权重钳制/快照脏判)、保存/测试连接/预设回填、
// 列表 CRUD 与 loadProviders 闭环、对壳 defineExpose 契约。
// ============================================================
const { t } = useI18n()
@@ -286,37 +197,7 @@ const providerForm = reactive({
savedSnapshot: '' as string,
})
/**
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
*/
const redundantTags = computed(() => {
const models = providerForm.models
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
if (models.length < 2) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
const common = (pick: (m: ModelConfig) => string[]) => {
const sets = models.map(m => new Set(pick(m)))
const first = sets[0]
const result = new Set<string>()
for (const v of first) {
if (sets.every(s => s.has(v))) result.add(v)
}
return result
}
return {
modalities: common(m => m.modalities),
capabilities: common(m => m.capabilities),
// 探测来源:全模型相同的值也冗余(如全 PresetTable)
sources: (() => {
const vals = models.map(m => m.probe_source).filter((v): v is ProbeSource => !!v)
if (vals.length < 2) return new Set<string>()
return new Set([...new Set(vals)].filter(v => vals.every(x => x === v)))
})(),
}
})
// (冗余标签判定 redundantTags 随模型表格拆至 provider-panel/model-table.vue)
/**
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
@@ -349,59 +230,7 @@ function formBusinessSnapshot(): string {
})
}
// ============================================================
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
// ============================================================
/** 当前展开「添加能力」菜单的模型名;null=无展开 */
const addingTagModel = ref<string | null>(null)
/** 可添加的模态(与 tagModality 对齐;text 是对话基础模态,允许删但一般不主动加) */
const ADDABLE_MODALITIES: Modality[] = ['text', 'vision', 'audio', 'video']
/** 可添加的能力 */
const ADDABLE_CAPABILITIES: Capability[] = ['tool_use', 'embedding', 'code_gen']
/** 某模型可添加的可选 tag(模态 + 能力,排除已选的) */
function addableTags(m: ModelConfig): { key: string; kind: 'modality' | 'capability'; label: string }[] {
const mods = ADDABLE_MODALITIES
.filter(x => !m.modalities.includes(x))
.map(x => ({ key: x, kind: 'modality' as const, label: t('settings.tagModality.' + x) }))
const caps = ADDABLE_CAPABILITIES
.filter(x => !m.capabilities.includes(x))
.map(x => ({ key: x, kind: 'capability' as const, label: t('settings.tagCapability.' + x) }))
return [...mods, ...caps]
}
/** 标记模型为「手动标注」(probe_source=user_set),下次探测不覆盖 */
function markManual(m: ModelConfig) {
m.probe_source = 'user_set'
addingTagModel.value = null
}
function removeModality(m: ModelConfig, md: Modality) {
m.modalities = m.modalities.filter(x => x !== md)
markManual(m)
}
function removeCapability(m: ModelConfig, cap: Capability) {
m.capabilities = m.capabilities.filter(x => x !== cap)
markManual(m)
}
function addTag(m: ModelConfig, key: string, kind: 'modality' | 'capability') {
if (kind === 'modality') {
const md = key as Modality
if (!m.modalities.includes(md)) m.modalities = [...m.modalities, md]
} else {
const cap = key as Capability
if (!m.capabilities.includes(cap)) m.capabilities = [...m.capabilities, cap]
}
markManual(m)
}
function startAddTag(m: ModelConfig) {
addingTagModel.value = addingTagModel.value === m.model_id ? null : m.model_id
}
// (模型能力手动标注 tag 编辑块随模型表格拆至 provider-panel/model-table.vue)
/**
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
@@ -677,64 +506,15 @@ async function setDefaultProvider(id: string) {
}
}
// ============================================================
// c: 负载均衡池可编辑层(enabled / weight)
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
// 重建 per_provider caps,下条消息即按新配置 acquire。
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
// ============================================================
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
// (负载均衡池 enabled/weight 即时 IPC 块随卡片列表拆至 provider-panel/provider-list.vue)
/**
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
* 乐观更新本地数组,失败 revert + toast。
*/
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled
p.enabled = enabled
try {
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.enabled = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
/** 子件 toast 上抛中继(池控件 IPC 结果反馈) */
function relayToast(msg: string, type: 'error' | 'warning' | 'info' | 'success') {
emit('toast', msg, type)
}
/**
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
*/
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
const prevTimer = _poolTimers.get(p.id)
if (prevTimer) clearTimeout(prevTimer)
_poolTimers.set(p.id, setTimeout(async () => {
_poolTimers.delete(p.id)
try {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}
}, 300))
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
/** 卡片列表子件引用(池 debounce 计时器下沉子件,卸载清理经此委托) */
const providerListRef = useTemplateRef<InstanceType<typeof ProviderList>>('providerListRef')
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
@@ -744,7 +524,7 @@ defineExpose({
loadProviders,
isFormDirty,
closeProviderForm,
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
clearPoolTimer() { providerListRef.value?.clearPoolTimer() },
})
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
@@ -754,92 +534,24 @@ onMounted(loadProviders)
<style scoped>
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局。
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/
pool-badge/detail 系列/form 系列/btn-link/toggle--sm 及输入控件布局覆盖等。
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局,btn-link 已并轨 global.css。
C4c 拆分:provider 卡片/池控件样式随 provider-list.vue、模型表格/标签样式随
model-table.vue 下沉子件;本 scoped 仅保留表单区样式(form 系列/红框/错误横幅/
模型列表区容器与提示行)。.setting-input--sm/.toggle--sm 为两子件共用,各持一份。
注:注释列举类名时勿写星斜杠序列(会提前闭合 CSS 注释)。 */
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* ===== Provider 卡片 ===== */
.provider-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.provider-info { display: flex; align-items: center; gap: 8px; }
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.default-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100,255,218,0.15);
color: var(--df-success);
font-weight: 500;
}
.provider-actions { display: flex; gap: 4px; }
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
.detail-label {
font-size: 12px;
color: var(--df-text-dim);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
.provider-card--disabled { opacity: 0.6; }
.pool-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(255,107,107,0.15);
color: var(--df-danger);
font-weight: 500;
}
/* 负载均衡池行(toggle / weight 控件) */
.detail-row--pool { align-items: center; }
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
/* 表单(本面板特有输入控件样式;.setting-select 走全局)。
--form-label-w 为标签列宽单一来源:hint 缩进用 calc(label 列宽+行 gap) 对齐 input 位,
免手算魔法数。 */
.form-grid { --form-label-w: 72px; display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.form-label { flex: 0 0 72px; font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
.form-label { flex: 0 0 var(--form-label-w); font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
/* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。
横向布局下占满整行换到 label+input 下方。 */
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
横向布局下占满整行换到 label+input 下方,缩进与 input 左缘结构对齐。 */
.form-field-hint { flex-basis: 100%; margin-left: calc(var(--form-label-w) + 10px); font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
/* 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
.save-error-banner {
background: var(--df-danger-bg);
border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm);
padding: 10px 12px;
}
/* 保存失败持久错误横幅:视觉(底色/边/圆角/内距)走全局 .df-note--danger,此处仅留内部排版 */
.save-error-msg {
margin: 0;
font-family: var(--df-font-sans);
@@ -860,12 +572,6 @@ onMounted(loadProviders)
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
.setting-input--invalid { border-color: var(--df-danger) !important; }
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
.model-list-unsaved-hint {
@@ -874,86 +580,6 @@ onMounted(loadProviders)
line-height: 1.5;
color: var(--df-warning);
}
/* ===== 模型列表(表格形式:表头 + 数据行) =====
原 model-list/model-row 等无任何样式定义(裸渲染),本批补齐并改为表格。
风格对齐 provider-card(浅底 / 圆角 / 0.5px 边框)。 */
.model-table {
width: 100%;
border-collapse: collapse;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
overflow: hidden;
font-size: 12px;
}
.model-table th,
.model-table td {
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
.model-table thead th {
background: var(--df-bg);
font-weight: 500;
color: var(--df-text-dim);
font-size: 11px;
border-bottom: 0.5px solid var(--df-border);
white-space: nowrap;
}
.model-table tbody tr { border-bottom: 0.5px solid var(--df-border); }
.model-table tbody tr:last-child { border-bottom: none; }
.model-table tbody tr:hover { background: var(--df-accent-bg); }
/* 列宽:启用窄、模型/能力弹性、权重窄 */
.model-table .col-enabled { width: 56px; text-align: center; }
.model-table .col-weight { width: 110px; }
.model-table .col-model { min-width: 140px; }
.col-enabled .toggle--sm { display: inline-block; }
.model-id {
font-size: 13px;
font-family: var(--df-font-mono);
color: var(--df-text);
font-weight: 500;
word-break: break-all;
}
/* 标签列:小圆角 chip,三类语义色区分 */
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
font-weight: 500;
white-space: nowrap;
}
.tag-modality { background: rgba(100,181,246,0.15); color: var(--df-info); }
.tag-capability { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.tag-source { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* 可编辑 tag:×删除按钮(悬停显红)+ 添加按钮 */
.tag-remove { margin-left: 3px; cursor: pointer; opacity: 0.6; font-weight: 700; }
.tag-remove:hover { opacity: 1; color: var(--df-danger); }
.tag-add {
cursor: pointer;
background: transparent;
border: 1px dashed var(--df-border);
color: var(--df-text-dim);
font-weight: 700;
}
.tag-add:hover { border-color: var(--df-accent); color: var(--df-accent); }
/* 添加能力内联菜单(点+展开):浅底容器包可选 tag */
.add-tag-menu {
margin-top: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
}
.add-tag-menu .tag { cursor: pointer; }
.add-tag-menu .tag:hover { filter: brightness(1.2); }
.add-tag-empty { font-size: 10px; color: var(--df-text-dim); padding: 2px 4px; }
/* 权重输入框:表格内固定窄宽 */
.model-table .setting-input--sm { width: 64px; }
.model-list-hint {
margin-top: 8px;
font-size: 11px;
+4 -2
View File
@@ -115,10 +115,12 @@ async function save() {
<style scoped>
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.uclaw-settings { display: flex; flex-direction: column; gap: 4px; }
.uclaw-input { width: 320px; font-family: var(--df-font-mono, monospace); }
/* 定宽输入补 max-width,窄窗收缩防横向溢出 */
.uclaw-input { width: 320px; max-width: 100%; font-family: var(--df-font-mono, monospace); }
/* 原 var(--df-text-tertiary) 为幽灵 token(全 styles 未定义,渲染成继承色),改用已定义的弱化色 */
.uclaw-hint {
font-size: 11px;
color: var(--df-text-tertiary);
color: var(--df-text-dim);
line-height: 1.6;
margin-top: 4px;
white-space: pre-line;

Some files were not shown because too many files have changed in this diff Show More