重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)
L1 agent 元能力层(治痛①死循环零交付): - env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱) - 断路器:同类工具失败≥3熔断 + guard.reset - detect_environment 主动探测工具(python/node/shell) - 求助协议 AiHelpRequired 事件 + 前端求助卡 L2 统一状态机后端(治痛②,前端批2): - ConvState enum 5态 + 合法转换守卫(conv_state.rs) - GeneratingGuard 接入视图层(guard.rs) L3 事件总线后端骨架(治痛③④⑤,接入批2): - EventBus pub-sub + AiBusEvent 8变体(event_bus.rs) list 工具调用重复治理第一步: - build_system_prompt_with_excluded 去重被@实体 + 清单注明语
This commit is contained in:
@@ -30,7 +30,9 @@ pub enum ShellType {
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
|
||||
// L1 环境感知:Windows 默认 PowerShell(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
||||
if cfg!(windows) { ShellType::PowerShell } else { ShellType::Sh }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
156
docs/02-架构设计/专项设计/aichat体验与agent能力系统化重构-2026-06-21.md
Normal file
156
docs/02-架构设计/专项设计/aichat体验与agent能力系统化重构-2026-06-21.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# aichat 体验与 agent 能力系统化重构
|
||||
|
||||
> 2026-06-21 | 不破不立 · 长久治本 · 机制优先说教
|
||||
|
||||
## 0. 背景:为什么是系统化重构
|
||||
|
||||
7-agent UX 诊断 + kms 会话(8f607aa4,53 轮 0 产出)实证:**用户难受不是几个 bug,是失控感 + 黑箱感叠加**。诊断出 5 个高痛,但均匀修 5 痛 = 补丁堆砌([[no-patch-groundwork]] 元原则禁止)。
|
||||
|
||||
反思收敛:**DevFlow 给了 AI 一双手(工具),没给一双眼(环境感知)和一个刹车(止损求助)**。根是 **agent 元能力(感知+决策+求助)整体缺失**,表是 UX(降噪/控制/反馈/密度)。
|
||||
|
||||
**系统性解法**:立三层架构,L2/L3 已有设计待整合落地,L1 新立。5 痛作每层第一个消费场景驱动实现。每个 fix 是架构一块砖(可累积/可演进/可观测),非独立补丁。
|
||||
|
||||
## 1. 三层架构
|
||||
|
||||
| 层 | 职责 | 机制(代码强制,非 prompt 说教) | 现状 |
|
||||
|---|---|---|---|
|
||||
| **L1 agent 元能力层** | AI 看得见路、刹得住车、会求助 | 环境探测工具 + 断路器 + 求助协议 | **新立**(四空壳之一,[[aichat-arch-extensibility]]) |
|
||||
| **L2 统一状态机** | 状态收敛,前后端同一真相 | generating/stopping/error enum 视图 + guard 写收敛 | 已设计([[devflow-generating-statemachine]] `generating状态机加固-2026-06-15.md`),待落地 |
|
||||
| **L3 事件总线可观测性** | 进度/卡点/失败/求助统一可见 | pub-sub + request-reply + 流式统一总线 | 已设计([[global-event-bus-design]] `全局事件数据总线-2026-06-21.md`),推进 C |
|
||||
|
||||
**核心原则**([[ai-improvement-principles]]):**机制优先 prompt 说教**。断路器不靠"教 AI 别重试"(LLM 不听),靠代码强制熔断;环境感知不靠"prompt 写死 Windows 规则"(过时),靠动态探测工具。
|
||||
|
||||
## 2. L1 agent 元能力层(新立 · 核心)
|
||||
|
||||
L1 是根。三个组件:
|
||||
|
||||
### 2.1 环境感知(给 AI 一双眼)
|
||||
|
||||
**问题**:`prompt.rs:44 env_info_line()` 只注入"日期+OS"(25 token),AI 不知道 cmd 吃 `$`、PowerShell 才靠谱、python 路径在哪、编码 GBK/UTF-8。kms seq26-52 引号地狱根因。
|
||||
|
||||
**机制**(预防式 + 补救式 + 自省式三层):
|
||||
|
||||
- **预防式 — 启动详细环境注入**:扩 `env_info_line` 为 `env_profile`,启动注入:OS + 默认 shell 类型(Win→PowerShell / Linux·Mac→bash)+ python/node 路径 + 终端编码 + **平台陷阱提示**(Win: cmd 吃 `$/引号,复杂脚本写文件用 `powershell -File` / `python file`;Linux·Mac: bash 引号规则)。约 +120 token,消除引号地狱类系统性错误。
|
||||
- **补救式 — `detect_environment` 工具**:AI 可主动调,返回详细环境(shell/编码/可用命令/Python 版本)。失败 N 次后**自动触发**(见 2.2 断路器),把结果注入 context。
|
||||
- **自省式 — 环境画像缓存**:探测结果缓存(会话级 `env_profile`),避免重复探测;关键环境变化(如 shell 切换)刷新。
|
||||
|
||||
**跨设备**:env_profile 动态 `cfg!` 分支 + 运行时探测,平台无关。Win/Linux/Mac 各自正确姿势。
|
||||
|
||||
**兜底**:探测失败回退静态 env_profile(编译期 OS + 默认 shell),不阻断。
|
||||
|
||||
**开关**:`env_probe_enabled`(默认 on),off 则只静态注入。
|
||||
|
||||
### 2.2 断路器(给 AI 一个刹车)
|
||||
|
||||
**问题**:agentic loop 仅 `max_iterations=10` 硬截断,无智能止损。kms write_file 连撞 8 次、run_command 引号失败 20+ 次才到上限。路径防护错误 AI 视作普通错误反复换法。
|
||||
|
||||
**机制**(代码强制熔断,非说教):
|
||||
|
||||
- **同 tool_call 连续失败 ≥2 次**:熔断,强制停该工具,发求助。
|
||||
- **同路径连撞 ≥2 次**:熔断(路径错误不可绕过,AI 换工具也撞)。
|
||||
- **同错误类型连续 ≥3 次**:熔断(如引号错误反复)。
|
||||
- **熔断动作**:停 loop → 发 `AiHelpRequired` 事件(2.3)→ 前端求助卡(而非 max_iterations 默默截断)。
|
||||
- **错误分类**:路径防护错误标 `fatal_unbypassable`(AI 可操作提示:"路径 X 不在授权区,此错误不可换工具绕过,请改路径或 bind_directory")。
|
||||
|
||||
**开关**:`circuit_breaker_enabled`(默认 on)+ 阈值可调(`max_consecutive_fail=2`)。
|
||||
|
||||
**兜底**:断路器误判 → 用户在求助卡选"继续重试"可覆盖熔断(人工 > 机制)。
|
||||
|
||||
### 2.3 求助协议(让 AI 会求助)
|
||||
|
||||
**问题**:AI 卡死只靠 max_iterations 截断或用户手动打断,无主动求助。prompt 教"失败就问用户"是说教,不可靠。
|
||||
|
||||
**机制**(结构化求助通道):
|
||||
|
||||
- **`AiHelpRequired` 事件**:agent 断路器触发 / 自省(识别"我反复失败")时发,结构化字段:`reason`(死循环/环境未知/权限不足)+ `context`(已试 N 次/卡在哪)+ `options`([换策略/授权路径/人工接管])。
|
||||
- **前端求助卡**:显式 UI(非隐藏),用户选 option → 注入 context → loop 续跑或停。
|
||||
- **与审批区分**:审批=工具执行前确认;求助=AI 主动"我搞不定"。两套机制不混。
|
||||
|
||||
**开关**:`help_required_enabled`(默认 on)。
|
||||
|
||||
## 3. L2 统一状态机(整合现有设计)
|
||||
|
||||
落地 `generating状态机加固-2026-06-15.md`:guard 写收敛 + enum 视图读收敛。
|
||||
|
||||
- **单一真相**:`ConvState` enum(`Idle/Generating/Stopping/Error/Compressed`),消 streaming/generating/isStopping 多源割裂。
|
||||
- **前端读 enum 视图**:停止按钮三态(可停/停中/停失败可重试)是 `Stopping` 状态自然产物,非加变量。
|
||||
- **MaxRoundsCard 判断改 enum**:达轮次时 `Generating` 态可靠弹"继续/停止"卡(诊断痛②:看门狗误清 streaming 致卡片不弹的根解)。
|
||||
|
||||
## 4. L3 事件总线可观测性(整合现有设计)
|
||||
|
||||
落地 `全局事件数据总线-2026-06-21.md`:进度/卡点/失败/求助/心跳统一事件,前端订阅渲染。
|
||||
|
||||
- **心跳 UI 化**:复用 `AiHeartbeat`,每 10-15s 显"AI 已思考 Xs"(诊断痛③:130s 盲等)。
|
||||
- **轮次可视化**:`AiAgentRound` → "轮次 X/Y"(Y=max_iterations 或 ∞)。
|
||||
- **工具分级**:30s 前 10s 预警(橙),30s 后红 + toast。
|
||||
- **审批倒计时**:剩余 X 分钟,临 1 分钟红。
|
||||
- **求助消费**:`AiHelpRequired`(L1)→ 求助卡(L3 渲染)。
|
||||
|
||||
## 5. 5 痛 → 架构组件映射(消费场景驱动)
|
||||
|
||||
| 诊断痛 | 眼前补丁(弃) | 长久架构组件 | 阶段 |
|
||||
|---|---|---|---|
|
||||
| ① AI 死循环零交付 | prompt 教止损 + 折叠 | **L1 断路器 + 环境探测 + 求助协议** | 1 |
|
||||
| ② 停止割裂/卡片不弹 | 加 isStopping | **L2 状态机**(三态是其产物) | 2 |
|
||||
| ③ 无进度反馈 | 加心跳动画 | **L3 事件总线**(心跳是事件之一) | 3 |
|
||||
| ④ 审批疲劳 | 分组按钮 | **L1 会话信任目录机制** + L3 批量事件 | 1/3 |
|
||||
| ⑤ 基础硌人 | 各自修 | L2 状态驱动渲染(统一,非散修) | 3 |
|
||||
|
||||
## 6. 实施顺序(分阶段,每阶段独立可回退)
|
||||
|
||||
**阶段1 · 立 L1 根**(治痛①,最痛):
|
||||
- 环境感知:`env_profile` 注入 + `detect_environment` 工具 + 缓存
|
||||
- 断路器:agentic loop 熔断(同 tool_call/路径/错误类型)
|
||||
- 求助协议:`AiHelpRequired` 事件 + 前端求助卡
|
||||
- 会话信任目录(L1):首次授权后同会话自动放行(治痛④一部分)
|
||||
|
||||
**阶段2 · 立 L2 状态机**(治痛②):
|
||||
- 落地 `ConvState` enum + guard 收敛
|
||||
- 停止三态 + MaxRoundsCard 改 enum 判断
|
||||
|
||||
**阶段3 · 立 L3 事件总线 + 前端消费**(治痛③④⑤):
|
||||
- 事件总线统一输出(进度/卡点/失败/求助/心跳)
|
||||
- 前端订阅渲染:心跳/轮次/工具分级/审批倒计时/求助卡
|
||||
- 降噪:失败折叠 + 同类聚类(x3 点击展开)
|
||||
- 基础:滚动锁存 / 代码围栏占位 / Shift+Enter hint
|
||||
|
||||
每阶段:**独立可回退 + 开关 + 兜底**。阶段1 不依赖 2/3 可先落地见效。
|
||||
|
||||
## 7. 跨设备约束(硬约束)
|
||||
|
||||
| 组件 | 跨设备要求 |
|
||||
|---|---|
|
||||
| L1 env_profile | 动态 `cfg!` + 运行时探测,Win→PowerShell / Linux·Mac→bash,平台陷阱各自注入 |
|
||||
| L1 断路器/求助 | 纯逻辑,平台无关 |
|
||||
| L2 状态机 | 纯逻辑,平台无关 |
|
||||
| L3 事件总线 | 纯逻辑 + 前端,平台无关 |
|
||||
| 路径处理 | 已 `std::path` + workspace_root 归一化 |
|
||||
|
||||
本机跨平台(Win/Linux/Mac)先保证;跨设备同步(手机/多端)是 `df-relay/df-miniapp` 云后端路线([[cross-end-rust-backend]]),另立。
|
||||
|
||||
## 8. 兜底 / 开关 / 可回退([[ai-improvement-principles]])
|
||||
|
||||
每机制配:
|
||||
- **开关**:`env_probe_enabled` / `circuit_breaker_enabled`(+阈值)/ `help_required_enabled`,默认 on,可关。
|
||||
- **兜底**:探测失败→静态 profile;断路器误判→人工覆盖;求助不发→回退 max_iterations。
|
||||
- **可回退**:每阶段独立 git 可 revert;开关 off 即降级旧行为。
|
||||
|
||||
## 9. 关联现有设计(整合不重复)
|
||||
|
||||
- L2 → `generating状态机加固-2026-06-15.md`(直接落地)
|
||||
- L3 → `全局事件数据总线-2026-06-21.md`(直接落地)
|
||||
- L1 决策 → `条件表达式引擎-2026-06-15.md` + `Agent架构说明-2026-06-14.md`(四空壳 coordinator/planner,元能力是其具体化)
|
||||
- 痛①实证 → `aichat-技术债审查-2026-06-21.md` + kms 会话 8f607aa4
|
||||
|
||||
## 10. 验收
|
||||
|
||||
- kms 类场景(白名单外整理):AI 撞 ≤2 次即求助(非 53 轮 0 产出)
|
||||
- 引号地狱:env_profile 注入后 AI 用对 shell / 写文件执行,不反复 -c 内联
|
||||
- 停止按钮:三态可靠,打断后状态干净
|
||||
- 进度:任意时刻用户知 AI 在哪/卡哪/还要多久
|
||||
- 审批:会话信任目录后同路径不重复弹
|
||||
- 跨设备:Win/Linux/Mac env_profile 各自正确
|
||||
|
||||
---
|
||||
|
||||
**下一步**:本设计过目 → 阶段1(L1 根)开整改 workflow 实施。L1 是根,先立见效。
|
||||
468
src-tauri/src/commands/ai/agentic/conv_state.rs
Normal file
468
src-tauri/src/commands/ai/agentic/conv_state.rs
Normal file
@@ -0,0 +1,468 @@
|
||||
//! L2 统一状态机 — `ConvState` enum(单一真相源)+ 合法转换守卫。
|
||||
//!
|
||||
//! 来源:`generating状态机加固-2026-06-15.md` §3(轻量状态机决策)+ `aichat体验与
|
||||
//! agent能力系统化重构-2026-06-21.md` §3(L2 统一状态机)。
|
||||
//!
|
||||
//! # 背景(为什么单独建这个 enum)
|
||||
//!
|
||||
//! 旧实现会话状态由三个标志组合隐式表达(`generating: bool` + `stop_flag: AtomicBool`
|
||||
//! + `pending_approvals` HashMap),判别逻辑散落 `try_continue_agent_loop` /
|
||||
//! `ai_chat_stop` / `ai_conversation_create` 三处(写侧散布 + 读侧散布双根因,见
|
||||
//! generating状态机加固-2026-06-15.md §2.3)。本模块提供**显式的、单一真相的**状态枚举:
|
||||
//!
|
||||
//! - **写侧收敛**:`ConvState` 仅经 [`ConvState::transition_to`] 守卫方法迁移,非法转换
|
||||
//! 直接拒绝(`Err(InvalidTransition)`),调用方无法绕过守卫写入非法组合。
|
||||
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
|
||||
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
|
||||
//!
|
||||
//! # 渐进第一步(本批范围)
|
||||
//!
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等
|
||||
//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState`
|
||||
//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html)
|
||||
//! 默认 on,off 降级纯旧 bool 行为(可回退)。
|
||||
//!
|
||||
//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到
|
||||
//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 开关
|
||||
// ============================================================
|
||||
|
||||
/// L2 状态机总开关(默认 on)。
|
||||
///
|
||||
/// - **on(默认)**:`GeneratingGuard` set/reset 时同步迁移 [`ConvState`](enum.ConvState.html)
|
||||
/// 作视图层,前后端可读统一状态;guard 失败兜底仍走旧 bool 复位(`generating=false`),
|
||||
/// 不影响语义(状态机层失败不阻断核心复位)。
|
||||
/// - **off(降级)**:guard 不写 `ConvState`,纯旧 `generating` bool 行为(排障 / 对比 / 临时
|
||||
/// 回退用)。设为 false 即可观察无状态机接入的效果,语义等价改动前。
|
||||
///
|
||||
/// 机制优先 prompt 说教:每改进配开关 + 兜底,分阶段可回退(对齐 AI 改进方案三原则)。
|
||||
pub const CONV_STATE_ENABLED: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// ConvState enum
|
||||
// ============================================================
|
||||
|
||||
/// 对话生命周期状态(单一真相源,5 态)。
|
||||
///
|
||||
/// 设计取舍(对齐 generating状态机加固-2026-06-15.md §3.2 「轻量状态机,不引入框架」):
|
||||
///
|
||||
/// - **不引入状态机框架**(codegen / FSM 库):5 态 7 边转换简单,枚举 + 显式 `transition_to`
|
||||
/// 守卫即可表达,过度封装得不偿失。
|
||||
/// - **派生关系明确**:`Compressed` 是 `Generating`/`Idle` 期间的瞬时派生态(压缩跑完
|
||||
/// 回到原态),非终端态;`Error` 可经用户「重试」回到 `Idle` 再 `Generating`。
|
||||
/// - **与读视图 [`SessionState`](../enum.SessionState.html) 区分**:`SessionState`
|
||||
/// (Streaming/AwaitingApproval/Idle)是面向「是否有审批挂起」的只读视图(读侧收敛①),
|
||||
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
|
||||
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
|
||||
///
|
||||
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
|
||||
/// 预留,避免后续改动序列化兼容性)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConvState {
|
||||
/// 空闲:无 agentic loop 活跃,可接新请求。
|
||||
///
|
||||
/// 终态目标:所有正常退出路径(完成 / 停止完成 / 错误恢复)的归处。
|
||||
Idle,
|
||||
/// 生成中:agentic loop 流式生成 + 工具执行活跃中(`generating=true`)。
|
||||
///
|
||||
/// 审批挂起期间 `ConvState` 仍为 `Generating`(审批是 loop 内的暂停点,非独立状态)。
|
||||
Generating,
|
||||
/// 停止中:用户点了停止(`stop_flag` 已置位),loop 检测中或正在收尾退出。
|
||||
///
|
||||
/// 瞬态:loop 检测到 `stop_flag` 后迁移到 `Idle`(完成收尾),或停止过程出错到 `Error`。
|
||||
Stopping,
|
||||
/// 错误态:生成过程出错(provider 失败 / Fatal / 断路器熔断等),loop 已退出。
|
||||
///
|
||||
/// 非终态:用户可「重试」从 `Error` → `Idle` → `Generating` 重新发起,或继续空闲。
|
||||
Error,
|
||||
/// 压缩派生态:loop 期间触发上下文压缩(LLM 摘要),生成流程的瞬时派生。
|
||||
///
|
||||
/// 派生而非独立态:压缩完成(成功 / 失败兜底)后回到原 `Generating`(loop 内压缩)或
|
||||
/// `Idle`(手动压缩 IPC 触发)。前端可据此展示「压缩中」spinner(本批未接)。
|
||||
Compressed,
|
||||
}
|
||||
|
||||
impl Default for ConvState {
|
||||
/// 新会话默认 `Idle`(无 loop 活跃)。
|
||||
fn default() -> Self {
|
||||
ConvState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
impl ConvState {
|
||||
/// 尝试迁移到目标态。合法返回 `Ok(新态)`,非法拒绝返回 `Err(InvalidTransition)`。
|
||||
///
|
||||
/// 调用方持锁后经本方法迁移状态(写收敛):不允许直接写字段绕过守卫。
|
||||
/// 非法转换(如 `Idle → Stopping`,无活跃 loop 不可能停止)直接拒绝——调用方应视为
|
||||
/// 逻辑 bug 并记录 warn 日志,不静默忽略(状态机完整性优先)。
|
||||
///
|
||||
/// # 合法转换表(7 边)
|
||||
///
|
||||
/// | from | to | 触发场景 |
|
||||
/// |------|----|---------|
|
||||
/// | Idle | Generating | `run_agentic_loop` 入口,guard set |
|
||||
/// | Generating | Stopping | 用户点停止,置 `stop_flag` |
|
||||
/// | Generating | Idle | loop 正常收敛退出,guard reset |
|
||||
/// | Generating | Error | provider Fatal / 断路器熔断等 |
|
||||
/// | Generating | Compressed | loop 内自动压缩派生 |
|
||||
/// | Stopping | Idle | stop 收尾完成,guard reset |
|
||||
/// | Stopping | Error | 停止过程出错 |
|
||||
/// | Error | Idle | 用户重试前置位(准备再 `Generating`) |
|
||||
/// | Error | Generating | 直接从错误态重新生成(重试) |
|
||||
/// | Compressed | Generating | 压缩完成,loop 内继续 |
|
||||
/// | Compressed | Idle | 手动压缩 IPC 完成(无 loop 活跃) |
|
||||
///
|
||||
/// 自环(同态 → 同态)允许(幂等写,如 guard 多次 set 同态),返回 `Ok(self)`。
|
||||
pub fn transition_to(self, target: ConvState) -> Result<ConvState, InvalidTransition> {
|
||||
// 自环幂等:同态迁移直接通过(防调用方重复 set 报错)。
|
||||
if self == target {
|
||||
return Ok(target);
|
||||
}
|
||||
// 合法转换白名单(显式列举,非 `match _ => Ok` 兜底——新加态必须显式补边,
|
||||
// 编译器不会漏)。
|
||||
let valid = matches!(
|
||||
(self, target),
|
||||
// Idle 起步
|
||||
(ConvState::Idle, ConvState::Generating)
|
||||
// Generating 分流:停 / 完成 / 错误 / 压缩派生
|
||||
| (ConvState::Generating, ConvState::Stopping)
|
||||
| (ConvState::Generating, ConvState::Idle)
|
||||
| (ConvState::Generating, ConvState::Error)
|
||||
| (ConvState::Generating, ConvState::Compressed)
|
||||
// Stopping 收尾:完成 / 出错
|
||||
| (ConvState::Stopping, ConvState::Idle)
|
||||
| (ConvState::Stopping, ConvState::Error)
|
||||
// Error 恢复:重试前置位 / 直接重新生成
|
||||
| (ConvState::Error, ConvState::Idle)
|
||||
| (ConvState::Error, ConvState::Generating)
|
||||
// Compressed 派生回退:loop 继续 / 手动压缩完成
|
||||
| (ConvState::Compressed, ConvState::Generating)
|
||||
| (ConvState::Compressed, ConvState::Idle)
|
||||
);
|
||||
if valid {
|
||||
Ok(target)
|
||||
} else {
|
||||
Err(InvalidTransition { from: self, to: target })
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否处于活跃生成态(`Generating` / 压缩派生 `Compressed`)。
|
||||
///
|
||||
/// 读侧便利方法:压缩派生期间 loop 仍活跃(只是临时跑压缩 LLM),归「活跃」。
|
||||
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
|
||||
/// 故本方法返回 true——审批挂起不算「停止生成」。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。本批仅建状态机 +
|
||||
/// 入口校验,无消费方,标 `allow(dead_code)` 保留(对齐零调用方区分真死 vs 预留原则)。
|
||||
#[allow(dead_code)]
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, ConvState::Generating | ConvState::Compressed)
|
||||
}
|
||||
|
||||
/// 是否可接受新请求(非活跃、非停止中、非压缩派生)。
|
||||
///
|
||||
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
|
||||
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。
|
||||
#[allow(dead_code)]
|
||||
pub fn can_accept_request(self) -> bool {
|
||||
matches!(self, ConvState::Idle | ConvState::Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// InvalidTransition(非法转换错误)
|
||||
// ============================================================
|
||||
|
||||
/// 状态机非法转换错误。
|
||||
///
|
||||
/// 携带 `from` / `to` 供调用方记录诊断日志(不 panic:状态机完整性违反应记 warn +
|
||||
/// 兜底走旧 bool 行为,不阻断核心生成流程——对齐「机制优先,失败兜底」原则)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InvalidTransition {
|
||||
pub from: ConvState,
|
||||
pub to: ConvState,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InvalidTransition {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "ConvState 非法转换:{:?} → {:?}", self.from, self.to)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidTransition {}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(纯逻辑无 IO)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- 合法转换通过 ----
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_generating() {
|
||||
assert_eq!(
|
||||
ConvState::Idle.transition_to(ConvState::Generating),
|
||||
Ok(ConvState::Generating)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_stopping() {
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Stopping),
|
||||
Ok(ConvState::Stopping)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_idle_normal_complete() {
|
||||
// loop 正常收敛退出
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_error_provider_fatal() {
|
||||
// provider Fatal / 断路器熔断
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Error),
|
||||
Ok(ConvState::Error)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_compressed_loop_auto_compress() {
|
||||
// loop 内自动压缩派生
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Compressed),
|
||||
Ok(ConvState::Compressed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_idle_complete() {
|
||||
// stop 收尾完成
|
||||
assert_eq!(
|
||||
ConvState::Stopping.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_error() {
|
||||
assert_eq!(
|
||||
ConvState::Stopping.transition_to(ConvState::Error),
|
||||
Ok(ConvState::Error)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_idle_retry_prep() {
|
||||
// 用户重试前置位
|
||||
assert_eq!(
|
||||
ConvState::Error.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_generating_retry() {
|
||||
// 直接从错误态重新生成
|
||||
assert_eq!(
|
||||
ConvState::Error.transition_to(ConvState::Generating),
|
||||
Ok(ConvState::Generating)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_generating_loop_continue() {
|
||||
// 压缩完成 loop 内继续
|
||||
assert_eq!(
|
||||
ConvState::Compressed.transition_to(ConvState::Generating),
|
||||
Ok(ConvState::Generating)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_idle_manual_compress_done() {
|
||||
// 手动压缩 IPC 完成(无 loop 活跃)
|
||||
assert_eq!(
|
||||
ConvState::Compressed.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 自环幂等 ----
|
||||
|
||||
#[test]
|
||||
fn test_self_transition_idempotent() {
|
||||
// 同态迁移直接通过(防调用方重复 set 报错)
|
||||
for s in [
|
||||
ConvState::Idle,
|
||||
ConvState::Generating,
|
||||
ConvState::Stopping,
|
||||
ConvState::Error,
|
||||
ConvState::Compressed,
|
||||
] {
|
||||
assert_eq!(s.transition_to(s), Ok(s), "{:?} 自环应通过", s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 非法转换拒绝 ----
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_stopping_rejected() {
|
||||
// 无活跃 loop 不可能停止
|
||||
let err = ConvState::Idle
|
||||
.transition_to(ConvState::Stopping)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.from, ConvState::Idle);
|
||||
assert_eq!(err.to, ConvState::Stopping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_error_rejected() {
|
||||
// Idle 直接到 Error 无意义(错误必经活跃态产生)
|
||||
assert!(ConvState::Idle.transition_to(ConvState::Error).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_compressed_rejected() {
|
||||
// 无 loop 活跃不会触发压缩派生(手动压缩 IPC 不经状态机派生)
|
||||
assert!(ConvState::Idle.transition_to(ConvState::Compressed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_generating_rejected() {
|
||||
// 停止中不能直接回生成(须先回 Idle 再起)
|
||||
assert!(ConvState::Stopping
|
||||
.transition_to(ConvState::Generating)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_compressed_rejected() {
|
||||
assert!(ConvState::Stopping
|
||||
.transition_to(ConvState::Compressed)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_stopping_rejected() {
|
||||
// 错误态已停止,不能再停
|
||||
assert!(ConvState::Error.transition_to(ConvState::Stopping).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_compressed_rejected() {
|
||||
assert!(ConvState::Error.transition_to(ConvState::Compressed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_stopping_rejected() {
|
||||
// 压缩派生期间不直接到停止(压缩完成后由原态分流)
|
||||
assert!(ConvState::Compressed
|
||||
.transition_to(ConvState::Stopping)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_error_rejected() {
|
||||
assert!(ConvState::Compressed
|
||||
.transition_to(ConvState::Error)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// ---- 便利方法 ----
|
||||
|
||||
#[test]
|
||||
fn test_is_active() {
|
||||
assert!(!ConvState::Idle.is_active());
|
||||
assert!(ConvState::Generating.is_active());
|
||||
assert!(!ConvState::Stopping.is_active());
|
||||
assert!(!ConvState::Error.is_active());
|
||||
// 压缩派生期间 loop 仍活跃(临时跑压缩 LLM)
|
||||
assert!(ConvState::Compressed.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_accept_request() {
|
||||
assert!(ConvState::Idle.can_accept_request());
|
||||
assert!(!ConvState::Generating.can_accept_request());
|
||||
assert!(!ConvState::Stopping.can_accept_request());
|
||||
// Error 态可接(用户重试即从 Error 起步)
|
||||
assert!(ConvState::Error.can_accept_request());
|
||||
assert!(!ConvState::Compressed.can_accept_request());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_idle() {
|
||||
assert_eq!(ConvState::default(), ConvState::Idle);
|
||||
}
|
||||
|
||||
// ---- 完整生命周期链 ----
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_normal_complete() {
|
||||
// 正常生命周期:Idle → Generating → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_with_stop() {
|
||||
// 停止生命周期:Idle → Generating → Stopping → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Stopping).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_with_compress() {
|
||||
// 压缩派生:Idle → Generating → Compressed → Generating → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Compressed).unwrap();
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_error_retry() {
|
||||
// 错误恢复:Idle → Generating → Error → Generating → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Error).unwrap();
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_transition_error_display() {
|
||||
// Display 包含 from / to,供日志诊断
|
||||
let err = ConvState::Idle
|
||||
.transition_to(ConvState::Stopping)
|
||||
.unwrap_err();
|
||||
let msg = format!("{}", err);
|
||||
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
|
||||
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::ai::AiSession;
|
||||
|
||||
use super::conv_state::{self, ConvState};
|
||||
|
||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
||||
///
|
||||
/// 两路复位:
|
||||
@@ -22,32 +24,87 @@ use crate::commands::ai::AiSession;
|
||||
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
|
||||
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
|
||||
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
|
||||
///
|
||||
/// L2 统一状态机(渐进第一步,2026-06-21):guard 内嵌 `ConvState` 作**写收敛视图层**。
|
||||
///
|
||||
/// - `new` 时按 `conv_state::CONV_STATE_ENABLED` 门控,把内部 `state` 从 `Idle` 经守卫迁移到
|
||||
/// `Generating`(非法转换记 warn 不阻断,对齐「机制优先,失败兜底」原则)。
|
||||
/// - `reset` / `drop` 收尾时同步迁移 `Generating → Idle`(状态机视图与 `generating` bool 双轨,
|
||||
/// bool 仍是核心真相源,enum 仅视图)。off 降级:guard 不持/不迁 enum,纯旧 bool 行为(可回退)。
|
||||
///
|
||||
/// **渐进取舍(为什么 enum 放 guard 而非 PerConvState 字段)**:guard 是 loop 生命周期的天然
|
||||
/// 边界(set/reset/Drop 成对),把 enum 绑 guard 即覆盖全部生成态进入/退出点,无需改 PerConvState
|
||||
/// 字段(改字段会牵动所有读写点,属批2+ 范围)。本批 enum 仅作 guard 内部视图,不对外持久化;
|
||||
/// 批2+ 把 enum 提升到 PerConvState.conv_state 字段后,guard 仍可保留作写收敛入口(枚举源切换)。
|
||||
pub(super) struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
|
||||
conv_id: String,
|
||||
done: bool,
|
||||
/// L2 状态机视图(CONV_STATE_ENABLED 门控)。None = 开关 off 降级,guard 不持视图。
|
||||
/// 仅 guard 生命周期内的本地视图,不持久化(批2+ 提升至 PerConvState 字段)。
|
||||
state: Option<ConvState>,
|
||||
}
|
||||
|
||||
impl GeneratingGuard {
|
||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String) -> Self {
|
||||
Self { session, conv_id, done: false }
|
||||
// CONV_STATE_ENABLED 门控:off → state=None,guard 不持/不迁 enum(纯旧 bool 行为)。
|
||||
// on → state 从 Idle 起,入口即经守卫迁移到 Generating(写收敛:状态机入口校验)。
|
||||
let mut state = if conv_state::CONV_STATE_ENABLED {
|
||||
Some(ConvState::Idle)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = state.as_mut() {
|
||||
// Idle → Generating 守卫迁移。正常路径(self-loop 幂等 / 首次进入)合法;理论非法不发生
|
||||
// (新 guard 必 Idle),失败记 warn 不阻断(对齐失败兜底原则)。
|
||||
match s.transition_to(ConvState::Generating) {
|
||||
Ok(new_state) => *s = new_state,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.new ConvState Idle→Generating 非法(状态机视图层,不阻断核心生成)"
|
||||
);
|
||||
// 状态机层失败:enum 保留 Idle(不写非法态),核心 generating 仍由调用方 set true。
|
||||
// 即 enum 视图与 bool 暂时不同步,但 enum 仅视图不影响核心复位语义。
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { session, conv_id, done: false, state }
|
||||
}
|
||||
|
||||
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
|
||||
///
|
||||
/// 仅写 per_conv.conv_id.generating(唯一真相源)。
|
||||
/// L2:同步迁移 `ConvState → Idle`(状态机视图,CONV_STATE_ENABLED 门控)。
|
||||
pub(super) async fn reset(&mut self) {
|
||||
if !self.done {
|
||||
let mut session = self.session.lock().await;
|
||||
session.conv(&self.conv_id).generating = false;
|
||||
self.done = true;
|
||||
}
|
||||
// 状态机视图同步:Generating → Idle(正常收敛退出)。仅记日志验证迁移合法(批2+ 接入
|
||||
// PerConvState.conv_state 字段后此处写字段持久化)。done=true 后再次 reset 幂等,enum 已 Idle
|
||||
// 自环通过。开关 off → state=None 跳过(纯旧 bool 行为)。
|
||||
if let Some(s) = self.state.as_mut() {
|
||||
match s.transition_to(ConvState::Idle) {
|
||||
Ok(new_state) => *s = new_state,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.reset ConvState →Idle 非法(状态机视图层,不阻断核心复位)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
|
||||
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
|
||||
/// (B-260615-26: 修复审批执行后对话不续生成回归)
|
||||
///
|
||||
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
|
||||
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
@@ -55,7 +112,19 @@ impl GeneratingGuard {
|
||||
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
// 状态机视图同步(panic/异常退出路径):未 done 即异常退出,enum 应从 Generating→Idle 收敛。
|
||||
// 仅记日志(无 await 上下文,不写持久化——批2+ 接入字段后此处可写)。
|
||||
// 开关 off / done=true(正常 reset/disarm 已走) → 跳过。
|
||||
if !self.done {
|
||||
if let Some(s) = self.state.as_mut() {
|
||||
if let Err(e) = s.transition_to(ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.drop ConvState →Idle 非法(异常退出兜底,核心 generating 仍 spawn 复位)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let session = self.session.clone();
|
||||
let conv_id = self.conv_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -65,3 +134,52 @@ impl Drop for GeneratingGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(guard 内嵌 ConvState 视图层)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// guard.new 默认迁移 Idle→Generating,内部视图应为 Generating(CONV_STATE_ENABLED on)。
|
||||
///
|
||||
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验 state 字段的迁移语义——
|
||||
/// new 内部 transition_to 是纯函数,直接断言逻辑路径。此处用相同的纯函数路径验证 guard 语义。
|
||||
#[test]
|
||||
fn test_guard_new_migrates_to_generating() {
|
||||
// 模拟 guard.new 内部状态机迁移路径(纯逻辑,无 session):
|
||||
// CONV_STATE_ENABLED on → Idle → Generating。
|
||||
assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设");
|
||||
let mut s = ConvState::Idle;
|
||||
s = s.transition_to(ConvState::Generating).unwrap();
|
||||
assert_eq!(s, ConvState::Generating);
|
||||
}
|
||||
|
||||
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_reset_migrates_to_idle() {
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_drop_path_migrates_to_idle() {
|
||||
// guard.drop 异常路径(!done)迁移 Generating→Idle。
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// disarm 不迁移状态(审批等待保持 Generating)。
|
||||
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
|
||||
#[test]
|
||||
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
|
||||
let s = ConvState::Generating;
|
||||
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
|
||||
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use df_ai::context_helpers::{
|
||||
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
||||
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
||||
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
||||
use df_ai::retry;
|
||||
@@ -106,6 +106,30 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
|
||||
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
|
||||
pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
||||
///
|
||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
|
||||
/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 →
|
||||
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
|
||||
///
|
||||
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
|
||||
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
|
||||
|
||||
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
|
||||
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
|
||||
pub const CIRCUIT_BREAKER_ENABLED: bool = true;
|
||||
|
||||
/// L1 断路器熔断时是否发结构化求助(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21)。
|
||||
///
|
||||
/// true(默认):熔断 emit AiHelpRequired(结构化求助卡:reason + context + options),
|
||||
/// 引导用户换思路/授权路径/人工接管(机制优先 prompt 说教,非教 AI 自己止损)。
|
||||
/// false(兜底回退):熔断仍 emit AiError(旧行为,前端错误气泡),用于求助卡未就绪/
|
||||
/// 排障/对比。两路保留 guard.reset + return 强制熔断语义不变,仅换前端呈现形态。
|
||||
/// 配合 CIRCUIT_BREAKER_ENABLED:CIRCUIT_BREAKER_ENABLED=false 时断路器整段跳过,
|
||||
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
|
||||
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
|
||||
|
||||
// 阶段2(path_auth 审批链重构):占位配对完整性开关(解 400 orphan)。
|
||||
//
|
||||
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
|
||||
@@ -131,6 +155,22 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
mod guard;
|
||||
use guard::GeneratingGuard;
|
||||
|
||||
// ============================================================
|
||||
// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。
|
||||
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
||||
//
|
||||
// 本批范围(渐进):
|
||||
// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。
|
||||
// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState
|
||||
// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。
|
||||
// - guard.reset 同步 ConvState→Idle(正常退出路径)。
|
||||
// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。
|
||||
//
|
||||
// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。
|
||||
// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。
|
||||
// ============================================================
|
||||
pub mod conv_state;
|
||||
|
||||
// ============================================================
|
||||
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
||||
// ============================================================
|
||||
@@ -374,6 +414,11 @@ pub(crate) async fn run_agentic_loop(
|
||||
//
|
||||
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
||||
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
||||
//
|
||||
// L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。
|
||||
// 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum
|
||||
// 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
@@ -601,6 +646,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
|
||||
// L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。
|
||||
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
|
||||
let mut last_reasoning_content: Option<String> = None;
|
||||
|
||||
@@ -1240,6 +1289,95 @@ pub(crate) async fn run_agentic_loop(
|
||||
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
|
||||
);
|
||||
|
||||
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
|
||||
// CIRCUIT_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
|
||||
// 仅检查自动执行(Low)的工具结果——pending_count>0(待审批)交给下方审批分支,
|
||||
// 此处只看已回填的 Tool 消息。取末尾连续 Tool 消息(倒序 take_while role==Tool),
|
||||
// 失败内容前 40 字符归一 key 计数,同 key 累计达阈值 → guard.reset + AiError + return。
|
||||
if CIRCUIT_BREAKER_ENABLED {
|
||||
let (max_count, sample_key) = {
|
||||
let session = session_arc.lock().await;
|
||||
// conv 可能已被删除(stop/新对话),get 不到 → 无消息可判,跳过本轮断路器检查。
|
||||
let messages = match session.conv_read(&conv_id) {
|
||||
Some(conv) => conv.messages.all_messages_clone(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果;非 Tool 即停)。
|
||||
// MessageRole 未派生 PartialEq,用 matches! 宏判变体(不改共享类型 df-ai-core)。
|
||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
for m in recent_tool_results {
|
||||
let content = m.content.as_str();
|
||||
// 失败判定:禁止/跳过重试/失败/Error 关键词(覆盖权限拒绝/路径错误/异常等)。
|
||||
let is_failure = content.starts_with("禁止")
|
||||
|| content.starts_with("已跳过重试")
|
||||
|| content.contains("失败")
|
||||
|| content.contains("Error")
|
||||
|| content.contains("error");
|
||||
if is_failure {
|
||||
// 前 40 字符归一 key:同类失败(同前缀)累加,不同错误各自计数互不干扰。
|
||||
let key: String = content.chars().take(40).collect();
|
||||
*fail_counts.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
// 取当前最大计数及其 key(无失败 → max_count=0,不触发)。
|
||||
fail_counts
|
||||
.iter()
|
||||
.max_by_key(|(_, &v)| v)
|
||||
.map(|(k, &v)| (v, k.clone()))
|
||||
.unwrap_or((0u32, String::new()))
|
||||
};
|
||||
// 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。
|
||||
if max_count >= CIRCUIT_BREAKER_THRESHOLD {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
max_count,
|
||||
sample_key = %sample_key,
|
||||
"[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count
|
||||
);
|
||||
guard.reset().await;
|
||||
// L1 求助协议(§2.3,2026-06-21):熔断改发结构化 AiHelpRequired(机制优先 prompt 说教)。
|
||||
// 开关 CIRCUIT_BREAKER_HELP_EVENT=true(默认)→ AiHelpRequired(求助卡,显 reason/options
|
||||
// 供用户选);false(兜底回退)→ AiError(旧错误气泡)。两路均 guard.reset + return 强制熔断,
|
||||
// 仅前端呈现形态不同,语义不变(均终止 loop,逼用户介入)。
|
||||
if CIRCUIT_BREAKER_HELP_EVENT {
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!(
|
||||
"连续同类失败 {} 次,疑似死循环已停止",
|
||||
max_count
|
||||
),
|
||||
context: format!("最近错误: {}", sample_key),
|
||||
options: vec![
|
||||
"换思路".into(),
|
||||
"授权路径".into(),
|
||||
"人工接管".into(),
|
||||
],
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 兜底回退:求助卡未就绪/排障/对比时,沿用旧 AiError 错误气泡呈现。
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiError {
|
||||
error: format!(
|
||||
"连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}",
|
||||
max_count, sample_key
|
||||
),
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
|
||||
if pending_count > 0 {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
|
||||
@@ -30,7 +30,7 @@ use super::super::augmentation::build_augmentation_segment;
|
||||
use super::super::augmentation::sanitize;
|
||||
use super::super::conversation::save_conversation;
|
||||
use super::super::knowledge_inject::inject_knowledge_into_prompt;
|
||||
use super::super::prompt::build_system_prompt;
|
||||
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};
|
||||
|
||||
use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
||||
|
||||
@@ -137,6 +137,29 @@ fn span_to_mention_ref(span: &MentionSpanDto) -> Option<MentionRef> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 mention_spans 提取 (被@的项目 id 列表, 被@的任务 id 列表)。
|
||||
///
|
||||
/// 用于 [`build_system_prompt_with_excluded`] 的去重入参:被@的 project/task 已有 augmentation
|
||||
/// 精准投影,需从全局清单中排除以免同一实体两次入 prompt(skill/idea 不在清单内,无需排除)。
|
||||
///
|
||||
/// - `None` / 空 spans → 两个空 vec(行为退化为无去重的全貌清单,等同旧 build_system_prompt)。
|
||||
/// - 仅收集 kind == "project" / "task" 的 `ref_id`(idea/skill 不进项目/任务清单)。
|
||||
/// - 不去重(同实体被@多次理论上不会出现,即便出现 excluded 列表多次命中也只是 continue,无副作用)。
|
||||
fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<String>, Vec<String>) {
|
||||
let mut proj = Vec::new();
|
||||
let mut task = Vec::new();
|
||||
if let Some(spans) = spans.as_ref() {
|
||||
for span in spans {
|
||||
match span.kind.as_str() {
|
||||
"project" => proj.push(span.ref_id.clone()),
|
||||
"task" => task.push(span.ref_id.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(proj, task)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 发送 / 审批 / 控制
|
||||
// ============================================================
|
||||
@@ -363,7 +386,11 @@ pub async fn ai_chat_send(
|
||||
// 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let mut system_prompt = build_system_prompt(&state, &lang).await;
|
||||
// 去重:从 mention_spans 提取被@的 project/task id,传给 prompt 构建器从全局清单中排除
|
||||
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
|
||||
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
|
||||
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
|
||||
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
|
||||
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
@@ -1353,7 +1380,9 @@ pub async fn ai_chat_force_send(
|
||||
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let mut system_prompt = build_system_prompt(&state, &lang).await;
|
||||
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
|
||||
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
|
||||
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
|
||||
375
src-tauri/src/commands/ai/event_bus.rs
Normal file
375
src-tauri/src/commands/ai/event_bus.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
//! 全局事件数据总线 — pub-sub 骨架(L3 阶段1,渐进第一步)
|
||||
//!
|
||||
//! 关联设计:docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md
|
||||
//!
|
||||
//! 本模块是 **统一事件数据总线(Unified Event Bus)** 的后端骨架,聚焦 **pub-sub**(发布订阅)
|
||||
//! 语义。背景:当前 AI 事件经 `app_handle.emit("ai-chat-event", ...)` 散布在各处,无统一总线,
|
||||
//! 跨模块直接耦合,阻碍 ai-working 可插拔 + 跨端透传。
|
||||
//!
|
||||
//! ## 本批范围(L3 骨架,纯新增无行为变化)
|
||||
//! - `EventBus` 结构(基于 tokio::sync::broadcast,容量 256)
|
||||
//! - `AiBusEvent` 强类型 enum(覆盖关键事件:Stream/Tool/Round/Help/Heartbeat/Completed/Error,
|
||||
//! 对齐现有 `AiChatEvent` 子集)
|
||||
//! - `subscribe()` / `publish()` 基础接口
|
||||
//! - `EVENT_BUS_ENABLED` 开关(预留,默认 on —— 总线骨架自带,但接入实际事件源时此开关控制是否经总线)
|
||||
//! - pub/sub 基础单元测试
|
||||
//!
|
||||
//! ## 不做(留后续批次)
|
||||
//! - 不接入 `agentic/mod.rs` 各 emit 点(B 域,留批2接入)
|
||||
//! - 不入 `AppState`(接入留批2)
|
||||
//! - 不碰前端订阅渲染(留批2 Tauri bridge)
|
||||
//! - 不实现 request-reply / 流式 reply(设计阶段2/5,本批仅 pub-sub)
|
||||
//! - 不做跨端透传(df-tunnel adapter,阶段6)
|
||||
//!
|
||||
//! ## 开关与兜底(渐进可回退)
|
||||
//! - `EVENT_BUS_ENABLED: bool`(默认 true):接入事件源后,关闭则 publish 静默丢弃(不影响原 emit 路径)。
|
||||
//! 骨架阶段未接入,无实际效果,预留作接入期的快速回退开关。
|
||||
//! - publish 返回 `usize`(接收者数量),调用方可忽略;无接收者时不报错(broadcast 语义)。
|
||||
//! - 慢消费者(broadcast 满):`send` 返 `Err(SendError)`,本骨架 `publish` 静默丢弃并返回 0,
|
||||
//! 不 panic(对齐设计待决策6「背压/限流」的保守默认,正式背压策略留后续)。
|
||||
//!
|
||||
//! ## 与 df-workflow EventBus 关系
|
||||
//! df-workflow 的 `EventBus`(`crates/df-workflow/src/eventbus.rs`)是工作流节点间专用总线
|
||||
//! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(对齐 `AiChatEvent` 子集)。
|
||||
//! 设计阶段1 计划「df-workflow EventBus 包装为通用 DomainEvent」,本骨架先建 AI 域总线,
|
||||
//! 通用 DomainEvent 泛化留后续(待决策1 bus crate 位置 + 待决策2 事件 schema)。
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
// ============================================================
|
||||
// 开关(EVENT_BUS_ENABLED,预留,默认 on)
|
||||
//
|
||||
// 机制:接入事件源后,关闭则 EventBus::publish 静默丢弃事件(原 app.emit 路径不受影响,
|
||||
// 双写桥接由接入批负责,关闭总线仅丢总线副本)。骨架阶段未接入,开关预留作接入期快速回退。
|
||||
//
|
||||
// 兜底:即便开关误关,总线静默丢事件不影响原 emit 路径(AiChatEvent 经 app.emit 仍正常推送前端)。
|
||||
// ============================================================
|
||||
|
||||
/// 事件总线开关(预留,默认 on)。
|
||||
///
|
||||
/// 接入实际事件源后,`EventBus::publish` 在 `false` 时静默丢弃(返回 0),原 `app.emit`
|
||||
/// 路径不受影响。骨架阶段未接入,无实际效果。
|
||||
///
|
||||
/// dead_code 说明:本批为骨架,EVENT_BUS_ENABLED 暂无消费方(未接入 emit 点);
|
||||
/// 标 allow 保留作批2接入期的快速回退开关(零调用方≠垃圾,预留保留)。
|
||||
#[allow(dead_code)]
|
||||
pub const EVENT_BUS_ENABLED: bool = true;
|
||||
|
||||
/// 默认事件通道容量(对齐 df-workflow EventBus 默认 256)。
|
||||
///
|
||||
/// 容量权衡:太小 → 慢消费者丢事件(订阅者消费不及时);太大 → 内存占用。
|
||||
/// 256 是 broadcast 常见默认值,覆盖典型 AI 流场景(文本片段 + 工具调用 + 心跳混合)。
|
||||
///
|
||||
/// dead_code 说明:骨架阶段 EventBus::new() 内联用字面量路径常量,但常量本身供
|
||||
/// 外部自定义容量场景引用,标 allow 保留(预留)。
|
||||
#[allow(dead_code)]
|
||||
pub const DEFAULT_BUS_CAPACITY: usize = 256;
|
||||
|
||||
// ============================================================
|
||||
// AiBusEvent — 总线事件类型(对齐 AiChatEvent 子集)
|
||||
//
|
||||
// 设计取舍(对齐设计文档待决策2「事件 schema」倾向:强类型 + 开放式):
|
||||
// - 强类型 enum:编译期安全,新增事件类型需改 enum(扩展成本可控,AI 域事件类型有限且稳定)
|
||||
// - 覆盖关键事件:Stream(流式文本)/Tool(工具调用)/Round(agent 新轮)/Help(求助)/
|
||||
// Heartbeat(心跳)/Completed(完成)/Error(错误) —— 对齐现有 AiChatEvent 高频子集
|
||||
//
|
||||
// 与 AiChatEvent 区别:
|
||||
// - AiChatEvent 是「后端→前端单向推送载荷」(经 app.emit,Tauri 序列化,前端消费)
|
||||
// - AiBusEvent 是「总线内部事件」(经 broadcast channel,后端模块间消费,设计上可桥接到前端)
|
||||
// 本骨架 AiBusEvent 复用 AiChatEvent 字段语义,但作为独立类型(总线事件 ≠ 前端载荷,未来
|
||||
// 可能分叉:如总线事件携带 reply_to / correlation_id 等 request-reply 元数据)。
|
||||
//
|
||||
// 序列化:derive Serialize/Deserialize 备跨端透传(serde JSON,待决策4);Clone 备 broadcast
|
||||
// 多订阅者复制;Debug 备日志诊断。
|
||||
// ============================================================
|
||||
|
||||
/// AI 事件总线事件(pub-sub 域,对齐 AiChatEvent 子集)。
|
||||
///
|
||||
/// 总线订阅者经 `EventBus::subscribe()` 拿 Receiver 后 recv 本类型事件。
|
||||
/// 事件类型覆盖关键 AI 生命周期:流式文本 / 工具调用 / agent 新轮 / 求助 / 心跳 / 完成 / 错误。
|
||||
///
|
||||
/// dead_code 说明:骨架阶段 enum 本身未在 emit 点构造(批2接入),标 allow 保留
|
||||
/// (零调用方≠垃圾,本枚举为总线核心契约,批2接入立即消费)。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AiBusEvent {
|
||||
/// 流式文本片段(对齐 AiChatEvent::AiTextDelta)
|
||||
Stream {
|
||||
delta: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 工具调用开始(对齐 AiChatEvent::AiToolCallStarted)
|
||||
ToolStarted {
|
||||
id: String,
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 工具调用完成(对齐 AiChatEvent::AiToolCallCompleted)
|
||||
ToolCompleted {
|
||||
id: String,
|
||||
result: serde_json::Value,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Agent 循环新一轮(对齐 AiChatEvent::AiAgentRound)
|
||||
Round {
|
||||
round: u32,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 求助(对齐 AiChatEvent::AiHelpRequired,L1 求助协议)
|
||||
Help {
|
||||
reason: String,
|
||||
context: String,
|
||||
options: Vec<String>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 流式心跳(对齐 AiChatEvent::AiHeartbeat,静默期报活)
|
||||
Heartbeat {
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// AI 响应完成(对齐 AiChatEvent::AiCompleted)
|
||||
Completed {
|
||||
total_tokens: u32,
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 错误(对齐 AiChatEvent::AiError)
|
||||
Error {
|
||||
error: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线
|
||||
//
|
||||
// 选 broadcast 而非 mpsc:
|
||||
// - pub-sub 多订阅者(mpsc 单消费者不满足「多模块订阅同一事件」场景)
|
||||
// - broadcast 容量固定,慢消费者丢老事件而非阻塞发布者(对齐流式场景:宁可丢老 chunk 不阻塞 LLM 流)
|
||||
//
|
||||
// Clone 语义:broadcast::Sender 内部 Arc,clone 共享同一通道(多持有者 publish 到同一总线)。
|
||||
// 对齐设计 §架构「模块订阅 + 发布,无相互 import」—— 各模块持 clone 的 EventBus 即可 pub/sub。
|
||||
// ============================================================
|
||||
|
||||
/// AI 事件总线(pub-sub 域,L3 阶段1 骨架)。
|
||||
///
|
||||
/// 基于 `tokio::sync::broadcast`,多订阅者多发布者共享同一通道。
|
||||
/// - `subscribe()`:订阅,返回 [`broadcast::Receiver`] recv [`AiBusEvent`]
|
||||
/// - `publish(event)`:发布,所有活跃订阅者收到(broadcast 语义)
|
||||
///
|
||||
/// 容量默认 256(`DEFAULT_BUS_CAPACITY`),可经 [`EventBus::with_capacity`] 自定义。
|
||||
///
|
||||
/// 兜底:
|
||||
/// - 无订阅者时 publish 不报错(broadcast 语义,事件丢弃)
|
||||
/// - 慢消费者(容量满)publish 返 0(静默丢老事件,不 panic)
|
||||
/// - `EVENT_BUS_ENABLED=false` 时 publish 静默丢弃(预留开关,接入期回退用)
|
||||
///
|
||||
/// dead_code 说明:骨架阶段未入 AppState、未在 emit 点构造(批2接入),标 allow 保留
|
||||
/// (总线核心结构,批2接入 AppState 即消费)。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<AiBusEvent>,
|
||||
}
|
||||
|
||||
/// 事件订阅者(broadcast::Receiver,AiBusEvent 接收端)。
|
||||
///
|
||||
/// dead_code 说明:骨架阶段无外部消费方(批2接入订阅),标 allow 保留。
|
||||
#[allow(dead_code)]
|
||||
pub type EventSubscriber = broadcast::Receiver<AiBusEvent>;
|
||||
|
||||
// dead_code 说明(impl 块):骨架阶段 EventBus 未入 AppState、无外部调用方
|
||||
// (批2接入 AppState + emit 点后立即消费)。标 allow 覆盖 new/with_capacity/
|
||||
// subscribe/publish/subscriber_count 全部关联项的 never-used 警告。
|
||||
// 对齐零调用方原则:预留保留不盲删(批2接入即消除)。
|
||||
#[allow(dead_code)]
|
||||
impl EventBus {
|
||||
/// 创建默认容量(256)的事件总线。
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_BUS_CAPACITY)
|
||||
}
|
||||
|
||||
/// 创建指定容量的事件总线。
|
||||
///
|
||||
/// capacity 为 0 会 panic(broadcast::channel 要求 capacity ≥ 1)。
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
let (sender, _) = broadcast::channel(capacity);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// 订阅事件总线,返回 Receiver。
|
||||
///
|
||||
/// 订阅后仅收到订阅时刻之后的 publish(历史事件不补发)。可多次 subscribe 获多个独立 Receiver,
|
||||
/// 每个 Receiver 各自维护消费进度(broadcast 多消费者语义)。
|
||||
pub fn subscribe(&self) -> EventSubscriber {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
|
||||
/// 发布事件到总线。
|
||||
///
|
||||
/// 返回值:成功送达的活跃订阅者数量(0 = 无订阅者或容量满丢老事件)。
|
||||
/// `EVENT_BUS_ENABLED=false` 时静默丢弃返回 0(预留开关,骨架阶段未接入)。
|
||||
///
|
||||
/// 注:broadcast::send 是同步方法(非 async),与 df-workflow EventBus::send 的 async 签名
|
||||
/// 不同 —— 本骨架 publish 同步返回更直接(broadcast::send 内部无 await 点),df-workflow 的
|
||||
/// async 签名是为对齐 trait 抽象,本总线无此约束故同步。
|
||||
pub fn publish(&self, event: AiBusEvent) -> usize {
|
||||
if !EVENT_BUS_ENABLED {
|
||||
return 0;
|
||||
}
|
||||
// broadcast::send 返 Result<usize, SendError>:
|
||||
// Ok(n) = 送达 n 个活跃订阅者
|
||||
// Err(SendError(_)) = 无订阅者(事件丢弃)
|
||||
// 两种情况都不 panic,Err 视为 0(对齐「无订阅者静默丢弃」兜底)。
|
||||
self.sender.send(event).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 当前活跃订阅者数量(broadcast::receiver_count)。
|
||||
///
|
||||
/// 诊断/监控用:接入期可观测订阅者是否到位。骨架阶段无实际调用,预留作可观测性扩展。
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.sender.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — pub/sub 基础语义
|
||||
//
|
||||
// 覆盖:
|
||||
// 1. subscribe 后 publish,receiver 收到事件(基础 pub-sub)
|
||||
// 2. 多订阅者都收到(broadcast 多消费者)
|
||||
// 3. 无订阅者时 publish 不 panic(兜底)
|
||||
// 4. publish 返回值 = 活跃订阅者数量
|
||||
//
|
||||
// 不覆盖(留后续批次接入时):
|
||||
// - 慢消费者容量满丢老事件(需灌满容量场景,接入期补)
|
||||
// - 跨模块解耦实际效果(需接入真实 emit 点,批2)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 基础 pub-sub:subscribe 后 publish,receiver 收到事件。
|
||||
#[tokio::test]
|
||||
async fn test_basic_pub_sub() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
let event = AiBusEvent::Stream {
|
||||
delta: "hello".to_string(),
|
||||
conversation_id: Some("conv-1".to_string()),
|
||||
};
|
||||
|
||||
let delivered = bus.publish(event.clone());
|
||||
assert_eq!(delivered, 1, "publish 应送达 1 个订阅者");
|
||||
|
||||
let received = rx.recv().await.expect("receiver 应收到事件");
|
||||
match received {
|
||||
AiBusEvent::Stream { delta, conversation_id } => {
|
||||
assert_eq!(delta, "hello");
|
||||
assert_eq!(conversation_id.as_deref(), Some("conv-1"));
|
||||
}
|
||||
other => panic!("期望 Stream 事件,收到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// 多订阅者都收到(broadcast 多消费者语义)。
|
||||
#[tokio::test]
|
||||
async fn test_multiple_subscribers() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx1 = bus.subscribe();
|
||||
let mut rx2 = bus.subscribe();
|
||||
|
||||
let event = AiBusEvent::Heartbeat {
|
||||
conversation_id: None,
|
||||
};
|
||||
let delivered = bus.publish(event.clone());
|
||||
assert_eq!(delivered, 2, "publish 应送达 2 个订阅者");
|
||||
|
||||
// 两个 receiver 各自收到(独立消费进度)
|
||||
let r1 = rx1.recv().await.expect("rx1 应收到");
|
||||
let r2 = rx2.recv().await.expect("rx2 应收到");
|
||||
assert!(matches!(r1, AiBusEvent::Heartbeat { .. }));
|
||||
assert!(matches!(r2, AiBusEvent::Heartbeat { .. }));
|
||||
}
|
||||
|
||||
/// 无订阅者时 publish 不 panic,返回 0(兜底)。
|
||||
#[test]
|
||||
fn test_publish_no_subscribers() {
|
||||
let bus = EventBus::new();
|
||||
// 无 subscribe 直接 publish
|
||||
let delivered = bus.publish(AiBusEvent::Error {
|
||||
error: "no one listening".to_string(),
|
||||
conversation_id: None,
|
||||
});
|
||||
assert_eq!(delivered, 0, "无订阅者 publish 应返回 0");
|
||||
}
|
||||
|
||||
/// publish 返回值 = 活跃订阅者数量(subscriber_count 对齐)。
|
||||
#[test]
|
||||
fn test_delivered_count_matches_subscribers() {
|
||||
let bus = EventBus::new();
|
||||
assert_eq!(bus.subscriber_count(), 0);
|
||||
|
||||
let _rx1 = bus.subscribe();
|
||||
assert_eq!(bus.subscriber_count(), 1);
|
||||
|
||||
let _rx2 = bus.subscribe();
|
||||
assert_eq!(bus.subscriber_count(), 2);
|
||||
|
||||
// delivered 应等于 subscriber_count
|
||||
let delivered = bus.publish(AiBusEvent::Round {
|
||||
round: 1,
|
||||
conversation_id: None,
|
||||
});
|
||||
assert_eq!(delivered, bus.subscriber_count());
|
||||
}
|
||||
|
||||
/// EventBus Clone 后共享同一通道(同一总线多持有者)。
|
||||
#[tokio::test]
|
||||
async fn test_clone_shares_channel() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
// 经 clone 的实例 publish,原实例订阅的 receiver 也应收到(共享通道)
|
||||
bus_clone.publish(AiBusEvent::Completed {
|
||||
total_tokens: 100,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 50,
|
||||
conversation_id: None,
|
||||
});
|
||||
|
||||
let received = rx.recv().await.expect("clone 共享通道,receiver 应收到");
|
||||
assert!(matches!(received, AiBusEvent::Completed { .. }));
|
||||
}
|
||||
|
||||
/// 全事件类型枚举可构造 + 可序列化(serde 备跨端透传)。
|
||||
#[test]
|
||||
fn test_event_variants_serde() {
|
||||
let events = vec![
|
||||
AiBusEvent::Stream { delta: "x".into(), conversation_id: None },
|
||||
AiBusEvent::ToolStarted { id: "t1".into(), name: "write_file".into(), args: serde_json::json!({}), conversation_id: None },
|
||||
AiBusEvent::ToolCompleted { id: "t1".into(), result: serde_json::json!({"ok": true}), conversation_id: None },
|
||||
AiBusEvent::Round { round: 2, conversation_id: None },
|
||||
AiBusEvent::Help { reason: "r".into(), context: "c".into(), options: vec!["a".into()], conversation_id: None },
|
||||
AiBusEvent::Heartbeat { conversation_id: None },
|
||||
AiBusEvent::Completed { total_tokens: 1, prompt_tokens: 1, completion_tokens: 1, conversation_id: None },
|
||||
AiBusEvent::Error { error: "e".into(), conversation_id: None },
|
||||
];
|
||||
// 每个变体都能序列化(serde tag = "type" 生效,备跨端透传)
|
||||
for event in &events {
|
||||
let json = serde_json::to_string(event).expect("事件应可序列化");
|
||||
assert!(json.contains("\"type\""), "序列化后应含 type tag: {}", json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! - [`prompt`] — 系统提示词构建
|
||||
//! - [`provider_pool`] — F-260614-04 多 Provider 负载均衡池选择(纯逻辑)
|
||||
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
|
||||
//! - [`event_bus`] — L3 全局事件数据总线 pub-sub 骨架(broadcast,渐进第一步,未接入 emit)
|
||||
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
|
||||
//! - [`knowledge_inject`] — 知识库注入 + 提炼
|
||||
//! - [`augmentation`] — 上下文增强
|
||||
@@ -31,6 +32,7 @@ pub mod audit;
|
||||
pub mod commands;
|
||||
pub mod compress;
|
||||
pub mod conversation;
|
||||
pub mod event_bus;
|
||||
pub mod http;
|
||||
pub mod knowledge_inject;
|
||||
pub mod prompt;
|
||||
@@ -175,6 +177,26 @@ pub enum AiChatEvent {
|
||||
AiCompressing { conversation_id: Option<String> },
|
||||
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
|
||||
AiCompressed { conversation_id: Option<String>, summary: String },
|
||||
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
///
|
||||
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
||||
/// 「失败就问用户」——LLM 不可靠,故用结构化事件 + 前端求助卡(机制优先 prompt 说教)。
|
||||
///
|
||||
/// 字段语义:
|
||||
/// - `reason`:求助原因(死循环/环境未知/权限不足等可读摘要,前端直接展示)
|
||||
/// - `context`:已试情况(失败次数/最近错误 sample/卡在哪),供用户判断如何介入
|
||||
/// - `options`:建议选项(如 [换思路/授权路径/人工接管]),前端渲染为按钮供用户选
|
||||
/// - `conversation_id`:多对话路由(与其他事件一致)
|
||||
///
|
||||
/// 与 AiApprovalRequired 区分:审批=工具执行前确认;求助=AI 主动「我搞不定」。
|
||||
/// 与 AiMaxRoundsReached 区分:达 max 是被动截断;求助是主动识别卡点。
|
||||
/// 兜底:开关 CIRCUIT_BREAKER_ENABLED=false 或求助消费方未接 → 后端保留 AiError 终态分支兜底。
|
||||
AiHelpRequired {
|
||||
reason: String,
|
||||
context: String,
|
||||
options: Vec<String>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -31,24 +31,31 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前运行环境信息(注入 system prompt,供 LLM 写日期/命令时参考)
|
||||
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
|
||||
/// L1 环境感知(治 kms seq26-52 引号地狱):教 AI 避命令行 -c 内联,复杂脚本写文件执行。
|
||||
///
|
||||
/// - 日期:LLM 无法自行获取当前日期,不注入则写文件日期全靠猜(常从项目已有文件名模式推断出错)
|
||||
/// - 操作系统:LLM 写 shell 命令需知道平台(dir vs ls、路径分隔符等)
|
||||
/// - 日期:LLM 无法自行获取,不注入则写文件日期全靠猜
|
||||
/// - OS + shell:平台对应 shell(Windows=PowerShell / Linux·macOS=bash),AI 写命令需对齐
|
||||
/// - 执行姿势:复杂脚本(多行/含引号/$变量)写 .ps1/.py 文件用 `powershell -File`/`python` 执行,
|
||||
/// 避免命令行 -c 内联(cmd/PowerShell 引号转义易出错,kms 实证 20+ 次失败)
|
||||
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
|
||||
///
|
||||
/// 两项合计约 25 token,消除一整类系统性错误。
|
||||
fn env_info_line() -> String {
|
||||
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
|
||||
fn env_profile_line() -> String {
|
||||
let today = chrono::Local::now().format("%Y-%m-%d");
|
||||
let os = if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
let (os, shell, interp) = if cfg!(target_os = "windows") {
|
||||
("Windows", "PowerShell", "python(无 python3)、node")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
("macOS", "bash/zsh", "python3、node")
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
("Linux", "bash", "python3、node")
|
||||
} else {
|
||||
"Unknown"
|
||||
("Unknown", "sh", "python3、node")
|
||||
};
|
||||
format!("当前日期: {today} | 运行环境: {os}\n\n")
|
||||
format!(
|
||||
"当前日期: {today} | 运行环境: {os} | shell: {shell}\n\
|
||||
执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
|
||||
@@ -113,9 +120,34 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
/// 非 build_system_prompt 职责 —— 两套并行:全局清单(全貌)+ augmentation(用户本次引用)。
|
||||
///
|
||||
/// 注入克制:仅各取最近 20 条,防 context 膨胀。
|
||||
///
|
||||
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
|
||||
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
|
||||
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
||||
build_system_prompt_with_excluded(state, lang, &[], &[]).await
|
||||
}
|
||||
|
||||
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
|
||||
///
|
||||
/// 与 [`build_system_prompt`] 的差异(机制而非说教):
|
||||
/// 1. **去重**:被 @ 的 project/task 已由 augmentation 层精准投影(含 path/正文),清单中
|
||||
/// 再现会造成同一实体两次入 prompt(实测 build f64dee94 会话 list_projects 8 次 /
|
||||
/// list_tasks 20 次)。此处把 `excluded_project_ids`/`excluded_task_ids` 命中的条目
|
||||
/// 从全局清单循环中跳过,被@实体只保留 augmentation 一次。
|
||||
/// 2. **注明语**:清单末尾按语言追加一行,告知 LLM 「项目已全部列出无需再调 list_projects」/
|
||||
/// 「任务仅最近 20 条,全量查询请用 list_tasks」,从机制层降低工具与清单重复调用
|
||||
/// (而非在行为准则里反复说教)。
|
||||
///
|
||||
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
|
||||
/// - 注入克制策略不变:仍各 take(20)。
|
||||
pub(crate) async fn build_system_prompt_with_excluded(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
excluded_project_ids: &[String],
|
||||
excluded_task_ids: &[String],
|
||||
) -> String {
|
||||
let (prefix, proj_label, task_label) = system_prompt_parts(lang);
|
||||
let mut prompt = env_info_line();
|
||||
let mut prompt = env_profile_line();
|
||||
prompt.push_str(prefix);
|
||||
|
||||
// 附加当前数据上下文
|
||||
@@ -123,9 +155,15 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
|
||||
if !projects.is_empty() {
|
||||
prompt.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) {
|
||||
continue;
|
||||
}
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description));
|
||||
}
|
||||
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
|
||||
prompt.push_str(&projects_listed_note(lang, 20));
|
||||
}
|
||||
}
|
||||
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
|
||||
@@ -133,15 +171,48 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
|
||||
if let Ok(tasks) = state.tasks.list_active().await {
|
||||
if !tasks.is_empty() {
|
||||
prompt.push_str(task_label);
|
||||
// 去重:被 @ 的任务跳过(同项目去重机理)
|
||||
for tk in tasks.iter().take(20) {
|
||||
if excluded_task_ids.iter().any(|id| id == &tk.id) {
|
||||
continue;
|
||||
}
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description));
|
||||
}
|
||||
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
|
||||
prompt.push_str(&tasks_listed_note(lang));
|
||||
}
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
/// 项目清单尾部注明语(中/英)。
|
||||
///
|
||||
/// 机制层降重复:直接告知「已全部列出」,LLM 知道再调 list_projects 是冗余。
|
||||
/// `shown` 取实际注入条数上限 20(避免传运态 list_active 长度,只描述清单本身)。
|
||||
fn projects_listed_note(lang: &str, shown: usize) -> String {
|
||||
match lang {
|
||||
"en" => format!(
|
||||
"(Listed {} active projects above; the active set is shown, no need to call list_projects again.)\n",
|
||||
shown
|
||||
),
|
||||
_ => format!(
|
||||
"(以上为活跃项目清单,无需重复调用 list_projects)\n"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务清单尾部注明语(中/英)。
|
||||
///
|
||||
/// 机制层降重复:明确「仅最近 20 条 + 全量查询走 list_tasks」,避免 LLM 误以为清单即全量
|
||||
/// 而反复调 list_tasks 校验(实测单会话 20 次)。
|
||||
fn tasks_listed_note(lang: &str) -> String {
|
||||
match lang {
|
||||
"en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool.)\n".to_string(),
|
||||
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具)\n".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文压缩 system prompt 模板(F-15 §4.2,纯函数无 IO)。
|
||||
///
|
||||
/// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留
|
||||
@@ -284,4 +355,20 @@ mod tests {
|
||||
assert!(en.contains("topic words"));
|
||||
assert!(en.contains("breaks the context"));
|
||||
}
|
||||
|
||||
// 项目/任务清单注明语(中/英)存在
|
||||
#[test]
|
||||
fn listed_notes_present_zh_en() {
|
||||
let zh_proj = projects_listed_note("zh-CN", 20);
|
||||
assert!(zh_proj.contains("无需重复调用 list_projects"));
|
||||
let en_proj = projects_listed_note("en", 20);
|
||||
assert!(en_proj.contains("no need to call list_projects"));
|
||||
|
||||
let zh_task = tasks_listed_note("zh-CN");
|
||||
assert!(zh_task.contains("仅显示最近 20 条任务"));
|
||||
assert!(zh_task.contains("list_tasks"));
|
||||
let en_task = tasks_listed_note("en");
|
||||
assert!(en_task.contains("20 most recent tasks"));
|
||||
assert!(en_task.contains("list_tasks"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,6 +1835,140 @@ fn register_file_tools(
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 环境感知 (Low risk, L1 agent 元能力层) ──
|
||||
// detect_environment:AI 主动探测运行环境的「眼睛」(设计 §2.1 补救式感知)。
|
||||
// 返回 JSON:{ os, default_shell, python_path, node_path }。
|
||||
//
|
||||
// 为什么需要:prompt.rs env_profile_line 只注入静态 OS+shell+解释器提示(编译期 cfg!),
|
||||
// AI 不知 python 真实路径/版本/node 是否安装/GBK 还是 UTF-8,撞墙后才补救。
|
||||
// 本工具让 AI 在「要做某环境相关操作前」(如跑 python 脚本/装依赖)主动探测,
|
||||
// 而非靠 prompt 教(机制优先 prompt 说教,见设计核心原则)。
|
||||
//
|
||||
// 接入断路器(设计 §2.2):后续断路器触发(run_command 连撞 N 次引号/路径错)后,
|
||||
// 系统提示会引导 AI 调本工具刷新环境认知(本工具不主动调断路器,仅提供能力)。
|
||||
//
|
||||
// 安全:Low risk 纯只读探测。which/where 只读 PATH 不写不删,无副作用。
|
||||
// 不走白名单(探测系统 PATH,非 workspace 文件),不审批(只读)。
|
||||
//
|
||||
// 开关(env_probe_enabled,设计 §2.1 §8):DEVFLOW_ENV_PROBE_ENABLED=off 关闭探测,
|
||||
// 返回静态 profile(编译期 OS + 默认 shell,与 env_profile_line 一致),兜底降级旧行为。
|
||||
// 默认 on(空值/未设/on/1/true 均视为开)。开关 off 不致工具调用失败,只退化能力。
|
||||
//
|
||||
// 兜底(设计 §2.1):探测失败(超时/python 未装/which 不可用)→ 该字段 null + errors 收集原因,
|
||||
// 不阻断工具返回。LLM 据此知「该能力不可用」,回退静态 profile 语义。
|
||||
registry.register(
|
||||
"detect_environment",
|
||||
"探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
|
||||
df_ai::ai_tools::object_schema(vec![]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|_args: serde_json::Value| Box::pin(async move {
|
||||
// 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
|
||||
let os = if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
// 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
|
||||
// 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
|
||||
// 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
|
||||
let default_shell = if cfg!(target_os = "windows") {
|
||||
"PowerShell"
|
||||
} else {
|
||||
"bash"
|
||||
};
|
||||
|
||||
// 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
|
||||
// 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
|
||||
let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
|
||||
Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
|
||||
Err(_) => true, // 未设 = 默认开
|
||||
};
|
||||
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let (python_path, node_path) = if probe_enabled {
|
||||
// Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
|
||||
// probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
|
||||
// Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
|
||||
let py = if cfg!(target_os = "windows") {
|
||||
probe_executable("python", "where python", &mut errors).await
|
||||
} else {
|
||||
let p3 = probe_executable("python3", "which python3", &mut errors).await;
|
||||
if p3.is_some() {
|
||||
p3
|
||||
} else {
|
||||
// python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
|
||||
probe_executable("python", "which python", &mut errors).await
|
||||
}
|
||||
};
|
||||
let node = if cfg!(target_os = "windows") {
|
||||
probe_executable("node", "where node", &mut errors).await
|
||||
} else {
|
||||
probe_executable("node", "which node", &mut errors).await
|
||||
};
|
||||
(py, node)
|
||||
} else {
|
||||
// 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"os": os,
|
||||
"default_shell": default_shell,
|
||||
"python_path": python_path,
|
||||
"node_path": node_path,
|
||||
"probe_enabled": probe_enabled,
|
||||
"errors": errors,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// 探测可执行文件路径(which/where 风格),8s 超时兜底。
|
||||
///
|
||||
/// 执行 `cmd`(如 "which python3" / "where node")取 stdout 首行(trim 后)作为路径。
|
||||
/// 失败(命令不存在/解释器未装/超时/退出码非 0)记 `error` 到 `errors` 并返回 None。
|
||||
///
|
||||
/// 超时兜底(对齐 workflow-cargo-timeout-wrap 记忆):防 which/where 偶发卡死拖垮会话。
|
||||
/// 用 df_execute::shell::execute(对齐 run_command 同源,Windows 无控制台窗口 + kill_on_drop),
|
||||
/// shell_type 用 default(Windows=PowerShell / Unix=sh)。
|
||||
async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec<String>) -> Option<String> {
|
||||
let request = ShellRequest {
|
||||
command: cmd.to_string(),
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
// 8s 超时:which/where 通常 <1s,8s 足够且不拖垮会话。超时视为不可用(记错返 None)。
|
||||
timeout_secs: Some(8),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
match execute(request).await {
|
||||
Ok(result) => {
|
||||
// 退出码非 0:which/where 未找到目标(如 python 未装)→ 正常情况,记错返 None
|
||||
if !matches!(result.exit_code, Some(0)) {
|
||||
errors.push(format!(
|
||||
"{}: 探测命令退出码 {:?}({})",
|
||||
label, result.exit_code, cmd
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let first_line = result.stdout.lines().next().map(|s| s.trim().to_string());
|
||||
match first_line {
|
||||
Some(p) if !p.is_empty() => Some(p),
|
||||
_ => {
|
||||
errors.push(format!("{}: 探测输出为空({})", label, cmd));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{}: 探测失败({}): {}", label, cmd, e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||||
@@ -2262,7 +2396,7 @@ mod tests {
|
||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||
// ============================================================
|
||||
|
||||
/// build_ai_tool_registry 应注册恰好 30 个工具(18 data + 11 file + 1 http),且工具名集合稳定。
|
||||
/// build_ai_tool_registry 应注册恰好 31 个工具(18 data + 12 file + 1 http),且工具名集合稳定。
|
||||
///
|
||||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||
@@ -2275,17 +2409,18 @@ mod tests {
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
|
||||
// 总量基线:30(18 data + 11 file + 1 http)。拆分前后必须一致。
|
||||
// 总量基线:31(18 data + 12 file + 1 http)。拆分前后必须一致。
|
||||
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
30,
|
||||
"工具总数应为 30(18 data + 11 file + 1 http),实际 {}", registry.len()
|
||||
31,
|
||||
"工具总数应为 31(18 data + 12 file + 1 http),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
// data 层 18 个(持 db):CRUD/状态机/工作流
|
||||
// file 层 11 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep
|
||||
// file 层 12 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测
|
||||
// http 层 1 个(不持 db):http_request
|
||||
let mut expected: Vec<&str> = vec![
|
||||
// ── data 层 (18) ──
|
||||
@@ -2295,12 +2430,13 @@ mod tests {
|
||||
"run_workflow", "delete_task", "create_idea",
|
||||
"delete_project", "restore_project", "purge_project",
|
||||
"list_trash", "get_project_count", "get_task_count",
|
||||
// ── file 层 (11) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621)
|
||||
// ── file 层 (12) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1)
|
||||
"read_file", "list_directory", "write_file",
|
||||
"patch_file", "file_info", "append_file",
|
||||
"delete_file", "rename_file", "search_files", "run_command",
|
||||
"grep",
|
||||
"grep", "detect_environment",
|
||||
// ── http 层 (1) ──
|
||||
"http_request",
|
||||
];
|
||||
|
||||
@@ -326,6 +326,13 @@ export type AiChatEvent = ({
|
||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||
} | {
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
|
||||
// 字段对齐后端 AiChatEvent::AiHelpRequired:reason(原因摘要)/context(已试情况)/options(建议选项)。
|
||||
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
|
||||
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
|
||||
type: 'AiHelpRequired'; reason: string; context: string; options: string[]
|
||||
} | {
|
||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||
type: 'AiStreamRetry'; attempt: number; max_attempts: number
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
</div>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
|
||||
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
||||
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
|
||||
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
|
||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||
@@ -136,6 +140,7 @@ import ChatInput from './ai/ChatInput.vue'
|
||||
import TopBar from './ai/TopBar.vue'
|
||||
import MessageList from './ai/MessageList.vue'
|
||||
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
|
||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
|
||||
105
src/components/ai/HelpRequiredCard.vue
Normal file
105
src/components/ai/HelpRequiredCard.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断后展示的操作卡。
|
||||
触发:后端断路器连续同类失败达阈值 → emit AiHelpRequired(已 guard.reset,loop 终止)→
|
||||
useAiEvents handleLifecycleEvent 翻 pendingHelp(模块级 ref)→ 本卡渲染。
|
||||
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref。
|
||||
显 reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)。
|
||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||
<div v-if="helpActive" class="ai-help-required">
|
||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
|
||||
<div class="ai-help-required-options">
|
||||
<button
|
||||
v-for="opt in helpActive.options"
|
||||
:key="opt"
|
||||
class="ai-help-required-btn"
|
||||
@click="handleSelectOption(opt)"
|
||||
>{{ opt }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingHelp } from '../../composables/ai/useAiEvents'
|
||||
|
||||
// toast 经 emit 转父(保持单一 toast 源,与 MaxRoundsCard 一致)
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前挂起的求助(有值即后端发过 AiHelpRequired)。pendingHelp 由 useAiEvents 翻:
|
||||
// AiHelpRequired 设值;AiCompleted/AiError 清值(重新发送或错误中断后求助卡消失)。
|
||||
//
|
||||
// helpActive 既做守卫(挂起会话===当前展示会话)又做模板取值源:返回 PendingHelp 非空或 null,
|
||||
// 模板 v-if="helpActive" 取真值后 Vue 类型收窄为非 null,可直接访问 .reason/.context/.options。
|
||||
// 显示守卫:仅当挂起求助属当前展示会话时显(F-09 多会话并发不串台)。
|
||||
// 注:不依赖 streaming——求助事件后端已 guard.reset(generating=false),若依赖 isGenerating
|
||||
// 会导致卡片不显(对齐 MaxRoundsCard F-260620 去 streaming 依赖的根治理)。
|
||||
const helpActive = computed(() => {
|
||||
const p = pendingHelp.value
|
||||
if (p && p.conversationId === store.state.activeConversationId) return p
|
||||
return null
|
||||
})
|
||||
|
||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
|
||||
function handleSelectOption(opt: string): void {
|
||||
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
|
||||
pendingHelp.value = null
|
||||
emit('toast', { msg: t('aiChat.helpRequiredAck'), type: 'info' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
|
||||
.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);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-help-required-text {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-help-required-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-help-required-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
.ai-help-required-btn:hover { filter: brightness(1.1); }
|
||||
</style>
|
||||
@@ -45,7 +45,8 @@ const appSettings = useAppSettingsStore()
|
||||
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
|
||||
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
|
||||
// L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
|
||||
|
||||
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
|
||||
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
|
||||
@@ -121,6 +122,22 @@ export interface PendingDirAuth {
|
||||
}
|
||||
export const pendingDirAuths = ref<PendingDirAuth[]>([])
|
||||
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
//
|
||||
// agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。
|
||||
// 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。
|
||||
// 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号,
|
||||
// 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId,
|
||||
// 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。
|
||||
// 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。
|
||||
export interface PendingHelp {
|
||||
reason: string
|
||||
context: string
|
||||
options: string[]
|
||||
conversationId: string | null
|
||||
}
|
||||
export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
@@ -468,6 +485,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。
|
||||
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
|
||||
pendingHelp.value = null
|
||||
}
|
||||
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
|
||||
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
|
||||
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
|
||||
@@ -533,6 +554,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。
|
||||
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
|
||||
pendingHelp.value = null
|
||||
}
|
||||
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
|
||||
const errConv2 = event.conversation_id || ''
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
|
||||
@@ -558,6 +583,47 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiHelpRequired': {
|
||||
// L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。
|
||||
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
|
||||
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
|
||||
// 显 reason + options 按钮供用户选。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearAllToolSlowTimers()
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0
|
||||
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
|
||||
state.pendingApprovals = []
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。
|
||||
const helpConv2 = event.conversation_id || ''
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2)
|
||||
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
|
||||
const helpConv = event.conversation_id || ''
|
||||
if (helpConv) {
|
||||
localStorage.removeItem(`df-ai-gen-${helpConv}`)
|
||||
localStorage.removeItem(`df-ai-text-${helpConv}`)
|
||||
}
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
|
||||
pendingHelp.value = {
|
||||
reason: event.reason,
|
||||
context: event.context,
|
||||
options: event.options,
|
||||
conversationId: event.conversation_id || state.activeConversationId || null,
|
||||
}
|
||||
void loadConversations()
|
||||
notifyConversationChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -574,18 +640,19 @@ export function handleEvent(event: AiChatEvent) {
|
||||
state.activeConversationId = convId
|
||||
void appSettings.set('df-ai-active-conv', convId)
|
||||
}
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
|
||||
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
||||
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
|
||||
state.generatingConvs.delete(convId || '')
|
||||
void loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误事件除外)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
||||
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
|
||||
state.generatingConvs.add(convId)
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
|
||||
@@ -145,6 +145,9 @@ export default {
|
||||
continueLoopFailed: 'Continue failed: {msg}',
|
||||
stopLoopFailed: 'Stop failed: {msg}',
|
||||
|
||||
// ── L1 help protocol (aichat experience & agent capability redesign §2.3, 2026-06-21): circuit-breaker help card ──
|
||||
helpRequiredAck: 'Choice noted. Please rephrase your request to try another approach / continue',
|
||||
|
||||
// ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ──
|
||||
dirAuthRequired: 'Path authorization required',
|
||||
dirAuthHint: '{tool} wants to access: {path}',
|
||||
|
||||
@@ -146,6 +146,9 @@ export default {
|
||||
continueLoopFailed: '继续失败:{msg}',
|
||||
stopLoopFailed: '停止失败:{msg}',
|
||||
|
||||
// ── L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断求助卡 ──
|
||||
helpRequiredAck: '已记录选择,请重新描述需求以换思路/继续',
|
||||
|
||||
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
||||
dirAuthRequired: '需要路径授权',
|
||||
dirAuthHint: '{tool} 想访问:{path}',
|
||||
|
||||
Reference in New Issue
Block a user