优化: 上下文管理与提示词链路批
- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation) - T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价 - T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引 - T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏 - 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄) - 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退 - 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」 - 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框 - 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态 - UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
This commit is contained in:
@@ -806,12 +806,21 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
|
||||
}
|
||||
}
|
||||
// 口径归一(G1,P0-A/u-claw 三口径,与 anthropic_helpers message_start 同表):
|
||||
// anthropic input_tokens 只含未缓存输入,不含 cache_read/creation 两段;
|
||||
// 落库 prompt_tokens 对齐 openai/deepseek 全量输入语义(hit/miss ⊆ prompt),
|
||||
// 故 prompt = input + read + creation,total = prompt + output。
|
||||
// cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价。
|
||||
let prompt_tokens = resp
|
||||
.usage
|
||||
.input_tokens
|
||||
.saturating_add(resp.usage.cache_read())
|
||||
.saturating_add(resp.usage.cache_creation_input_tokens);
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: resp.usage.input_tokens,
|
||||
prompt_tokens,
|
||||
completion_tokens: resp.usage.output_tokens,
|
||||
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
||||
// anthropic cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价
|
||||
prompt_cache_hit_tokens: resp.usage.cache_read_input_tokens,
|
||||
total_tokens: prompt_tokens.saturating_add(resp.usage.output_tokens),
|
||||
prompt_cache_hit_tokens: resp.usage.cache_read(),
|
||||
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
||||
reasoning_tokens: 0,
|
||||
};
|
||||
@@ -1016,6 +1025,47 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造带缓存分项的 message_start 事件(cache_read/cache_creation,P0-A 三口径)
|
||||
fn message_start_with_cache(input_tokens: u32, cache_read: u32, cache_creation: u32) -> String {
|
||||
format!(
|
||||
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"cache_read_input_tokens":{},"cache_creation_input_tokens":{},"output_tokens":0}}}}}}"#,
|
||||
input_tokens, cache_read, cache_creation
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造智谱 anthropic 兼容端点形态的 message_start(隐式缓存命中走嵌套
|
||||
/// prompt_tokens_details.cached_tokens,无顶层 cache 字段——官方缓存文档口径)
|
||||
fn message_start_zhipu_nested(input_tokens: u32, cached_tokens: u32) -> String {
|
||||
format!(
|
||||
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"prompt_tokens_details":{{"cached_tokens":{}}},"output_tokens":0}}}}}}"#,
|
||||
input_tokens, cached_tokens
|
||||
)
|
||||
}
|
||||
|
||||
/// 智谱嵌套形态回退:顶层 cache 字段缺席时从 prompt_tokens_details.cached_tokens
|
||||
/// 取命中量,prompt=input+cached(隐式缓存无写入分项,miss=0)
|
||||
#[test]
|
||||
fn anthropic_zhipu_nested_cache_tokens_fallback() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_anthropic_event(&message_start_zhipu_nested(500, 4000), &mut acc);
|
||||
let u = c.usage.expect("usage 应随 message_start 带出");
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 4000);
|
||||
assert_eq!(u.prompt_cache_miss_tokens, 0);
|
||||
// 口径归一:prompt = input + cached
|
||||
assert_eq!(u.prompt_tokens, 4500);
|
||||
}
|
||||
|
||||
/// 顶层优先:官方 Anthropic 端点回顶层字段时嵌套值(即便异常并存)不覆盖顶层
|
||||
#[test]
|
||||
fn anthropic_top_level_cache_read_wins_over_nested() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let frame = r#"{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_read_input_tokens":200,"prompt_tokens_details":{"cached_tokens":999},"output_tokens":0}}}"#;
|
||||
let c = apply_anthropic_event(frame, &mut acc);
|
||||
let u = c.usage.expect("usage 应随 message_start 带出");
|
||||
assert_eq!(u.prompt_cache_hit_tokens, 200);
|
||||
assert_eq!(u.prompt_tokens, 300);
|
||||
}
|
||||
|
||||
/// 辅助:构造 message_delta 事件(output_tokens 为累计值)
|
||||
fn message_delta(output_tokens: u32) -> String {
|
||||
format!(
|
||||
@@ -1135,6 +1185,48 @@ mod tests {
|
||||
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
|
||||
}
|
||||
|
||||
/// 三口径映射锁定(G1,P0-A):anthropic input_tokens 不含缓存两段,落库 prompt_tokens
|
||||
/// 对齐 openai/deepseek 全量输入语义(hit/miss ⊆ prompt)→ prompt = input + read +
|
||||
/// creation;message_delta 的 total 重算基于累加器 prompt(已含缓存段)自动继承新口径。
|
||||
#[test]
|
||||
fn anthropic_message_start_cache_three_caliber_prompt_sums_cache_segments() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_anthropic_event(&message_start_with_cache(100, 64, 36), &mut acc);
|
||||
let u0 = c.usage.expect("message_start 应带出初始 usage 快照");
|
||||
assert_eq!(u0.prompt_tokens, 200, "prompt = input(100)+read(64)+creation(36)");
|
||||
assert_eq!(u0.prompt_cache_hit_tokens, 64, "cache_read → hit 原样映射");
|
||||
assert_eq!(u0.prompt_cache_miss_tokens, 36, "cache_creation → miss 原样映射");
|
||||
assert_eq!(u0.total_tokens, 200, "初始 total 同口径");
|
||||
|
||||
// message_delta 累计 output 后 total 重算:继承含缓存段 prompt 基数(200+30)
|
||||
let _ = apply_anthropic_event(&message_delta(30), &mut acc);
|
||||
let a = acc.as_ref().unwrap();
|
||||
assert_eq!(a.prompt_tokens, 200, "delta 不改 prompt");
|
||||
assert_eq!(a.total_tokens, 230, "total = prompt(含缓存两段)+output");
|
||||
}
|
||||
|
||||
/// 缺失容错(G2,P0-A):端点 200 但 usage 键整体缺失/usage 对象缺计数字段时,
|
||||
/// 不再整帧反序列化失败硬炸(三路走查 #3「有效文本回复整体丢弃」),全零兜底
|
||||
/// 下行走既有估算轨(round_usage_effective prompt==0 → 估算打标),对齐 OpenAI 臂。
|
||||
#[test]
|
||||
fn anthropic_response_missing_or_partial_usage_deserializes_not_fatal() {
|
||||
// usage 键整体缺失
|
||||
let resp: AnthropicResponse = serde_json::from_str(
|
||||
r#"{"id":"msg_1","model":"claude-x","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn"}"#,
|
||||
)
|
||||
.expect("usage 键缺失不应炸帧");
|
||||
assert_eq!(resp.usage.input_tokens, 0, "缺 usage → 全零兜底走估算轨");
|
||||
assert_eq!(resp.usage.output_tokens, 0);
|
||||
|
||||
// usage 对象存在但缺计数字段(部分网关只回 cache 分项)
|
||||
let resp2: AnthropicResponse = serde_json::from_str(
|
||||
r#"{"id":"msg_2","model":"claude-x","content":[],"usage":{"cache_read_input_tokens":7}}"#,
|
||||
)
|
||||
.expect("usage 缺 input/output 字段不应炸帧");
|
||||
assert_eq!(resp2.usage.input_tokens, 0);
|
||||
assert_eq!(resp2.usage.cache_read_input_tokens, 7, "已有分项不丢");
|
||||
}
|
||||
|
||||
/// content_block_start (tool_use) 带 id+name
|
||||
#[test]
|
||||
fn anthropic_content_block_start_tool_use() {
|
||||
|
||||
@@ -69,6 +69,10 @@ pub(crate) struct AnthropicResponse {
|
||||
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||
#[allow(dead_code)]
|
||||
stop_reason: Option<String>,
|
||||
/// usage 整段 default(G2):端点不回 usage 键时不再 missing-field 炸帧,全零兜底
|
||||
/// 下行走既有估算轨(round_usage_effective)。字段级容错在 AnthropicUsage 侧,
|
||||
/// 此处兜「usage 键整体缺失」——两层合起来才覆盖「端点不回 usage」全形态。
|
||||
#[serde(default)]
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
@@ -88,9 +92,15 @@ pub(crate) struct AnthropicContentBlock {
|
||||
pub input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct AnthropicUsage {
|
||||
/// 计数字段全部 default 容错(G2,P0-A 口径统一):部分网关 200 但 usage 缺失/缺字段,
|
||||
/// 必填会让整帧反序列化失败 → 有效回复整体丢弃(三路走查 #3/#5)。缺省 0 后全零
|
||||
/// TokenUsage 下行走既有估算兜底轨(round_usage_effective,prompt==0 → 估算打标),
|
||||
/// 与 OpenAI 臂的 serde default 容错(openai_helpers.rs:139-157)对齐。
|
||||
#[serde(default)]
|
||||
pub input_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub output_tokens: u32,
|
||||
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
|
||||
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
|
||||
@@ -102,6 +112,32 @@ pub(crate) struct AnthropicUsage {
|
||||
/// 非 cache 场景无此字段 → serde default 0。
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: u32,
|
||||
/// 智谱 anthropic 兼容端点的隐式缓存(自动生效,命中半价)不回顶层 cache 字段,
|
||||
/// 命中量走 OpenAI 风格嵌套 prompt_tokens_details.cached_tokens(智谱官方缓存文档
|
||||
/// 口径)。serde 容错读入,消费侧经 [`Self::cache_read`] 回退取用——缺此层时
|
||||
/// GLM 链路账本/命中率恒 0(实测 54 条观测全零的根因)。
|
||||
#[serde(default)]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
/// OpenAI 风格嵌套的缓存命中分项(智谱 anthropic 兼容端点口径)。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct PromptTokensDetails {
|
||||
#[serde(default)]
|
||||
pub cached_tokens: u32,
|
||||
}
|
||||
|
||||
impl AnthropicUsage {
|
||||
/// 缓存命中读取量:顶层 cache_read_input_tokens 非零优先(官方 Anthropic 端点),
|
||||
/// 智谱嵌套形态(顶层缺省 0)回退;两处都不存在时 0。
|
||||
pub fn cache_read(&self) -> u32 {
|
||||
if self.cache_read_input_tokens > 0 {
|
||||
return self.cache_read_input_tokens;
|
||||
}
|
||||
self.prompt_tokens_details
|
||||
.as_ref()
|
||||
.map_or(0, |d| d.cached_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -155,15 +191,30 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
let cache_read = u_obj
|
||||
.and_then(|u| u.get("cache_read_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
// 智谱 anthropic 兼容端点隐式缓存走嵌套 prompt_tokens_details.cached_tokens,
|
||||
// 顶层字段缺席时回退读取(与非流式 AnthropicUsage::cache_read 同表)。
|
||||
.or_else(|| {
|
||||
u_obj
|
||||
.and_then(|u| u.get("prompt_tokens_details"))
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
})
|
||||
.unwrap_or(0) as u32;
|
||||
let cache_creation = u_obj
|
||||
.and_then(|u| u.get("cache_creation_input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
// 口径归一(G1,P0-A/u-claw 三口径): anthropic input_tokens 只含未缓存输入,
|
||||
// 不含 cache_read/cache_creation 两段;落库 prompt_tokens 对齐 openai/deepseek
|
||||
// 全量输入语义(prompt=全量输入,hit/miss 为其子集),故 prompt = input + read
|
||||
// + creation。total 同口径(prompt + 当前 output)。
|
||||
let prompt = (inp as u32)
|
||||
.saturating_add(cache_read)
|
||||
.saturating_add(cache_creation);
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: inp as u32,
|
||||
prompt_tokens: prompt,
|
||||
completion_tokens: 0,
|
||||
total_tokens: inp as u32,
|
||||
total_tokens: prompt,
|
||||
prompt_cache_hit_tokens: cache_read,
|
||||
prompt_cache_miss_tokens: cache_creation,
|
||||
reasoning_tokens: 0,
|
||||
|
||||
@@ -705,8 +705,16 @@ impl AiConversationRepo {
|
||||
///
|
||||
/// A2-B9(G3.2 clearChat 裁决):原 `clear_messages` + `delete_range` 两条独立 DB 写非原子,
|
||||
/// DB 失败会致 messages JSON 列与 ai_messages 表不一致(如仅一条成功)。本方法一次 transaction
|
||||
/// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token;② DELETE ai_messages 该 conv
|
||||
/// 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear` 先停 loop 再单事务清空。
|
||||
/// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token + 生命周期标记归位;
|
||||
/// ② DELETE ai_messages 该 conv 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear`
|
||||
/// 先停 loop 再单事务清空。
|
||||
///
|
||||
/// 生命周期标记归位(P1-B 方案甲代史收尾,2026-10-21):硬清空=被清代的终局,对话壳虽
|
||||
/// 保留但属全新一代,旧代终局语义必须随事务归位防泄漏给同壳新任务——loop_state→NULL
|
||||
/// (被清的 parked 会话不得再进 list_parked 可恢复清单)、status→'active'(旧 completed/
|
||||
/// incomplete/error 不再代表新代)、boundary_json→NULL(V45「随会话同亡」:旧任务边界
|
||||
/// 不得约束同壳新任务;内存侧 per_conv.boundary 由调用方同步清,防下轮 save 随行覆写
|
||||
/// 复活 DB 侧刚清的快照)。
|
||||
///
|
||||
/// 对话壳保留(侧栏仍可见,可继续在该对话内聊);返回 Ok(())——调用方只关心成功与否
|
||||
/// (对齐 replace_conversation 语义,不返回受影响行数)。
|
||||
@@ -718,9 +726,9 @@ impl AiConversationRepo {
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
{
|
||||
// ① ai_conversations.messages 置空 + token 清零(对话壳保留)
|
||||
// ① ai_conversations.messages 置空 + token 清零 + 生命周期标记归位(对话壳保留)
|
||||
tx.execute(
|
||||
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, updated_at = ?1 WHERE id = ?2",
|
||||
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, loop_state = NULL, status = 'active', boundary_json = NULL, updated_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
@@ -1244,6 +1252,47 @@ mod tests {
|
||||
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||
}
|
||||
|
||||
/// P1-B 方案甲代史收尾契约(2026-10-21):硬清空=被清代的终局,生命周期标记必须随
|
||||
/// 同一事务归位——loop_state→NULL(被清的 parked 会话不得再进可恢复清单)、status→
|
||||
/// 'active'(旧终局态不得泄漏给同壳新任务)、boundary_json→NULL(旧任务边界随代同亡,
|
||||
/// 不得约束同壳新任务)。锁死 clear_conversation_atomic 的收尾语义(调用方 ai_chat_clear)。
|
||||
#[tokio::test]
|
||||
async fn clear_conversation_atomic_resets_lifecycle_markers() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiConversationRepo::new(&db);
|
||||
// 前置态:带旧边界 + parked + incomplete 的非空会话(清空前的典型停靠残留)
|
||||
repo.insert(AiConversationRecord {
|
||||
id: "c1".into(),
|
||||
title: None,
|
||||
messages: r#"[{"role":"user","content":"旧任务"}]"#.into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: Some(120),
|
||||
completion_tokens: Some(80),
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "0".into(),
|
||||
updated_at: "0".into(),
|
||||
origin: None,
|
||||
boundary_json: Some(r#"{"risk_ceiling":"Medium"}"#.into()),
|
||||
loop_state: Some("parked".into()),
|
||||
status: "incomplete".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
repo.clear_conversation_atomic("c1").await.expect("clear");
|
||||
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got.loop_state, None, "清空后 loop_state 应归位 NULL(停靠残留不得进可恢复清单)");
|
||||
assert_eq!(got.status, "active", "清空后 status 应归位 active(旧代终局不得泄漏)");
|
||||
assert_eq!(got.boundary_json, None, "清空后 boundary_json 应随代同亡");
|
||||
assert_eq!(got.messages, "[]", "消息内容照旧清空");
|
||||
}
|
||||
|
||||
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
|
||||
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! P0 阶段(本文件):仅建 Repo + CRUD 方法,不接 ContextManager 读写路径
|
||||
//! (切读策略待用户决策,P2)。ContextManager 仍走旧 messages JSON 列双写期未启动。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, Row};
|
||||
@@ -20,6 +21,7 @@ use df_types::error::Result;
|
||||
use crate::db::Database;
|
||||
use crate::models::AiMessageRecord;
|
||||
|
||||
use super::idea_repo::escape_like;
|
||||
use super::storage_err;
|
||||
|
||||
// ============================================================
|
||||
@@ -51,6 +53,72 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 跨会话关键词检索(P1-A,对标 u-claw #5/#6)
|
||||
// ============================================================
|
||||
|
||||
/// LIKE 检索不做通配模糊,关键词为分词产物(调用方 src-tauri 层 extract_query_keywords
|
||||
/// 分好传入,本层不做分词——df-storage 在依赖下层,反向依赖 src-tauri 会成环)。
|
||||
///
|
||||
/// **FTS5 升级触发条件(2026-10-21 拍板留档)**:ai_messages 行数 > 50 万,或 role 过滤后
|
||||
/// LIKE 检索 P95 实测 > 500ms 时,再评估升级 FTS5 虚表(V54+ 建虚表 + 一次性回填,
|
||||
/// 检索接口签名不变)。当前生产库实测(32,349 行 / content 39.5MB):role 过滤后单关键词
|
||||
/// LIKE 113ms、双关键词 127ms,桌面场景百毫秒级无体感;且中文 unicode61 按字切分召回
|
||||
/// 质量反不如子串 LIKE,仓内 idea_repo/conversation_repo 两处既有「不上 FTS5」决策先例。
|
||||
|
||||
/// 关键词检索单条命中。
|
||||
///
|
||||
/// `snippet` 为命中片段:content 中首个关键词命中点前后各约 200 字,整体截 500 字
|
||||
/// (截断处以 … 标记),避免全文回灌撑爆调用方上下文。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageSearchHit {
|
||||
pub message_id: String,
|
||||
pub conversation_id: String,
|
||||
pub seq: i64,
|
||||
pub role: String,
|
||||
pub timestamp: Option<i64>,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// 会话摘要降级链数据源(无参列会话时逐会话取用)。
|
||||
///
|
||||
/// `last_system` = 该会话最近一条 role=system 消息(压缩摘要/关键词锚点的载体,
|
||||
/// 四段式「## 意图/## 决策…」);`first_user` = 首条 user 消息。调用方按
|
||||
/// 「最近 system 摘要 > 首条 user」降级链取会话概要,两级皆空(空会话)则无摘要。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationSummarySource {
|
||||
pub last_system: Option<String>,
|
||||
pub first_user: Option<String>,
|
||||
}
|
||||
|
||||
/// 命中片段窗口:命中点前保留上下文与片段总长(按字符计,中文一字一符)。
|
||||
const SNIPPET_CONTEXT_CHARS: usize = 200;
|
||||
const SNIPPET_MAX_CHARS: usize = 500;
|
||||
|
||||
/// 从 content 提取命中片段:首个关键词命中点前后各约 200 字,整体截 500 字。
|
||||
///
|
||||
/// 大小写不敏感定位(与 SQLite LIKE 的 ASCII 大小写不敏感对齐);分词产物在 LIKE 层
|
||||
/// 已命中,理论必有命中点,兜底未找到时退回首部截断。全程按字符迭代操作,无字节
|
||||
/// 索引切边界的乱码风险。
|
||||
fn search_snippet(content: &str, keywords: &[String]) -> String {
|
||||
let ellipsis = |s: &str, head_cut: bool, tail_cut: bool| {
|
||||
format!("{}{}{}", if head_cut { "…" } else { "" }, s, if tail_cut { "…" } else { "" })
|
||||
};
|
||||
let lower = content.to_lowercase();
|
||||
let hit_char = keywords.iter().find_map(|kw| lower.find(&kw.to_lowercase())).map(|byte| {
|
||||
// 命中字节位 → 字符位(to_lowercase 极端 Unicode 下字符数可能漂移,
|
||||
// 但仅影响窗口中心偏移,chars().skip 的迭代本身无越界风险)
|
||||
lower[..byte].chars().count()
|
||||
});
|
||||
let Some(hit_char) = hit_char else {
|
||||
let head: String = content.chars().take(SNIPPET_MAX_CHARS).collect();
|
||||
return ellipsis(&head, false, content.chars().count() > SNIPPET_MAX_CHARS);
|
||||
};
|
||||
let start = hit_char.saturating_sub(SNIPPET_CONTEXT_CHARS);
|
||||
let body: String = content.chars().skip(start).take(SNIPPET_MAX_CHARS).collect();
|
||||
ellipsis(&body, start > 0, content.chars().count() > start + SNIPPET_MAX_CHARS)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AiMessageRepo
|
||||
// ============================================================
|
||||
@@ -375,6 +443,156 @@ impl AiMessageRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 跨会话关键词检索(P1-A):LIKE OR 逐词命中任一即入候选,timestamp DESC,limit/offset 分页。
|
||||
///
|
||||
/// - 分词在调用方(src-tauri 层 extract_query_keywords)完成,本层只收关键词数组,
|
||||
/// 不反向依赖 src-tauri(依赖方向铁律,对齐 KnowledgeRepo.search_keywords_scoped 先例);
|
||||
/// - role 恒过滤 user+assistant+system(拍板④:tool 结果占内容量 86% 且噪音大,默认排除);
|
||||
/// - `conversation_id` 传入时限定单会话(两跳式深查第二跳);
|
||||
/// - LIKE 转义与 `ESCAPE '|'` 复用 [`super::idea_repo::escape_like`] 单一实现,
|
||||
/// 关键词含 %/_/| 时按字面匹配;
|
||||
/// - 返回带命中片段(500 字截断,见 [`search_snippet`]),全文不出存储层;
|
||||
/// - status 不过滤:compressed 消息原文完整保留(ai_messages = 全量账本),照常可检索。
|
||||
///
|
||||
/// FTS5 升级触发条件见模块顶部注释(留档,>50 万行或 P95>500ms 再评估)。
|
||||
pub async fn search_keywords(
|
||||
&self,
|
||||
keywords: &[String],
|
||||
conversation_id: Option<&str>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<MessageSearchHit>> {
|
||||
if keywords.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conversation_id.map(|s| s.to_owned());
|
||||
// 每个关键词占一个编号占位符;conversation_id 占 ?(n+1),LIMIT/OFFSET 依次后推。
|
||||
// 动态 WHERE 拼接模式对齐 AiToolExecutionRepo::list_by_query 先例。
|
||||
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
|
||||
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
|
||||
for (i, kw) in keywords.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
patterns.push(format!("%{}%", escape_like(kw)));
|
||||
where_parts.push(format!("content LIKE ?{n} ESCAPE '|'"));
|
||||
}
|
||||
let mut extra: Vec<String> = Vec::new();
|
||||
if let Some(c) = &conv_id {
|
||||
extra.push(c.clone());
|
||||
}
|
||||
// 编号必须连续(SQLite 参数计数取最大 ?N,跳号会产生未绑定空洞报错):
|
||||
// 无 conv 条件时 LIMIT/OFFSET 紧跟关键词占位符之后。
|
||||
let conv_n = patterns.len() + 1;
|
||||
let (limit_n, offset_n) = if conv_id.is_some() {
|
||||
(conv_n + 1, conv_n + 2)
|
||||
} else {
|
||||
(conv_n, conv_n + 1)
|
||||
};
|
||||
let limit_i = limit as i64;
|
||||
let offset_i = offset as i64;
|
||||
let conv_clause = if conv_id.is_some() {
|
||||
format!(" AND conversation_id = ?{conv_n}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id, conversation_id, seq, role, timestamp, content FROM ai_messages \
|
||||
WHERE role IN ('user','assistant','system'){conv_clause} AND ({}) \
|
||||
ORDER BY timestamp DESC LIMIT ?{limit_n} OFFSET ?{offset_n}",
|
||||
where_parts.join(" OR "),
|
||||
);
|
||||
let keywords_owned: Vec<String> = keywords.to_vec();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
// 参数序列:关键词 pattern × n [+ conv_id] + limit + offset
|
||||
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
|
||||
for e in &extra {
|
||||
param_refs.push(e);
|
||||
}
|
||||
param_refs.push(&limit_i);
|
||||
param_refs.push(&offset_i);
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let content: String = row.get("content")?;
|
||||
Ok(MessageSearchHit {
|
||||
message_id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
seq: row.get("seq")?,
|
||||
role: row.get("role")?,
|
||||
timestamp: row.get("timestamp")?,
|
||||
snippet: search_snippet(&content, &keywords_owned),
|
||||
})
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 会话摘要降级链数据源(无参列最近会话用):一批会话各取「最近一条 system 消息」与
|
||||
/// 「首条 user 消息」content,逐会话单行返回(无消息的会话无行,调用方按无摘要兜底)。
|
||||
///
|
||||
/// 关联子查询走 idx_ai_messages_conv(conversation_id, seq) 索引,批量一次查询替代
|
||||
/// 逐会话 list_by_conversation 全量物化(276 条/会话均值,10 会话省 ~340KB 物化)。
|
||||
pub async fn summary_sources(
|
||||
&self,
|
||||
conversation_ids: &[String],
|
||||
) -> Result<HashMap<String, ConversationSummarySource>> {
|
||||
if conversation_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let conn = self.conn.clone();
|
||||
// IN 占位符逐个编号(?1..?n)
|
||||
let placeholders: Vec<String> =
|
||||
(1..=conversation_ids.len()).map(|n| format!("?{n}")).collect();
|
||||
let sql = format!(
|
||||
"SELECT m.conversation_id, \
|
||||
(SELECT s.content FROM ai_messages s \
|
||||
WHERE s.conversation_id = m.conversation_id AND s.role = 'system' \
|
||||
ORDER BY s.seq DESC LIMIT 1) AS last_system, \
|
||||
(SELECT u.content FROM ai_messages u \
|
||||
WHERE u.conversation_id = m.conversation_id AND u.role = 'user' \
|
||||
ORDER BY u.seq ASC LIMIT 1) AS first_user \
|
||||
FROM ai_messages m \
|
||||
WHERE m.conversation_id IN ({}) \
|
||||
GROUP BY m.conversation_id",
|
||||
placeholders.join(","),
|
||||
);
|
||||
let ids: Vec<String> = conversation_ids.to_vec();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, String>("conversation_id")?,
|
||||
ConversationSummarySource {
|
||||
last_system: row.get("last_system")?,
|
||||
first_user: row.get("first_user")?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut out = HashMap::new();
|
||||
for r in rows {
|
||||
let (id, source) = r.map_err(storage_err)?;
|
||||
out.insert(id, source);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -801,4 +1019,137 @@ mod tests {
|
||||
assert_eq!(got3.len(), 1);
|
||||
assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条");
|
||||
}
|
||||
|
||||
// ---------- search_keywords(P1-A 跨会话检索) ----------
|
||||
|
||||
/// role 过滤(tool 默认排除)+ timestamp DESC 排序 + conversation_id 限定深查。
|
||||
#[tokio::test]
|
||||
async fn search_keywords_filters_role_and_orders_desc() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
let mut u0 = mk_msg("u0", "conv_a", 0, "user", "怎么部署服务");
|
||||
u0.timestamp = Some(1000);
|
||||
let mut a0 = mk_msg("a0", "conv_a", 1, "assistant", "部署 分三步走");
|
||||
a0.timestamp = Some(2000);
|
||||
let mut hit_tool = mk_msg("t0", "conv_a", 2, "tool", "部署流程的 tool 结果");
|
||||
hit_tool.timestamp = Some(3000);
|
||||
let mut u1 = mk_msg("u1", "conv_b", 0, "user", "旧会话也提过部署");
|
||||
u1.timestamp = Some(500);
|
||||
repo.insert_batch(vec![u0, a0, hit_tool, u1])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
// tool role 默认排除:命中 3 条(两 conv 的 user/assistant),tool 结果不入检索面
|
||||
let hits = repo
|
||||
.search_keywords(&["部署".to_string()], None, 20, 0)
|
||||
.await
|
||||
.expect("search");
|
||||
assert_eq!(hits.len(), 3, "tool role 应被排除: {:?}", hits.iter().map(|h| &h.role).collect::<Vec<_>>());
|
||||
// timestamp DESC:conv_a(3000 之外的 2000/1000 应在 conv_b 100 之前)
|
||||
assert_eq!(hits[0].message_id, "a0");
|
||||
assert_eq!(hits[1].message_id, "u0");
|
||||
|
||||
// conversation_id 限定:只回该会话命中(两跳式深查第二跳)
|
||||
let scoped = repo
|
||||
.search_keywords(&["部署".to_string()], Some("conv_b"), 20, 0)
|
||||
.await
|
||||
.expect("search scoped");
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].conversation_id, "conv_b");
|
||||
|
||||
// offset 分页:跳过第一条
|
||||
let paged = repo
|
||||
.search_keywords(&["部署".to_string()], None, 20, 1)
|
||||
.await
|
||||
.expect("search paged");
|
||||
assert_eq!(paged.len(), 2);
|
||||
assert_eq!(paged[0].message_id, "u0");
|
||||
}
|
||||
|
||||
/// LIKE 通配符按字面匹配(%/_ 不扩大命中面)+ 命中片段以关键词为中心截 500 字。
|
||||
#[tokio::test]
|
||||
async fn search_keywords_escapes_wildcards_and_centers_snippet() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
let long_prefix = "前".repeat(600); // 命中点前 600 字:窗口应前推 200 字且带 …
|
||||
let long_tail = "后".repeat(600);
|
||||
repo.insert_batch(vec![
|
||||
mk_msg("m0", "c", 0, "user", &format!("{long_prefix}配额100%封顶{long_tail}")),
|
||||
mk_msg("m1", "c", 1, "user", "配额100x封顶,百分号不应误命中"),
|
||||
mk_msg("m2", "c", 2, "user", "变量名 quota_limit 带下划线"),
|
||||
mk_msg("m3", "c", 3, "user", "quotaXlimit 下划线不该当通配命中"),
|
||||
])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
// % 按字面:只命中 m0,不命中 100x
|
||||
let pct = repo
|
||||
.search_keywords(&["100%".to_string()], None, 20, 0)
|
||||
.await
|
||||
.expect("search %");
|
||||
assert_eq!(pct.len(), 1, "100% 不应通配命中 100x: {:?}", pct.iter().map(|h| &h.message_id).collect::<Vec<_>>());
|
||||
assert_eq!(pct[0].message_id, "m0");
|
||||
|
||||
// _ 按字面:只命中 quota_limit,不命中 quotaXlimit
|
||||
let under = repo
|
||||
.search_keywords(&["quota_limit".to_string()], None, 20, 0)
|
||||
.await
|
||||
.expect("search _");
|
||||
assert_eq!(under.len(), 1);
|
||||
assert_eq!(under[0].message_id, "m2");
|
||||
|
||||
// 片段:命中点前约 200 字,总长 500+省略号,两端截断带 …
|
||||
let snip = &pct[0].snippet;
|
||||
assert!(snip.contains('…'), "两端截断应带省略号: {snip}");
|
||||
assert!(snip.contains("100%封顶"), "片段应以命中点为中心: {snip}");
|
||||
let body = snip.trim_matches('…');
|
||||
assert!(
|
||||
body.chars().count() <= 500,
|
||||
"片段正文应截 500 字,实际 {}",
|
||||
body.chars().count()
|
||||
);
|
||||
assert!(snip.starts_with('…'), "命中点前 600 字,片段应带前省略号");
|
||||
}
|
||||
|
||||
/// 空关键词防御:直返空集不发 SQL(LIKE %% 恒真会全表命中)。
|
||||
#[tokio::test]
|
||||
async fn search_keywords_empty_keywords_returns_empty() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
repo.insert_batch(vec![mk_msg("m0", "c", 0, "user", "任意内容")])
|
||||
.await
|
||||
.expect("insert");
|
||||
let hits = repo.search_keywords(&[], None, 20, 0).await.expect("search empty");
|
||||
assert!(hits.is_empty(), "空关键词应返空集");
|
||||
}
|
||||
|
||||
// ---------- summary_sources(摘要降级链数据源) ----------
|
||||
|
||||
/// 每会话取「最近一条 system」与「首条 user」;无消息会话无行;批量一次查询。
|
||||
#[tokio::test]
|
||||
async fn summary_sources_takes_last_system_and_first_user() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
repo.insert_batch(vec![
|
||||
mk_msg("u0", "c1", 0, "user", "首个问题"),
|
||||
mk_msg("a0", "c1", 1, "assistant", "回复"),
|
||||
mk_msg("s0", "c1", 2, "system", "旧锚点"),
|
||||
mk_msg("s1", "c1", 3, "system", "## 意图\n- 最新压缩摘要"),
|
||||
mk_msg("u1", "c2", 0, "user", "c2 的唯一用户消息"),
|
||||
])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let out = repo
|
||||
.summary_sources(&["c1".to_string(), "c2".to_string(), "c3".to_string()])
|
||||
.await
|
||||
.expect("summary_sources");
|
||||
assert_eq!(out.len(), 2, "无消息的 c3 不应有行");
|
||||
let c1 = &out["c1"];
|
||||
assert_eq!(c1.last_system.as_deref(), Some("## 意图\n- 最新压缩摘要"), "应取 seq 最大的 system");
|
||||
assert_eq!(c1.first_user.as_deref(), Some("首个问题"));
|
||||
let c2 = &out["c2"];
|
||||
assert!(c2.last_system.is_none(), "无 system 消息应返 None(降级链走首条 user)");
|
||||
assert_eq!(c2.first_user.as_deref(), Some("c2 的唯一用户消息"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。
|
||||
//!
|
||||
//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用,
|
||||
//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。
|
||||
//! 覆盖所有审批恢复路径(ai_approve / ai_continue_loop 等)。
|
||||
//!
|
||||
//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟),
|
||||
//! 0 表示禁用超时。
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。
|
||||
//!
|
||||
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||
//! 触发条件(默认):history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||
//! P0-B 水位判据(KV `df-ai-compress-watermark`,默认关=上式逐字节不变;开=替换为水位
|
||||
//! 主判据 history+sys > ratio×budget,见 [`compress_should_trigger`])。
|
||||
//!
|
||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
|
||||
@@ -87,6 +89,50 @@ async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv
|
||||
}
|
||||
}
|
||||
|
||||
/// 压缩触发判据(P0-B Phase 1 纯函数收口,便于单测;设计=temp/分析-P0B-压缩水位判据-2026-10-21)。
|
||||
///
|
||||
/// - **开关关(默认)**:逐字节等价现行判据 `available*6/10 < history`(available=budget−sys,
|
||||
/// 即原内联表达式)——分支前置,不进任何新逻辑。
|
||||
/// - **开关开**:**替换**为水位主判据 `history+sys > ratio×budget`(分子=估算 history+sys,
|
||||
/// 与现行口径同源;分母=budget_limit 模型感知,与 build_for_request 裁剪硬门同轴,免坐标
|
||||
/// 错位;严格大于,恰等于阈值不触发)。0.6 判据仅在开关关时生效;体积兜底=build_for_request
|
||||
/// 硬门(1.0×available 视图裁剪,永在),不在判据层并联。
|
||||
/// - 保护区门(protect_start>0,最近 PROTECT_COUNT 条不参与)与「区外有可压缩消息」门在
|
||||
/// 两种模式下同样生效,不走分支。
|
||||
///
|
||||
/// 红线(分析 §4.3):水位触发点 ratio×budget + 单轮最大增长必须 < available=budget−sys。
|
||||
/// ratio 经 `parse_watermark_ratio` 钳位 [0.3,0.95] 保证 headroom ≥ 5% budget。开关开为替换
|
||||
/// 语义:若口径异常令水位线 ≥ available,自动压缩不再触发,唯一防线=build_for_request 硬门
|
||||
/// (视图裁到 available 内,永在)——水位判据不会越过裁剪门,只是失去提前接住的机会。
|
||||
///
|
||||
/// S1 叠批次挂点:S1 分批压缩(C_high/C_low 批次语义)与本判据同轴同点,后续在本函数
|
||||
/// 水位分支上叠加批次参数,不另起第二处判据(分析 §4.3 实施序);本批不实施 S1。
|
||||
fn compress_should_trigger(
|
||||
watermark_on: bool,
|
||||
ratio: f32,
|
||||
protect_start: usize,
|
||||
history_tokens: u32,
|
||||
budget: u32,
|
||||
sys_tokens: u32,
|
||||
has_compressible: bool,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
(super::super::prompt::MIN_WATERMARK_RATIO..=super::super::prompt::MAX_WATERMARK_RATIO)
|
||||
.contains(&ratio),
|
||||
"ratio 须经 parse_watermark_ratio 钳位后传入",
|
||||
);
|
||||
if !watermark_on {
|
||||
// 关:现行判据逐字节等价(触发阈值 0.6*available,available=budget−sys_tokens)。
|
||||
return protect_start > 0
|
||||
&& (budget.saturating_sub(sys_tokens) as u64) * 6 / 10 < history_tokens as u64
|
||||
&& has_compressible;
|
||||
}
|
||||
// 开:水位主判据(总入账 history+sys 超 ratio×budget;严格大于,恰等于不触发)。
|
||||
// 替换语义:0.6 判据仅在开关关时生效,不在判据层并联(体积兜底=build_for_request 硬门)。
|
||||
let watermark = history_tokens as u64 + sys_tokens as u64 > (budget as f32 * ratio) as u64;
|
||||
protect_start > 0 && watermark && has_compressible
|
||||
}
|
||||
|
||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
///
|
||||
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
||||
@@ -116,6 +162,12 @@ pub(super) async fn maybe_auto_compress(
|
||||
) -> bool {
|
||||
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
||||
if !prev_compressing {
|
||||
// P0-B 水位判据配置(KV 双键,锁外读一次;默认关=现行判据逐字节不变,
|
||||
// 对齐 compress_recall 同点零新增锁的读法)。
|
||||
let (wm_on, ratio) = {
|
||||
let st = app_handle.state::<AppState>();
|
||||
super::super::prompt::compress_watermark_cfg(&st).await
|
||||
};
|
||||
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
||||
let (should_compress, protect_start, pre_compress_tokens) = {
|
||||
let session = session_arc.lock().await;
|
||||
@@ -129,11 +181,17 @@ pub(super) async fn maybe_auto_compress(
|
||||
let mgr = &mgr.messages;
|
||||
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
||||
let history_tokens = mgr.history_tokens();
|
||||
let available = mgr.budget_limit().saturating_sub(sys_tokens);
|
||||
// 触发阈值 0.6*available(可用预算=budget-sys_tokens):available*6/10 < history_tokens
|
||||
let should = protect_start > 0
|
||||
&& (available as u64) * 6 / 10 < history_tokens as u64
|
||||
&& mgr.has_compressible_messages(protect_start);
|
||||
// 触发判据收口 compress_should_trigger:开关关=原 0.6 判据逐字节等价;
|
||||
// 开=水位主判据(ratio×budget,替换原 0.6 判据)。
|
||||
let should = compress_should_trigger(
|
||||
wm_on,
|
||||
ratio,
|
||||
protect_start,
|
||||
history_tokens,
|
||||
mgr.budget_limit(),
|
||||
sys_tokens,
|
||||
mgr.has_compressible_messages(protect_start),
|
||||
);
|
||||
(should, protect_start, history_tokens)
|
||||
};
|
||||
|
||||
@@ -638,3 +696,108 @@ mod guard_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// P0-B 水位判据契约(纯函数级,对齐 guard_tests 风格):
|
||||
// ①开关关=现行判据逐值等价 ②开=水位边界 ±1 token(严格大于才触发)③替换语义:开档
|
||||
// 0.6 判据不再生效(触发点后移)④保护区/可压缩门两模式同效。
|
||||
// ratio 钳位契约见 prompt.rs tests(parse_watermark_ratio)。
|
||||
#[cfg(test)]
|
||||
mod watermark_tests {
|
||||
use super::compress_should_trigger;
|
||||
|
||||
const BUDGET: u32 = 102_000; // 128K 窗口:budget=(128K−8K)×0.85
|
||||
const SYS: u32 = 8_192;
|
||||
|
||||
/// ①开关关:全输入域网格遍历,与现行内联表达式逐值同价(零行为变化的回归证明)。
|
||||
#[test]
|
||||
fn watermark_off_equals_legacy_expression() {
|
||||
for budget in [0u32, 1_000, BUDGET, u32::MAX] {
|
||||
for sys in [0u32, 100, SYS, u32::MAX] {
|
||||
for history in [0u32, 999, 56_000, u32::MAX] {
|
||||
for protect in [0usize, 1, 6] {
|
||||
for compressible in [false, true] {
|
||||
let available = budget.saturating_sub(sys);
|
||||
let legacy = protect > 0
|
||||
&& (available as u64) * 6 / 10 < history as u64
|
||||
&& compressible;
|
||||
assert_eq!(
|
||||
compress_should_trigger(
|
||||
false, 0.70, protect, history, budget, sys, compressible,
|
||||
),
|
||||
legacy,
|
||||
"关分支必须与现行判据逐值等价(b={budget},sys={sys},h={history})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ②开关开:水位边界 ±1 token——恰等于阈值不触发(严格大于),超 1 token 触发。
|
||||
#[test]
|
||||
fn watermark_on_boundary_exact_no_over_yes() {
|
||||
// ratio=0.5 在 f32 精确(阈值=500,无浮点歧义),硬编码锁语义。
|
||||
let (budget, sys) = (1_000u32, 100u32);
|
||||
assert!(
|
||||
!compress_should_trigger(true, 0.5, 6, 400, budget, sys, true),
|
||||
"恰等于阈值(400+100=500)不应触发"
|
||||
);
|
||||
assert!(
|
||||
compress_should_trigger(true, 0.5, 6, 401, budget, sys, true),
|
||||
"超 1 token(401+100=501)应触发"
|
||||
);
|
||||
// 默认 0.70:阈值与实现同式计算(自洽),典型 sys=8K 配置直接锁边界
|
||||
// (替换语义下无 0.6 兜底干扰,水位线即触发下界)。
|
||||
let threshold = (BUDGET as f32 * 0.70) as u64;
|
||||
let at = (threshold - SYS as u64) as u32;
|
||||
assert!(!compress_should_trigger(true, 0.70, 6, at, BUDGET, SYS, true));
|
||||
assert!(compress_should_trigger(true, 0.70, 6, at + 1, BUDGET, SYS, true));
|
||||
}
|
||||
|
||||
/// ③替换语义:开关开时原 0.6 判据不再生效——历史超 0.6 线但未到水位线不触发
|
||||
/// (即设计「触发点后移」的行为差;0.6 判据只在开关关时生效)。
|
||||
#[test]
|
||||
fn watermark_on_replaces_legacy_criterion() {
|
||||
// history=60_000:> 0.6 线 56284(关档此处已触发);< 水位线(71400−8192=63208)。
|
||||
let history = 60_000u32;
|
||||
assert!(
|
||||
compress_should_trigger(false, 0.70, 6, history, BUDGET, SYS, true),
|
||||
"前提自检:同点位关档按 0.6 已触发"
|
||||
);
|
||||
assert!(
|
||||
!compress_should_trigger(true, 0.70, 6, history, BUDGET, SYS, true),
|
||||
"开档 0.6 判据不再并联,未到水位线不触发"
|
||||
);
|
||||
}
|
||||
|
||||
/// ③补:sys 占比大时水位线早于 0.6 兜底线——水位分支真正改变决策的点位
|
||||
/// (同点位开关关不触发,证两分支可分)。
|
||||
#[test]
|
||||
fn watermark_on_fires_where_legacy_would_not() {
|
||||
let (sys, history) = (60_000u32, 20_000u32); // 水位线≈11.4K history < 20K < 兜底线 25.2K
|
||||
let watermark = history as u64 + sys as u64 > (BUDGET as f32 * 0.70) as u64;
|
||||
let legacy = ((BUDGET - sys) as u64) * 6 / 10 < history as u64;
|
||||
assert!(watermark && !legacy, "前提自检:该点位水位触发而 0.6 未触发");
|
||||
assert!(compress_should_trigger(true, 0.70, 6, history, BUDGET, sys, true));
|
||||
assert!(
|
||||
!compress_should_trigger(false, 0.70, 6, history, BUDGET, sys, true),
|
||||
"同点位关=现行判据不触发"
|
||||
);
|
||||
}
|
||||
|
||||
/// ④保护区与可压缩门:两种模式下同样生效(不走分支,任何 token 量都拦得住)。
|
||||
#[test]
|
||||
fn protect_and_compressible_gates_hold_in_both_modes() {
|
||||
for wm in [false, true] {
|
||||
assert!(
|
||||
!compress_should_trigger(wm, 0.70, 0, u32::MAX, BUDGET, 0, true),
|
||||
"保护区门(protect_start=0)必须生效"
|
||||
);
|
||||
assert!(
|
||||
!compress_should_trigger(wm, 0.70, 6, u32::MAX, BUDGET, 0, false),
|
||||
"可压缩门(区外无可压缩)必须生效"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ use super::conv_state::ConvState;
|
||||
|
||||
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
|
||||
///
|
||||
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
|
||||
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
|
||||
/// 抽自同构骨架(insert_destructive_loop_warning / insert_stall_warning /
|
||||
/// insert_tool_call_over_limit_warning 等;原 audit::cache insert_listing_bypass_warning 已退役):
|
||||
/// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。
|
||||
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
|
||||
///
|
||||
|
||||
@@ -180,20 +180,22 @@ pub const MAX_GOALS: usize = 5;
|
||||
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
|
||||
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
|
||||
|
||||
/// 技能清单注入最大条数(默认 120)。
|
||||
/// 技能清单注入最大条数(默认 60)。
|
||||
///
|
||||
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
|
||||
/// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能,
|
||||
/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见),
|
||||
/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。
|
||||
/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token,
|
||||
/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。
|
||||
pub const SKILL_LIST_MAX_ITEMS: usize = 120;
|
||||
/// 渐进披露(D2):清单只做导航,单条「名称+40字描述」索引卡片(约 20 token/条),
|
||||
/// 60 条 ≈ 1.2K token,注入位于 system 稳定段(跨轮缓存命中,只付一次成本);
|
||||
/// 未列出的技能仍可经 read_skill 按名按需读取,不因清单减半而不可用。
|
||||
pub const SKILL_LIST_MAX_ITEMS: usize = 60;
|
||||
|
||||
/// 技能清单单条描述截断长度(默认 80 字符)。
|
||||
/// 技能清单单条描述截断长度(默认 40 字符)。
|
||||
///
|
||||
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。
|
||||
pub const SKILL_DESC_MAX_CHARS: usize = 80;
|
||||
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号;
|
||||
/// 全文按需经 read_skill 读取(渐进披露:清单导航,详情懒取)。
|
||||
pub const SKILL_DESC_MAX_CHARS: usize = 40;
|
||||
|
||||
/// 派工纪律母本(N1 通用能力内置沉淀):主 agent 派工纪律 8 行,措辞可微调不可删项(用户拍板)。
|
||||
///
|
||||
@@ -2464,12 +2466,12 @@ async fn process_round_tools(
|
||||
let round_tool_call_count = tool_calls_acc.len();
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
|
||||
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await;
|
||||
// DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
|
||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
||||
// 挂起审批统计诊断(原 DIRAUTH eprintln 降级为 tracing::debug,
|
||||
// 避免污染 stderr,保留排障能力(RUST_LOG=debug 可见)。
|
||||
tracing::debug!(
|
||||
conv_id = %conv_id,
|
||||
pending_count,
|
||||
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
|
||||
"[AI-PENDING-DIAG] agentic loop 收到 pending"
|
||||
);
|
||||
|
||||
// F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出,
|
||||
@@ -3135,6 +3137,16 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
|
||||
stop_kind: Some("error".to_string()),
|
||||
},
|
||||
);
|
||||
// V50 终局态(P1-B 方案甲,2026-10-21):Fatal=错误终局,补落 status=error——此前
|
||||
// error 全仓零写点(与 completed 同属半闭环缺口)。事件侧 AiAgentFinished 已广播
|
||||
// status=error,持久层在此对齐,完成侧对称写点在 emit_ai_completed_once。
|
||||
// 未落库会话影响 0 行属预期(懒创建),失败仅记日志。
|
||||
{
|
||||
let app_state = app_handle.state::<AppState>();
|
||||
if let Err(e) = app_state.ai_conversations.set_status(conv_id, "error").await {
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] Fatal 写 status=error 失败");
|
||||
}
|
||||
}
|
||||
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
||||
let brief: String = error.chars().take(160).collect();
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
@@ -3200,6 +3212,11 @@ pub(crate) async fn emit_ai_completed_once(
|
||||
}
|
||||
// V49:正常收敛出口清理 loop_state(fire-and-forget 独立 spawn,不与上面的锁段耦合;
|
||||
// 进程内 loop 死亡的残留 running 由启动扫描兜底)。
|
||||
// V50 收尾单点接驳(P1-B 方案甲,2026-10-21):本 helper 是全部正常出口统一 emit 单点,
|
||||
// 此处顺写 status=completed —— 此前 V50 只写 active/incomplete 两态,completed 全仓零
|
||||
// 写点=半闭环(终局态建模只完成一半)。MidStream 保文/截断、用户手动 stop 均经本出口,
|
||||
// 都算「会话如何结束」的完成侧收束(V50 语义注释 migrations.rs migrate_v50:status 管
|
||||
// 「会话如何结束」)。错误侧对称写点在 emit_fatal_error(status=error)。
|
||||
{
|
||||
let app_state = app_handle.state::<AppState>();
|
||||
let db2 = app_state.db.clone();
|
||||
@@ -3207,6 +3224,10 @@ pub(crate) async fn emit_ai_completed_once(
|
||||
tokio::spawn(async move {
|
||||
let repo = df_storage::crud::AiConversationRepo::new(&db2);
|
||||
let _ = repo.set_loop_state(&cid, None).await;
|
||||
// 未落库会话影响 0 行属预期(懒创建,首插 save 随行带默认 active),失败仅记日志
|
||||
if let Err(e) = repo.set_status(&cid, "completed").await {
|
||||
tracing::warn!(conv_id = %cid, error = %e, "[ai] 收敛出口写 status=completed 失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
// B5 无人值守续作引擎收敛回读钩子:引擎在途会话到达本出口即清其标志并唤醒轮询器
|
||||
@@ -3303,7 +3324,13 @@ fn completed_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 六段拼接 ──
|
||||
// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 段序拼接 ──
|
||||
//
|
||||
// **段序=缓存友好原则(2026-10-23 拍板)**:按稳定性递减排列——恒静段(环境/定位/纪律/技能,
|
||||
// 进程级)→会话恒定段(devflow.md 投影)→loop 级段(目标钉扎/工作流 DAG)→最易变段(工作上下文,
|
||||
// 每条任务指令重建)压尾。依据=前缀缓存连续匹配:易变段插中间会使其后恒静段+全部历史陪葬
|
||||
// (实证:新任务后首条命中率跌至 16-33%,25K-38K token 全价重写/次,见
|
||||
// temp/分析-提示词段序缓存友好性-2026-10-23.md)。重排只动拼接次序,各段内容逐字未变。
|
||||
//
|
||||
// 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。
|
||||
// N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。
|
||||
@@ -3343,44 +3370,6 @@ async fn build_runtime_prompt(
|
||||
|
||||
let mut system_prompt = base_prompt;
|
||||
|
||||
// G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||
if !active_goals.is_empty() {
|
||||
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||
let trimmed = g.text.trim();
|
||||
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
truncated
|
||||
};
|
||||
format!("{}. {}", i + 1, truncated)
|
||||
}).collect();
|
||||
let goals_text = goal_lines.join("\n");
|
||||
system_prompt = if GOAL_INJECT_BANNER {
|
||||
format!(
|
||||
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}",
|
||||
system_prompt, goals_text
|
||||
)
|
||||
} else {
|
||||
format!("{}\n\n{}", system_prompt, goals_text)
|
||||
};
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = goals.len(),
|
||||
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||
goals.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。
|
||||
if !working_injection.is_empty() {
|
||||
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection);
|
||||
}
|
||||
|
||||
// EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是
|
||||
// loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。
|
||||
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
|
||||
@@ -3393,7 +3382,7 @@ async fn build_runtime_prompt(
|
||||
"- 所有操作都通过工具完成,用户不参与执行\n",
|
||||
"- 优先使用开发工具 IPC,非必要不写独立脚本\n",
|
||||
"- 脚本需要审批通过才执行,会拖慢工作流\n",
|
||||
"- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
|
||||
"- 已有 80+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
|
||||
"- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n",
|
||||
"- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n",
|
||||
"- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作",
|
||||
@@ -3440,6 +3429,51 @@ async fn build_runtime_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
// 项目规则文件投影(devflow.md/CLAUDE.md):会话级恒定段 —— 内容经 devflow_md 模块
|
||||
// 按会话缓存(同会话只读一次盘)。段序按缓存友好原则排列:恒静段(环境/定位/纪律/技能)
|
||||
// 之后、loop 级易变段(目标/DAG/工作上下文)之前——跨任务指令时目标/工作上下文重建,
|
||||
// 失效面止于它们自身,本段及之前的全部前缀照常命中;工作目录无规则文件时原样返回
|
||||
//(缺失不影响既有组装)。
|
||||
let mut system_prompt =
|
||||
crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await;
|
||||
|
||||
// G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||
if !active_goals.is_empty() {
|
||||
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||
let trimmed = g.text.trim();
|
||||
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
truncated
|
||||
};
|
||||
format!("{}. {}", i + 1, truncated)
|
||||
}).collect();
|
||||
let goals_text = goal_lines.join("\n");
|
||||
system_prompt = if GOAL_INJECT_BANNER {
|
||||
// 目标切换仲裁(2026-10-24 用户实证「切换目标两三轮后失忆回老目标」):
|
||||
// 钉扎是 system 级常驻,优先级天然压过对话流——用户中途口头换向后,
|
||||
// 老目标会持续把模型拉回。仲裁行显式声明「最新对话意志 > 登记目标」,
|
||||
// 保住钉扎防漂移价值的同时不压制用户切换权。
|
||||
format!(
|
||||
"{}\n\n## 当前目标(登记的活跃任务,全程锚定,所有动作须服务于它们;若用户最近消息已明确表达目标切换或新方向,以用户最新指令为准并顺势推进,勿回拉旧目标)\n{}",
|
||||
system_prompt, goals_text
|
||||
)
|
||||
} else {
|
||||
format!("{}\n\n{}", system_prompt, goals_text)
|
||||
};
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = goals.len(),
|
||||
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||
goals.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。
|
||||
if let Some(ref dag_summary) = workflow_dag_summary {
|
||||
system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary);
|
||||
@@ -3450,11 +3484,11 @@ 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;
|
||||
// T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。压尾:每条任务指令
|
||||
// 重建,排最后使前缀缓存失效面止于本段,不拖累恒静段/会话段/目标/DAG。
|
||||
if !working_injection.is_empty() {
|
||||
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection);
|
||||
}
|
||||
|
||||
(system_prompt, goals)
|
||||
}
|
||||
|
||||
@@ -828,3 +828,43 @@ mod midstream_reentry_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// P0-A 口径统一单测:回退轨 round_usage_effective 两态钉死——端点缺 usage(累加器 None →
|
||||
// 全零)走估算打标不静默零;真值(含三口径分项)原样透传不误标。两臂流式主链共用此单点派生。
|
||||
#[cfg(test)]
|
||||
mod usage_caliber_tests {
|
||||
use super::round_usage_effective;
|
||||
use df_ai::provider::TokenUsage;
|
||||
|
||||
/// 缺失回退(openai 臂缺 usage 形态,老端点/网关剥 include_usage):全零 TokenUsage →
|
||||
/// prompt 走 estimated_prompt 兜底 + is_estimated=true(消息级落库标注),completion 不估算。
|
||||
#[test]
|
||||
fn round_usage_effective_missing_usage_falls_back_to_estimate() {
|
||||
let (usage, estimated) = round_usage_effective(&TokenUsage::default(), 1234);
|
||||
assert!(estimated, "全零 usage 必须打估算标,不静默零入库");
|
||||
assert_eq!(usage.prompt_tokens, 1234, "prompt 走估算兜底");
|
||||
assert_eq!(usage.completion_tokens, 0, "completion 不估算(拍板点3,标注已如实)");
|
||||
assert_eq!(usage.total_tokens, 1234, "total = 估算 prompt + completion");
|
||||
}
|
||||
|
||||
/// 真值透传(三口径映射,两臂同轨):端点回真值 → prompt(全量输入,含缓存)/hit/miss/
|
||||
/// reasoning 原样透传落库,is_estimated=false 不误标。
|
||||
#[test]
|
||||
fn round_usage_effective_real_usage_passes_through_three_caliber() {
|
||||
let real = TokenUsage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 30,
|
||||
total_tokens: 130,
|
||||
prompt_cache_hit_tokens: 64,
|
||||
prompt_cache_miss_tokens: 36,
|
||||
reasoning_tokens: 12,
|
||||
};
|
||||
let (usage, estimated) = round_usage_effective(&real, 9999);
|
||||
assert!(!estimated, "真值不误标估算");
|
||||
assert_eq!(usage.prompt_tokens, 100, "真值 prompt 不被估算覆盖");
|
||||
assert_eq!(usage.prompt_cache_hit_tokens, 64, "hit 分项透传");
|
||||
assert_eq!(usage.prompt_cache_miss_tokens, 36, "miss 分项透传");
|
||||
assert_eq!(usage.reasoning_tokens, 12, "reasoning 分项透传");
|
||||
assert_eq!(usage.total_tokens, 130, "total 原样");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,155 +399,9 @@ fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AC-2 ③:run_command 目录列举绕行检测(2026-08-08 最保守版,只警告不硬拒)
|
||||
//
|
||||
// 实证 09e7abfa:LLM list_directory 列某目录后,又用 run_command `Get-ChildItem`/`ls`
|
||||
// 再探同一目录,绕过 ② 的目录级去重。需解析 run_command 命令内容判定,**易误判**
|
||||
// (ls/Get-ChildItem 也有正常用途),故本批只做最保守版:
|
||||
// - 仅当命令含列举类命令(Get-ChildItem/gci/ls) **且** 命令/working_dir 引用了会话中
|
||||
// 已用 list_directory 列过的目录路径(精确子串) 才判绕行;
|
||||
// - 只 insert 一条 system 警告(非静默),不硬拒——命令照常执行/审批,
|
||||
// 误报最多多一条提示,不阻断工作流;
|
||||
// - 未命中 → None,零行为变更。
|
||||
// 已列目录路径来自消息历史扫描(listed_directory_paths),与 find_cached_readonly_result
|
||||
// 同源(读 assistant tool_calls 的 list_directory args.path)。
|
||||
// ============================================================
|
||||
|
||||
/// 词边界子串匹配(纯函数):needle 在 haystack 中出现且前后均非字母数字。
|
||||
///
|
||||
/// 用字节扫描避免引入 regex 依赖;注意 "Get-ChildItem" 含连字符,不能简单按非字母数字
|
||||
/// 分词(会把 get-childitem 拆成 get/childitem),故按整词含连字符匹配。
|
||||
fn contains_word(haystack: &str, needle: &str) -> bool {
|
||||
let bytes = haystack.as_bytes();
|
||||
let n = needle.as_bytes();
|
||||
if n.is_empty() || n.len() > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
let max_start = bytes.len() - n.len();
|
||||
let mut i = 0;
|
||||
while i <= max_start {
|
||||
if &bytes[i..i + n.len()] == n {
|
||||
let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
||||
let next_idx = i + n.len();
|
||||
let next_ok = next_idx >= bytes.len() || !bytes[next_idx].is_ascii_alphanumeric();
|
||||
if prev_ok && next_ok {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 列举类命令检测(纯函数,词边界匹配防误判)。
|
||||
///
|
||||
/// 覆盖 PowerShell `Get-ChildItem`/`gci` 与类 Unix `ls`。`dir` 别名歧义大
|
||||
/// (PowerShell 里既列举又是路径常见词)排除,靠「命令引用已列目录」精化过滤。
|
||||
fn contains_listing_command(command: &str) -> bool {
|
||||
let lower = command.to_ascii_lowercase();
|
||||
contains_word(&lower, "get-childitem")
|
||||
|| contains_word(&lower, "gci")
|
||||
|| contains_word(&lower, "ls")
|
||||
}
|
||||
|
||||
/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory)
|
||||
/// 已列过的目录路径(去重,保留原始大小写)。
|
||||
///
|
||||
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path,
|
||||
/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据
|
||||
/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。
|
||||
async fn listed_directory_paths(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
) -> Vec<String> {
|
||||
use df_ai::provider::MessageRole;
|
||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
||||
let session = session_arc.lock().await;
|
||||
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
|
||||
let mut dirs: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for m in conv.messages.iter() {
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = m.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) {
|
||||
continue;
|
||||
}
|
||||
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
let Some(p) = args.get("path").and_then(|v| v.as_str()) else { continue };
|
||||
if seen.insert(p.to_string()) {
|
||||
dirs.push(p.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// AC-2 ③:检测 run_command 是否用目录列举命令绕行 list_directory 去重。
|
||||
///
|
||||
/// 返回 Some(已列过的目录路径) 当命令含列举类命令且引用了该目录;None = 无绕行。
|
||||
/// 只检测**先前轮次**已列的目录(当前轮同一批 list_directory 尚未写入消息,不覆盖
|
||||
/// 同轮先列后 ls 的极端形态——属边缘场景,主要绕行形态跨轮已覆盖)。
|
||||
pub(crate) async fn detect_listing_bypass(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
// 1) 取命令文本 + working_dir(绕行路径通常出现在二者之一)。
|
||||
let command = args.get("command").and_then(|v| v.as_str())?.trim();
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// 2) 命令须含列举类命令(Get-ChildItem/gci/ls)。
|
||||
if !contains_listing_command(command) {
|
||||
return None;
|
||||
}
|
||||
let working_dir = args.get("working_dir").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// 3) 扫描会话已列过的目录路径。
|
||||
let listed_dirs = listed_directory_paths(session_arc, conv_id).await;
|
||||
if listed_dirs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// 4) 命令或 working_dir 引用已列过的目录 → 绕行(精确子串,不模糊匹配)。
|
||||
for dir in &listed_dirs {
|
||||
if dir.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if command.contains(dir.as_str())
|
||||
|| (!working_dir.is_empty() && working_dir.contains(dir.as_str()))
|
||||
{
|
||||
return Some(dir.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。
|
||||
///
|
||||
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
|
||||
/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。
|
||||
pub(crate) async fn insert_listing_bypass_warning(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
dir: &str,
|
||||
) {
|
||||
let warn_text = format!(
|
||||
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
|
||||
dir
|
||||
);
|
||||
crate::commands::ai::agentic::helpers::insert_system_notice(
|
||||
session_arc,
|
||||
conv_id,
|
||||
&warn_text,
|
||||
&format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// AC-2 ③ 目录列举绕行检测/警告已退役(2026-10-25):仅告警无动作,噪音>价值,
|
||||
// 误报(正常 ls 用途)反误导 LLM。原 detect_listing_bypass/insert_listing_bypass_warning
|
||||
// 整链删除;F-05 高危同参缓存复用(本文件其余部分)不受影响。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -625,30 +479,6 @@ mod tests {
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn listing_command_detects_powershell_and_unix() {
|
||||
assert!(contains_listing_command("Get-ChildItem -Recurse"));
|
||||
assert!(contains_listing_command("gci -Force"));
|
||||
assert!(contains_listing_command("ls -la /tmp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_command_avoids_false_positives() {
|
||||
assert!(!contains_listing_command("git log"));
|
||||
assert!(!contains_listing_command("else"));
|
||||
assert!(!contains_listing_command("glass"));
|
||||
assert!(!contains_listing_command("cd /tmp"));
|
||||
assert!(!contains_listing_command("Get-ChildItem2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_word_boundary_behavior() {
|
||||
// "ls" 不应命中 "else"/"glass"(词边界);"Get-ChildItem" 含连字符仍应整词命中
|
||||
assert!(contains_word("get-childitem -recurse", "get-childitem"));
|
||||
assert!(!contains_word("else", "ls"));
|
||||
assert!(!contains_word("glass", "ls"));
|
||||
}
|
||||
|
||||
// ── cache_hit_warning 目录专用提示 ──
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) async fn audit_finalize(
|
||||
rec.status = status.to_string();
|
||||
rec.decided_by = Some("human".to_string());
|
||||
rec.executed_at = Some(now_millis());
|
||||
// V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入,
|
||||
// V50 纯执行耗时:审批路径由调用方(ai_approve)在真执行窗口测量后传入,
|
||||
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
|
||||
rec.duration_ms = duration_ms;
|
||||
if let Some(r) = result {
|
||||
|
||||
@@ -13,7 +13,7 @@ use df_storage::db::Database;
|
||||
|
||||
use super::event_bus::emit_ai_chat_event;
|
||||
use crate::state::{SettingsNorm, log_slow_lock};
|
||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||
use super::{AiChatEvent, AiSession, ToolCallDraft};
|
||||
use super::tools::entity_resolve;
|
||||
use crate::commands::ai::is_destructive_command;
|
||||
|
||||
@@ -35,7 +35,7 @@ mod diff;
|
||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
|
||||
pub(crate) mod path_auth;
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY};
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass};
|
||||
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
|
||||
pub(super) use path_auth::is_blacklist_bypassed;
|
||||
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
|
||||
@@ -45,7 +45,7 @@ pub(super) use path_auth::{set_blacklist_bypass, BLACKLIST_BYPASS_TEST_LOCK};
|
||||
// approval(audit/approval.rs):审批门控逻辑。
|
||||
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
||||
mod approval;
|
||||
use approval::{detect_retry_count, handle_approval_tool};
|
||||
use approval::handle_approval_tool;
|
||||
|
||||
// record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。
|
||||
// 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
||||
@@ -82,7 +82,7 @@ pub(crate) use finalize::audit_finalize;
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
mod cache;
|
||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
|
||||
pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -171,7 +171,7 @@ async fn deny_sub_agent_tool(
|
||||
}
|
||||
|
||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
||||
/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话
|
||||
/// 不 insert pending_approvals、不 emit AiApprovalRequired——边界会话
|
||||
/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的
|
||||
/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed
|
||||
/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk,
|
||||
@@ -312,7 +312,7 @@ fn upgrade_blocked_hint(blocked: &str) -> String {
|
||||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||||
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
||||
///
|
||||
/// 审批执行窗口(ai_approve / ai_authorize_dir)复用本函数:审批暂停时主 loop 已 return、
|
||||
/// 审批执行窗口(ai_approve)复用本函数:审批暂停时主 loop 已 return、
|
||||
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
|
||||
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
|
||||
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
|
||||
@@ -445,7 +445,7 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
|
||||
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
|
||||
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
|
||||
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
||||
// ai_approve 审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
||||
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
|
||||
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
|
||||
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
|
||||
@@ -613,7 +613,7 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
/// 工厂形态:同步路径与后台 grace 路径各消费一个实例(同一快照语义)。
|
||||
///
|
||||
/// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
|
||||
/// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
||||
/// ai_approve 审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
||||
/// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
|
||||
/// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
|
||||
/// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
|
||||
@@ -661,6 +661,26 @@ fn abort_when_invalid(
|
||||
static ARMED_TIMEOUT_IDS: std::sync::OnceLock<std::sync::Mutex<HashSet<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// 审批挂起到期时刻(ms Unix 时间戳):`approval_timeout_minutes` 配置换算,前端倒计时的单一真值
|
||||
/// (前端本地自动拒绝定时器已退役 2026-10-25,超时收敛只有后端 arm_approval_timeout_timers +
|
||||
/// cleanup_expired_approvals 惰性兜底)。mins=0(禁用超时)→ None,前端不显倒计时。
|
||||
pub(crate) fn approval_expires_at_millis(
|
||||
state: &crate::state::AppState,
|
||||
created_at: std::time::SystemTime,
|
||||
) -> Option<i64> {
|
||||
let mins = state
|
||||
.approval_timeout_minutes
|
||||
.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if mins == 0 {
|
||||
return None;
|
||||
}
|
||||
let ms = created_at
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_millis() as i64;
|
||||
Some(ms + (mins as i64) * 60_000)
|
||||
}
|
||||
|
||||
/// 为当前所有待审批武装「到点主动收敛」定时任务。
|
||||
///
|
||||
/// 幂等:ARMED_TIMEOUT_IDS 去重,同一 tool_call_id 只 spawn 一次。
|
||||
@@ -956,26 +976,21 @@ pub(crate) async fn process_tool_calls(
|
||||
|
||||
// ── 文件工具路径授权预校验 ──
|
||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
// 逐条预校验(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
||||
// - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired),
|
||||
// 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B)
|
||||
// - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程
|
||||
// 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。
|
||||
// path_auth 白名单挂起链(原 NeedsAuth → AiDirAuthRequired → ai_authorize_dir)已退役
|
||||
// (2026-10-25):bypass 恒开使 NeedsAuth 不可达,仅保留防御性硬拒兜底。
|
||||
let allowed_snapshot = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.allowed_dirs
|
||||
.clone();
|
||||
let allowed_guard = allowed_snapshot.read().await.clone();
|
||||
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
|
||||
// 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
|
||||
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
|
||||
// get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。
|
||||
let path_auth_bypass = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.settings
|
||||
.get_bool_norm(PATH_AUTH_BYPASS_KEY, false)
|
||||
.await;
|
||||
// 授权目录白名单机制退役为默认全放行(2026-10-25 用户拍板),原机制见 git 历史。
|
||||
// 存量 KV(df-ai-path-auth-bypass,含显式 false)一律忽略,bypass 恒开:
|
||||
// 主会话全放行,子代理仍走 KeepBlacklist(黑名单安全线不因退役解除)。
|
||||
// PATH_AUTH_BYPASS_KEY 壳保留在 path_auth.rs 作恢复点,前端管理入口已撤。
|
||||
let path_auth_bypass = true;
|
||||
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
||||
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
|
||||
path_auth::set_blacklist_bypass(path_auth_bypass);
|
||||
@@ -987,15 +1002,14 @@ pub(crate) async fn process_tool_calls(
|
||||
} else {
|
||||
PathBypass::Off
|
||||
};
|
||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||
// 收集 Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)。
|
||||
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
|
||||
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
|
||||
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
|
||||
// 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理)
|
||||
let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
let boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
for (idx, draft, args) in drafts {
|
||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||
@@ -1003,33 +1017,14 @@ pub(crate) async fn process_tool_calls(
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) {
|
||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||
FileToolAuthOutcome::NeedsAuth(req) => {
|
||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
||||
// path_auth 白名单挂起链已退役(2026-10-25):bypass 恒开(All/KeepBlacklist)
|
||||
// 下本分支不可达。防御性硬拒兜底(bypass 若回退为 Off,未授权路径拒绝而非静默放行)。
|
||||
let _ = (idx, args, req);
|
||||
let reason = "路径授权机制已退役,未授权路径拒绝".to_string();
|
||||
if is_sub_agent {
|
||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
|
||||
continue;
|
||||
}
|
||||
// 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权
|
||||
// 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired);
|
||||
// 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。
|
||||
if let Some(b) = &boundary {
|
||||
if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) {
|
||||
authorized_drafts.push((idx, draft, args));
|
||||
} else {
|
||||
let uncovered: Vec<String> = req.dirs.iter()
|
||||
.filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d))
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.collect();
|
||||
boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", "))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
|
||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
||||
if auto_exec_mode == "all" {
|
||||
authorized_drafts.push((idx, draft, args));
|
||||
sub_agent_path_denied.push((draft, reason, "needs-auth"));
|
||||
} else {
|
||||
path_auth_pending.push((draft, args, req));
|
||||
path_denied.push((draft, reason));
|
||||
}
|
||||
}
|
||||
FileToolAuthOutcome::Denied(reason) => {
|
||||
@@ -1092,86 +1087,8 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
}
|
||||
|
||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
//
|
||||
// detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
||||
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
|
||||
let dir_str = req.dirs.first()
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
// 短 lock 段:push tool_result(纯写)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
log_slow_lock(__lock_t, "process_tool_calls:305");
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
||||
current_message_id, None,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// 短 lock 段:pending_approvals.insert + 占位 tool_result push(两处纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.pending_approvals.insert(
|
||||
draft.id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
log_slow_lock(__lock_t, "process_tool_calls:325");
|
||||
}
|
||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||
let ev = AiChatEvent::AiDirAuthRequired {
|
||||
id: draft.id.clone(),
|
||||
tool: draft.name.clone(),
|
||||
path: path_str,
|
||||
dir: dir_str,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(&app_handle, ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"pending", risk_level, None, None,
|
||||
current_message_id, None,
|
||||
).await;
|
||||
}
|
||||
// Phase B(原 NeedsAuth → AiDirAuthRequired → ai_authorize_dir 挂起链)已随 path_auth
|
||||
// 退役整体删除(2026-10-25):pending_approvals 单表现在只有 Risk kind 挂起。
|
||||
|
||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||
//
|
||||
@@ -1273,15 +1190,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
}
|
||||
}
|
||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
// 执行/审批(不硬拒,防误判阻断正常命令)。易误判详见 cache.rs detect_listing_bypass 文档。
|
||||
if draft.name == "run_command" {
|
||||
if let Some(dir) = detect_listing_bypass(session_arc, conv_id, &args).await {
|
||||
insert_listing_bypass_warning(session_arc, conv_id, &dir).await;
|
||||
}
|
||||
}
|
||||
// AC-2 ③ 目录列举绕行警告已退役(2026-10-25):仅告警无动作,噪音>价值(误报反误导 LLM)。
|
||||
handle_approval_tool(
|
||||
draft, args, risk_level, &auto_exec_mode,
|
||||
session_arc, conv_id, tools_arc, &audit_repo,
|
||||
@@ -1700,19 +1609,13 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
|
||||
|
||||
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 单表 pending_approvals 统计 pending 总数(path_auth 退役后单 Risk kind)。
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
let pending_approvals_len = {
|
||||
let session = session_arc.lock().await;
|
||||
let len = session.pending_approvals.len();
|
||||
let (p, r) = session.pending_approvals.values()
|
||||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
||||
ApprovalKind::Path(_) => (p + 1, r),
|
||||
ApprovalKind::Risk { .. } => (p, r + 1),
|
||||
});
|
||||
(p, r, len)
|
||||
session.pending_approvals.len()
|
||||
};
|
||||
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len, path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
||||
tracing::debug!(pending_count, pending_approvals_len, "process_tool_calls 返回(挂起审批统计)");
|
||||
pending_count
|
||||
}
|
||||
|
||||
@@ -1724,7 +1627,7 @@ mod tests {
|
||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||
///
|
||||
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||
/// NeedsAuth 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||||
#[test]
|
||||
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
||||
|
||||
@@ -39,9 +39,11 @@ use super::super::augmentation::build_augmentation_segment;
|
||||
use super::super::augmentation::sanitize;
|
||||
use super::super::conversation::save_conversation;
|
||||
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
|
||||
use super::super::prompt::{build_system_prompt_parts, cache_enabled, cache_split_provider_type};
|
||||
use super::super::prompt::{
|
||||
build_system_prompt_parts_with_user, cache_enabled, cache_split_provider_type,
|
||||
};
|
||||
|
||||
use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
||||
use super::super::{AiChatEvent, SessionState};
|
||||
|
||||
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
|
||||
/// "需要用户审批,等待确认")就地替换为终态文本。
|
||||
@@ -386,8 +388,24 @@ pub async fn ai_regenerate(
|
||||
let conv_id = conversation_id.clone();
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
// 渐进披露(D1)门信号:regenerate 重跑末条 user,以其文本判文档策略全文是否附带
|
||||
// (取法同 knowledge_inject 末条 active user 口径:反向扫描 + is_active 过滤)。
|
||||
let doc_user_text = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.and_then(|c| {
|
||||
c.messages
|
||||
.all_messages_clone()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active())
|
||||
})
|
||||
.map(|m| m.content)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
|
||||
build_system_prompt_parts_with_user(&state, &lang, &[], &[], &doc_user_text, cache_on).await;
|
||||
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
|
||||
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
|
||||
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
|
||||
@@ -492,6 +510,49 @@ pub async fn ai_is_generating(
|
||||
Ok(state.conv_states.is_active(&target))
|
||||
}
|
||||
|
||||
/// 拖拽/粘贴的非图片文件落盘(2026-10-19 用户反馈「发送文件变成一堆文本」根治):
|
||||
/// WebView2 原生行为会把拖入 textarea 的文本文件内容直接插入输入框——前端接管全部
|
||||
/// 文件 drop/paste 后,非图片文件经此 IPC 落盘 attachments/(hash 去重),返回本机
|
||||
/// 路径供前端插引用行,AI 经 read_file 按需读取(与飞书文件入站同范式,体验一致)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_save_dropped_file(
|
||||
state: State<'_, AppState>,
|
||||
file_name: String,
|
||||
data: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
if data.is_empty() {
|
||||
return Err("文件内容为空".to_string());
|
||||
}
|
||||
// 对齐飞书入站文件上限:防超大件烧磁盘
|
||||
const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
|
||||
if data.len() > MAX_ATTACHMENT_BYTES {
|
||||
return Err(format!("文件超过 20MB 上限({} 字节)", data.len()));
|
||||
}
|
||||
let name = sanitize_file_name(&file_name);
|
||||
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), None);
|
||||
let path = store.store_attachment(&data, &name).await?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 文件名清洗:去路径穿越与非法字符(对齐 media_store::sanitize_name 语义,本地复用
|
||||
/// 防跨模块私有依赖;空名兜底"unnamed")。
|
||||
fn sanitize_file_name(name: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let trimmed = cleaned.trim().trim_matches('.').to_string();
|
||||
if trimmed.is_empty() {
|
||||
"unnamed".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260801 Phase2:anthropic 协议 url→base64 预拉
|
||||
// ============================================================
|
||||
@@ -965,7 +1026,7 @@ pub(crate) async fn spawn_agentic_session(
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
|
||||
build_system_prompt_parts_with_user(&state, &lang, &excl_proj, &excl_task, &message, cache_on).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
|
||||
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
|
||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
@@ -993,6 +1054,20 @@ pub(crate) async fn spawn_agentic_session(
|
||||
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
|
||||
}
|
||||
}
|
||||
// 飞书渠道交付引导(#958 生产端配套,2026-10-21):出站回传消费端在渠道侧
|
||||
// (extract_delivery_paths/reply_deliveries),但 prompt 层此前零处教 AI 写 [交付] 标记行,
|
||||
// AI 不会自发写标记,链路空转(用户实证「不会用飞书发文件」)。落点=spawn 链渠道标识
|
||||
// 透传判定(source.channel,#958 溯源同参),零新管道;拼装与 aug_seg 同构——缓存模式并
|
||||
// 入 volatile 尾、非缓存直拼 system,均在稳定段之后,BE3 指纹键不含此段零缓存失效。
|
||||
// 文案本体归渠道模块单一事实源(DELIVERY_GUIDANCE_PROMPT,与解析器/回传/拦截同文件演化)。
|
||||
if source.as_ref().map(|s| s.channel.as_str()) == Some(crate::feishu_channel::SOURCE_CHANNEL_FEISHU) {
|
||||
let guidance = crate::feishu_channel::DELIVERY_GUIDANCE_PROMPT;
|
||||
if cache_on {
|
||||
volatile = append_volatile(volatile, guidance);
|
||||
} else {
|
||||
system_prompt = format!("{}\n\n---\n{}", system_prompt, guidance);
|
||||
}
|
||||
}
|
||||
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
|
||||
|
||||
// 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。
|
||||
@@ -1235,9 +1310,8 @@ pub async fn ai_approve(
|
||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。
|
||||
// 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表,
|
||||
// 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。
|
||||
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove。
|
||||
// 无挂起则查审计表(幂等,已处理返成功)。
|
||||
// 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
|
||||
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
|
||||
@@ -1272,16 +1346,6 @@ pub async fn ai_approve(
|
||||
return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status));
|
||||
}
|
||||
};
|
||||
// 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。
|
||||
// 若前端误调 ai_approve 消费 kind==Path 挂起(应走 ai_authorize_dir)→ Err 防误调,
|
||||
// 避免把路径授权挂起当 Risk 审批执行(会跳过白名单写入,语义错乱)。
|
||||
// 兜底/回退:误调返回 Err 前不回滚 insert(前端拿 Err 自行刷新 pending,等价原「未找到」语义)。
|
||||
if !matches!(approval.kind, ApprovalKind::Risk { .. }) {
|
||||
return Err(format!(
|
||||
"tool_call_id={} 非普通审批(路径授权请用 ai_authorize_dir)",
|
||||
tool_call_id
|
||||
));
|
||||
}
|
||||
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
|
||||
let _recovered = approval.recovered;
|
||||
|
||||
@@ -1495,324 +1559,17 @@ pub async fn ai_approve(
|
||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||
|
||||
|
||||
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
|
||||
///
|
||||
/// 仅做词法判断(取前两字符 `<drive>:`),不做 canonicalize(避免 IO 卡死——这正是本批
|
||||
/// 防卡死预检的目标)。UNC 路径 `\\server\share` 返 None(跨盘语义不适用)。
|
||||
fn drive_letter(path: &str) -> Option<String> {
|
||||
let bytes = path.as_bytes();
|
||||
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
|
||||
let drive = (bytes[0] as char).to_ascii_uppercase();
|
||||
return Some(format!("{}:", drive));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
|
||||
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
|
||||
fn workspace_drive() -> Option<String> {
|
||||
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
|
||||
///
|
||||
/// 仅对涉及文件移动/删除的工具(delete_file / rename_file)有意义——这些工具的目标位
|
||||
/// (.trash 或 rename 的 to)锚定 workspace_root,源在另一盘时走 copy+remove 降级。
|
||||
/// 其他文件工具(read_file/write_file)无跨盘移动语义,不提示。
|
||||
fn cross_drive_hint(tool_name: &str, raw_paths: &[String]) -> Option<String> {
|
||||
// 仅文件移动/删除类工具涉及跨盘(源在 A 盘 → 目标 .trash/to 在 workspace 盘)
|
||||
if !matches!(tool_name, "delete_file" | "rename_file" | "move_file") {
|
||||
return None;
|
||||
}
|
||||
let ws_drive = workspace_drive()?;
|
||||
let cross_drives: Vec<&String> = raw_paths.iter()
|
||||
.filter(|p| drive_letter(p).map_or(false, |d| d != ws_drive))
|
||||
.collect();
|
||||
if cross_drives.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"⚠ 跨盘提示:路径 {:?} 与工作区({})不同盘,工具内部走 copy+remove 降级(非原子,大文件较慢,失败可能留残,已内置回滚)",
|
||||
cross_drives, ws_drive
|
||||
))
|
||||
}
|
||||
|
||||
/// 把跨盘提示追加到 tool_result(保留原结果,仅追加提示行)。
|
||||
///
|
||||
/// - String 结果:追加 `\n<提示>`。
|
||||
/// - Object 结果:追加 `cross_drive_note` 字段(不覆盖原字段)。
|
||||
/// - 其他类型:包成 Object 的 `result` + `cross_drive_note` 双字段。
|
||||
fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json::Value {
|
||||
match result {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(format!("{}\n{}", s, note)),
|
||||
serde_json::Value::Object(mut map) => {
|
||||
map.insert("cross_drive_note".to_string(), serde_json::Value::String(note.to_string()));
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
other => serde_json::json!({
|
||||
"result": other,
|
||||
"cross_drive_note": note,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。
|
||||
///
|
||||
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
|
||||
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
|
||||
///
|
||||
/// `decision`(三授权档 + 拒):
|
||||
/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
|
||||
/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
|
||||
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
|
||||
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
|
||||
///
|
||||
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
|
||||
/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_authorize_dir(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
tool_call_id: String,
|
||||
decision: String,
|
||||
) -> Result<String, String> {
|
||||
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
|
||||
// 避免污染 stderr(用户可见)。
|
||||
tracing::debug!(
|
||||
tool_call_id = %tool_call_id,
|
||||
decision = %decision,
|
||||
"[AI-AUTHZ] ai_authorize_dir 入口"
|
||||
);
|
||||
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
||||
let approval = {
|
||||
let __lock_t864 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let v = match session.pending_approvals.remove(&tool_call_id) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
// 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。
|
||||
drop(session);
|
||||
// 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复):
|
||||
// path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留
|
||||
// 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端
|
||||
// pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时
|
||||
// emit AiApprovalResult{approved:false} 让前端收敛(幂等)。
|
||||
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
|
||||
Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => {
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: rec.conversation_id.clone(),
|
||||
};
|
||||
emit_ai_chat_event(&app, ev);
|
||||
return Ok(format!("已处理({})", rec.status));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Err(format!("未找到路径授权挂起: {}", tool_call_id));
|
||||
}
|
||||
};
|
||||
log_slow_lock(__lock_t864, "ai_authorize_dir:864");
|
||||
v
|
||||
};
|
||||
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
|
||||
// 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。
|
||||
// 兜底/回退:Err 前 re-insert 回 pending_approvals 保持挂起态(原「未找到」会丢挂起致卡死,
|
||||
// re-insert 保守兜底防误调导致 pending 丢失 + loop 永挂)。
|
||||
let req = match approval.kind.clone() {
|
||||
ApprovalKind::Path(req) => req,
|
||||
ApprovalKind::Risk { .. } => {
|
||||
// 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。
|
||||
let __lock_t878 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.pending_approvals.insert(tool_call_id.clone(), approval);
|
||||
log_slow_lock(__lock_t878, "ai_authorize_dir:878");
|
||||
return Err(format!(
|
||||
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
|
||||
tool_call_id
|
||||
));
|
||||
}
|
||||
};
|
||||
// L1 补丁:req.dirs 含所有未授权父目录(rename_file from/to 不同目录时多项)。
|
||||
// once/always 遍历全部写入白名单,确保工具执行时 handler 内 resolve 全放行。
|
||||
let dir_strs: Vec<String> = req.dirs.iter()
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let conv_id = approval.conversation_id.clone();
|
||||
let args = approval.arguments.clone();
|
||||
|
||||
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
|
||||
if decision == "deny" {
|
||||
// BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
let err_msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "path_auth_denied",
|
||||
"message": "用户拒绝路径授权"
|
||||
}).to_string();
|
||||
{
|
||||
let __lock_t903 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
|
||||
}
|
||||
log_slow_lock(__lock_t903, "ai_authorize_dir:903");
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
emit_ai_chat_event(&app, ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
emit_ai_chat_event(&app, ev_approval);
|
||||
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t930 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
log_slow_lock(__lock_t930, "ai_authorize_dir:930");
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
return Ok("rejected".to_string());
|
||||
}
|
||||
|
||||
// ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
|
||||
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
|
||||
// deny 已在上方早 return,此处仅 once/session/always。
|
||||
match decision.as_str() {
|
||||
"once" => {
|
||||
for d in &dir_strs {
|
||||
state.add_once_allowed_dir(d.clone()).await;
|
||||
}
|
||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
|
||||
}
|
||||
"session" => {
|
||||
for d in &dir_strs {
|
||||
state.add_session_allowed_dir(d.clone()).await;
|
||||
}
|
||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
|
||||
}
|
||||
"always" => {
|
||||
for d in &dir_strs {
|
||||
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
|
||||
tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
|
||||
// 降级:写入会话临时授权,保本路径本会话可用
|
||||
state.add_session_allowed_dir(d.clone()).await;
|
||||
}
|
||||
}
|
||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
|
||||
}
|
||||
_ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
|
||||
}
|
||||
|
||||
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
|
||||
// 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
|
||||
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
|
||||
// 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
|
||||
// execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
|
||||
// V50 纯执行耗时:只测真执行窗口,不含等待人工批准。
|
||||
let exec_started = std::time::Instant::now();
|
||||
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
|
||||
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
|
||||
} else {
|
||||
// 授权执行窗口复用 execute_with_heartbeat(对齐 ai_approve,详见其执行段注释):
|
||||
// 授权挂起期主 loop 心跳已停,慢文件工具(如跨盘 copy+remove 降级)整窗静默
|
||||
// → 前端看门狗误杀;附带补齐 run_command 实时流式与超时 clamp(原恒 60s)。
|
||||
execute_with_heartbeat(
|
||||
&state.ai_tools, &approval.tool_name, args.clone(),
|
||||
&app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
|
||||
).await
|
||||
};
|
||||
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
||||
tracing::debug!(
|
||||
ok = exec_result.is_ok(),
|
||||
status = if exec_result.is_ok() { "completed" } else { "failed" },
|
||||
"[AI-AUTHZ] execute 后"
|
||||
);
|
||||
let (audit_status, mut result_val) = match &exec_result {
|
||||
Ok(val) => ("completed", val.clone()),
|
||||
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
|
||||
};
|
||||
// 阶段4(容错/恢复):跨盘预检 → tool_result 提示。
|
||||
// 检测授权路径(req.raw_paths 规范化前原值)与 workspace_root 是否跨盘(Windows 不同盘符,
|
||||
// 如 C盘授权路径 → E盘 workspace)。跨盘时文件工具(delete_file→.trash / rename_file from→to)
|
||||
// 内部走 copy+remove 降级(非原子,大文件慢),提示 LLM 知晓成本 + 失败留残风险。
|
||||
// 提示追加到 result_val(tool_result),不阻断执行(降级已能处理,仅告知)。
|
||||
if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
|
||||
result_val = append_cross_drive_note(result_val, ¬e);
|
||||
}
|
||||
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
|
||||
if exec_result.is_ok() {
|
||||
emit_data_changed(&app, &approval.tool_name);
|
||||
// MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
|
||||
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
|
||||
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
|
||||
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
|
||||
state.reload_allowed_dirs().await;
|
||||
}
|
||||
}
|
||||
|
||||
// once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
|
||||
// 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
|
||||
state.clear_once_allowed_dirs().await;
|
||||
|
||||
// 替换占位 tool_result 为真实结果
|
||||
{
|
||||
let __lock_t1018 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
|
||||
}
|
||||
log_slow_lock(__lock_t1018, "ai_authorize_dir:1018");
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: result_val.clone(),
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
emit_ai_chat_event(&app, ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: true,
|
||||
conversation_id: conv_id.clone(),
|
||||
};
|
||||
emit_ai_chat_event(&app, ev_approval);
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t1044 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
log_slow_lock(__lock_t1044, "ai_authorize_dir:1044");
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
Ok(audit_status.to_string())
|
||||
}
|
||||
|
||||
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
|
||||
///
|
||||
/// 阶段4:`kind` 字段透传(`risk` / `path`),前端 switchConversation 恢复 pendingApprovals 时
|
||||
/// 按 kind 渲染——risk 类显 approve/reject,path 类显 once/always/deny(对齐阶段3b 统一审批模型)。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PendingToolCallInfo {
|
||||
pub tool_call_id: String,
|
||||
pub conversation_id: Option<String>,
|
||||
/// 阶段4:审批类型,前端按 kind 渲染不同决策按钮。
|
||||
/// 'risk' = 普通 RiskLevel 审批(approve/reject);'path' = 路径授权(once/always/deny)。
|
||||
/// 审批类型,恒 'risk'(path_auth 已退役,2026-10-25)。字段保留作前端协议兼容。
|
||||
#[serde(rename = "kind")]
|
||||
pub kind: &'static str,
|
||||
/// 后端超时收敛到期时刻(ms Unix 时间戳,前端倒计时单一真值);None=禁用超时/异常无 created_at。
|
||||
#[serde(rename = "expires_at_millis")]
|
||||
pub expires_at_millis: Option<i64>,
|
||||
}
|
||||
|
||||
/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
|
||||
@@ -1823,21 +1580,17 @@ pub async fn ai_pending_tool_calls(
|
||||
) -> Result<Vec<PendingToolCallInfo>, String> {
|
||||
let __lock_t1071 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
// 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。
|
||||
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
|
||||
// (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
|
||||
// (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
|
||||
// 单 pending_approvals 表按 conversation_id 过滤;kind 恒 'risk'(path_auth 已退役)。
|
||||
let list = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str()))
|
||||
.map(|a| PendingToolCallInfo {
|
||||
expires_at_millis: a.created_at
|
||||
.and_then(|c| crate::commands::ai::audit::approval_expires_at_millis(state.inner(), c)),
|
||||
tool_call_id: a.tool_call_id.clone(),
|
||||
conversation_id: a.conversation_id.clone(),
|
||||
kind: match a.kind {
|
||||
ApprovalKind::Risk { .. } => "risk",
|
||||
ApprovalKind::Path(_) => "path",
|
||||
},
|
||||
kind: "risk",
|
||||
})
|
||||
.collect();
|
||||
log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071");
|
||||
@@ -1900,6 +1653,11 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
// pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
|
||||
finalize_pending_placeholders(&mut *session, id, "会话已清除");
|
||||
session.conv(id).messages.clear();
|
||||
// V45 边界随代同亡(P1-B 代史收尾,2026-10-21):硬清空=旧任务代终局,内存边界同步清。
|
||||
// per_conv.boundary 是真相源(save 每轮随行覆写 DB),不清则下轮 save 会把 DB 侧刚在
|
||||
// clear_conversation_atomic 归位的 boundary_json=NULL 重新覆写成旧边界,旧任务约束
|
||||
// (风险上限/目录白名单/禁破坏性)泄漏给同壳新任务。
|
||||
session.conv(id).boundary = None;
|
||||
// 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(id.as_str()));
|
||||
}
|
||||
@@ -2215,7 +1973,7 @@ pub async fn ai_chat_edit(
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
|
||||
build_system_prompt_parts_with_user(&state, &lang, &[], &[], &new_message, cache_on).await;
|
||||
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
|
||||
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
|
||||
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
|
||||
@@ -2485,7 +2243,7 @@ pub async fn ai_chat_force_send(
|
||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||
let (mut system_prompt, mut volatile) =
|
||||
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
|
||||
build_system_prompt_parts_with_user(&state, &lang, &excl_proj, &excl_task, &message, cache_on).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
|
||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
|
||||
@@ -705,7 +705,7 @@ pub async fn ai_conversation_switch(
|
||||
//
|
||||
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||
// 审批处理时(ai_approve)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! - [`config`] — skills + config 域 4 个 IPC(ai_list_skills + ai_set_concurrency_config
|
||||
//! + ai_set_agent_max_iterations + ai_set_agent_max_retries);`ai_list_skills` 复用
|
||||
//! `super::super::skills::{skills_cached, SkillInfo}`(skills.rs 缓存+扫盘,config 仅 IPC 包装)
|
||||
//! - [`search`] — search 域 1 个 IPC(search_messages 跨会话消息检索,P1-A)+ 共享内核
|
||||
//! (keyword_search / recent_conversation_briefs,AI 工具 history_search 同源复用)
|
||||
//!
|
||||
//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider,config}::*`
|
||||
//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider,config,search}::*`
|
||||
//! 把各域 IPC 拉到 `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*`
|
||||
//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
||||
//!
|
||||
@@ -32,3 +34,6 @@ pub use self::provider::*;
|
||||
pub mod config;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::config::*;
|
||||
pub mod search;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::search::*;
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! search 域 IPC — 跨会话消息检索(P1-A,对标 u-claw #5)
|
||||
//!
|
||||
//! `search_messages` IPC 与 AI 工具 `history_search`(tools/history.rs)共用本文件内核
|
||||
//! ([`keyword_search`] / [`recent_conversation_briefs`]):同一 Repo 查询两条薄包装,
|
||||
//! DRY 无双轨(设计:分析-P1A-跨会话检索-2026-10-21 §三③)。
|
||||
//!
|
||||
//! 依赖方向:分词在 command 层完成(extract_query_keywords,与 knowledge_search 同源),
|
||||
//! 关键词数组传 Repo——df-storage 在依赖下层,不能反向依赖 src-tauri。
|
||||
//! LIKE 起步 + role 过滤 user+assistant+system(tool 结果默认排除,拍板④);
|
||||
//! FTS5 升级条件留档在 message_repo.rs 搜索方法头(拍板⑥,>50 万行或 P95>500ms 再评估)。
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use df_storage::crud::{AiConversationRepo, AiMessageRepo, MessageSearchHit};
|
||||
use df_storage::db::Database;
|
||||
|
||||
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||
use crate::commands::ai::knowledge_inject::extract_query_keywords;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// IPC 分页:默认条数与上限(分析 §4.2:默认 20,帽 50)。
|
||||
const SEARCH_DEFAULT_LIMIT: usize = 20;
|
||||
const SEARCH_MAX_LIMIT: usize = 50;
|
||||
|
||||
/// 会话概要截断长度(拍板⑤降级链产物统一口径:截 200 字)。
|
||||
const CONVERSATION_SUMMARY_CHARS: usize = 200;
|
||||
|
||||
/// 关键词检索命中 DTO:会话摘要元信息(id/title/updated_at)+ 命中片段(Repo 层已截 500 字)。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct MessageSearchDto {
|
||||
pub conversation_id: String,
|
||||
pub title: Option<String>,
|
||||
pub updated_at: String,
|
||||
/// 子代理轨迹标记(sub_agent);主会话为 null
|
||||
pub origin: Option<String>,
|
||||
pub archived: bool,
|
||||
pub message_id: String,
|
||||
pub seq: i64,
|
||||
pub role: String,
|
||||
pub timestamp: Option<i64>,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// 会话概要 DTO(无参列最近会话)。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ConversationBrief {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// 摘要降级链产物(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字;空会话为空串
|
||||
pub summary: String,
|
||||
pub updated_at: String,
|
||||
pub origin: Option<String>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
/// 关键词检索内核(IPC 与 AI 工具共用):分词 → Repo LIKE 检索 → 会话元信息补全。
|
||||
///
|
||||
/// query 分词后无有效关键词(纯停用词/纯标点)返空 Vec,调用方按空结果给指引文案
|
||||
/// (禁裸空数组,tools/knowledge.rs 同惯例)。
|
||||
pub(crate) async fn keyword_search(
|
||||
db: &Arc<Database>,
|
||||
query: &str,
|
||||
conversation_id: Option<&str>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<MessageSearchDto>, String> {
|
||||
let keywords = extract_query_keywords(query);
|
||||
if keywords.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let hits = AiMessageRepo::new(db)
|
||||
.search_keywords(&keywords, conversation_id, limit, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
enrich_with_conversations(db, hits).await
|
||||
}
|
||||
|
||||
/// 命中列表补会话元信息:按去重后的会话 id 逐个 PK 查询(hit 上限几十条、会话数更少,
|
||||
/// 子毫秒级 PK lookup,不值得为此加批量 Repo 面)。会话已删的孤儿消息按空元信息兜底。
|
||||
async fn enrich_with_conversations(
|
||||
db: &Arc<Database>,
|
||||
hits: Vec<MessageSearchHit>,
|
||||
) -> Result<Vec<MessageSearchDto>, String> {
|
||||
let conv_repo = AiConversationRepo::new(db);
|
||||
// 元信息三元组:(title, updated_at, origin, archived)
|
||||
let mut metas: HashMap<String, (Option<String>, String, Option<String>, bool)> = HashMap::new();
|
||||
let conv_ids: HashSet<String> = hits.iter().map(|h| h.conversation_id.clone()).collect();
|
||||
for conv_id in conv_ids {
|
||||
let record = conv_repo.get_by_id(&conv_id).await.map_err(|e| e.to_string())?;
|
||||
if let Some(r) = record {
|
||||
metas.insert(conv_id, (r.title, r.updated_at, r.origin, r.archived));
|
||||
}
|
||||
}
|
||||
Ok(hits
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
let (title, updated_at, origin, archived) = metas
|
||||
.get(&h.conversation_id)
|
||||
.cloned()
|
||||
.unwrap_or((None, String::new(), None, false));
|
||||
MessageSearchDto {
|
||||
conversation_id: h.conversation_id,
|
||||
title,
|
||||
updated_at,
|
||||
origin,
|
||||
archived,
|
||||
message_id: h.message_id,
|
||||
seq: h.seq,
|
||||
role: h.role,
|
||||
timestamp: h.timestamp,
|
||||
snippet: h.snippet,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 最近会话概要列表(无参降级数据源):与 ai_conversation_list 同源 list_all,
|
||||
/// 排除子代理轨迹(origin='sub_agent',对齐侧栏口径),按 updated_at DESC 取前 limit。
|
||||
///
|
||||
/// 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截
|
||||
/// [`CONVERSATION_SUMMARY_CHARS`] 字;两级皆空(空会话)为空串。
|
||||
pub(crate) async fn recent_conversation_briefs(
|
||||
db: &Arc<Database>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ConversationBrief>, String> {
|
||||
let records = AiConversationRepo::new(db)
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut mains: Vec<_> = records.into_iter().filter(|r| r.origin.is_none()).collect();
|
||||
// updated_at 毫秒串数值降序(「最近」=最近活跃,非最近创建);脏数据按 0 沉底
|
||||
mains.sort_by_key(|r| std::cmp::Reverse(r.updated_at.parse::<u64>().unwrap_or(0)));
|
||||
mains.truncate(limit);
|
||||
let sources = AiMessageRepo::new(db)
|
||||
.summary_sources(&mains.iter().map(|r| r.id.clone()).collect::<Vec<_>>())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(mains
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let summary = sources
|
||||
.get(&r.id)
|
||||
.and_then(|s| match (&s.last_system, &s.first_user) {
|
||||
(Some(sys), _) if !sys.trim().is_empty() => Some(sys.clone()),
|
||||
(_, Some(usr)) if !usr.trim().is_empty() => Some(usr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.map(|s| truncate_chars_ellipsis(&s, CONVERSATION_SUMMARY_CHARS))
|
||||
.unwrap_or_default();
|
||||
ConversationBrief {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary,
|
||||
updated_at: r.updated_at,
|
||||
origin: r.origin,
|
||||
archived: r.archived,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 跨会话消息全文检索(P1-A 后端搜索面,F4-1 前端侧栏升级的预留 IPC)。
|
||||
///
|
||||
/// 命中片段 Repo 层已截 500 字防全文回灌;role 默认排除 tool(拍板④);
|
||||
/// query 分词后无有效关键词返空 results(前端空态自理)。
|
||||
#[tauri::command]
|
||||
pub async fn search_messages(
|
||||
state: State<'_, AppState>,
|
||||
query: String,
|
||||
conversation_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
) -> Result<Vec<MessageSearchDto>, String> {
|
||||
keyword_search(
|
||||
&state.db,
|
||||
&query,
|
||||
conversation_id.as_deref(),
|
||||
limit.unwrap_or(SEARCH_DEFAULT_LIMIT).min(SEARCH_MAX_LIMIT),
|
||||
offset.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::AiMessageRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{AiConversationRecord, AiMessageRecord};
|
||||
|
||||
async fn test_db() -> Arc<Database> {
|
||||
Arc::new(Database::open_in_memory().await.unwrap())
|
||||
}
|
||||
|
||||
async fn ins_conv(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>, updated_at: &str) {
|
||||
AiConversationRepo::new(db)
|
||||
.insert(AiConversationRecord {
|
||||
id: id.into(),
|
||||
title: title.map(|s| s.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: "100".into(),
|
||||
updated_at: updated_at.into(),
|
||||
origin: origin.map(|s| s.into()),
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord {
|
||||
AiMessageRecord {
|
||||
id: format!("m_{conv}_{seq}"),
|
||||
conversation_id: conv.into(),
|
||||
seq,
|
||||
role: role.into(),
|
||||
content: content.into(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: "active".into(),
|
||||
reasoning_content: None,
|
||||
timestamp: Some(timestamp),
|
||||
created_at: "100".into(),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 关键词检索内核:分词命中 + 会话元信息(title/origin/archived)补全;
|
||||
/// tool role 不入检索面;纯停用词 query 返空。
|
||||
#[tokio::test]
|
||||
async fn keyword_search_enriches_conversation_meta() {
|
||||
let db = test_db().await;
|
||||
ins_conv(&db, "c1", Some("部署踩坑复盘"), None, "200").await;
|
||||
ins_conv(&db, "c2", None, Some("sub_agent"), "300").await;
|
||||
AiMessageRepo::new(&db)
|
||||
.insert_batch(vec![
|
||||
msg("c1", 0, "user", "上次部署服务踩了端口占用的坑", 100),
|
||||
msg("c1", 1, "tool", "部署 tool 结果正文", 200),
|
||||
msg("c2", 0, "assistant", "子代理轨迹里的部署结论", 300),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hits = keyword_search(&db, "帮我看看部署相关的历史", None, 20, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
// tool 结果排除(user+assistant 命中,tool 不入),两会话各一条
|
||||
assert_eq!(hits.len(), 2, "tool 应排除: {:?}", hits.iter().map(|h| &h.role).collect::<Vec<_>>());
|
||||
let c1 = hits.iter().find(|h| h.conversation_id == "c1").unwrap();
|
||||
assert_eq!(c1.title.as_deref(), Some("部署踩坑复盘"), "命中应补会话标题");
|
||||
assert!(c1.snippet.contains("部署"), "片段应含命中词: {}", c1.snippet);
|
||||
// 子代理轨迹会话同样可检索(origin 透出供 AI 识别轨迹)
|
||||
let c2 = hits.iter().find(|h| h.conversation_id == "c2").unwrap();
|
||||
assert_eq!(c2.origin.as_deref(), Some("sub_agent"));
|
||||
|
||||
// 纯停用词/客套话 query → 分词后无有效关键词 → 空集(调用方给指引)
|
||||
let empty = keyword_search(&db, "帮我看看一下这个", None, 20, 0).await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
/// 无参列最近会话:摘要降级链(最近 system 压缩摘要 > 首条 user)、updated_at DESC、
|
||||
/// 子代理轨迹排除、limit 截断。
|
||||
#[tokio::test]
|
||||
async fn recent_briefs_summary_fallback_chain() {
|
||||
let db = test_db().await;
|
||||
// c_old:更新最早(100);c_user:无 system,走首条 user 兜底(200);
|
||||
// c_sum:有压缩摘要,优先于 user(300);c_sub:子代理轨迹排除(400);c_tail:超 limit 沉底(50)
|
||||
ins_conv(&db, "c_old", Some("最早会话"), None, "100").await;
|
||||
ins_conv(&db, "c_user", Some("无摘要会话"), None, "200").await;
|
||||
ins_conv(&db, "c_sum", Some("有摘要会话"), None, "300").await;
|
||||
ins_conv(&db, "c_sub", Some("子代理轨迹"), Some("sub_agent"), "400").await;
|
||||
ins_conv(&db, "c_tail", Some("超limit沉底"), None, "50").await;
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
repo.insert_batch(vec![
|
||||
msg("c_old", 0, "user", "c_old 首条", 1),
|
||||
msg("c_user", 0, "user", "c_user 的首条用户消息,应作摘要兜底", 2),
|
||||
msg("c_sum", 0, "user", "c_sum 首条不应入选", 3),
|
||||
msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要应优先", 4),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let briefs = recent_conversation_briefs(&db, 4).await.unwrap();
|
||||
let ids: Vec<&str> = briefs.iter().map(|b| b.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["c_sum", "c_user", "c_old", "c_tail"],
|
||||
"updated_at DESC 且子代理轨迹排除: {ids:?}"
|
||||
);
|
||||
let c_sum = briefs.iter().find(|b| b.id == "c_sum").unwrap();
|
||||
assert!(c_sum.summary.contains("c_sum 压缩摘要应优先"), "system 摘要优先: {}", c_sum.summary);
|
||||
assert!(!c_sum.summary.contains("不应入选"), "user 消息不得越过 system 摘要");
|
||||
let c_user = briefs.iter().find(|b| b.id == "c_user").unwrap();
|
||||
assert!(c_user.summary.contains("首条用户消息"), "无 system 时首条 user 兜底: {}", c_user.summary);
|
||||
let c_old = briefs.iter().find(|b| b.id == "c_old").unwrap();
|
||||
// c_old 只有一条 user?其摘要也应来自首条 user(链路兜底)
|
||||
assert!(c_old.summary.contains("c_old 首条"), "仅 user 消息时同样兜底: {}", c_old.summary);
|
||||
}
|
||||
|
||||
/// 摘要截断:超 200 字的 system 摘要截断加省略号(拍板⑤统一口径)。
|
||||
#[tokio::test]
|
||||
async fn recent_briefs_truncates_long_summary() {
|
||||
let db = test_db().await;
|
||||
ins_conv(&db, "c_long", Some("长摘要会话"), None, "100").await;
|
||||
let long_summary = format!("## 意图\n- {}", "长".repeat(300));
|
||||
AiMessageRepo::new(&db)
|
||||
.insert_batch(vec![msg("c_long", 0, "system", &long_summary, 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let briefs = recent_conversation_briefs(&db, 10).await.unwrap();
|
||||
assert_eq!(briefs.len(), 1);
|
||||
let summary_chars = briefs[0].summary.chars().count();
|
||||
assert_eq!(
|
||||
summary_chars, CONVERSATION_SUMMARY_CHARS + 1,
|
||||
"截 200 字 + 省略号,实际 {summary_chars}"
|
||||
);
|
||||
assert!(briefs[0].summary.ends_with('…'));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
|
||||
//!
|
||||
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
|
||||
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
|
||||
//! - `devflow.md`:agent 生态自己的项目认知文件,目录式渐进披露——纪律类分区
|
||||
//! (标题含 纪律/规范/必读/铁律)全文保留,其余分区收敛为单行目录;
|
||||
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
|
||||
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
|
||||
//!
|
||||
@@ -19,7 +20,7 @@
|
||||
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
|
||||
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
|
||||
//!
|
||||
//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询
|
||||
//! 裁剪纪律:注入的 token 才是成本——分区按字符上限截断 + 总闸 3000 收口,路标条目数被查询
|
||||
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
|
||||
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
|
||||
|
||||
@@ -35,16 +36,19 @@ use crate::state::AllowedDirs;
|
||||
|
||||
// ── 裁剪上限(字符数,中文一字一符) ──
|
||||
//
|
||||
// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、
|
||||
// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。
|
||||
// 分区上限兜住单分区体量,总上限兜住整段体量。纪律类分区全文保留(完整性优先),
|
||||
// 其余分区收敛为单行目录后体量可忽略;总闸 3000 为实际生效的预算闸,分区上限是
|
||||
// 叠加前的第一道结构兜底。
|
||||
/// 规范纪律分区上限
|
||||
const DISCIPLINE_MAX_CHARS: usize = 4000;
|
||||
/// 领单分区上限
|
||||
const CLAIM_MAX_CHARS: usize = 2000;
|
||||
/// 目录行首行摘要上限
|
||||
const INDEX_SUMMARY_MAX_CHARS: usize = 100;
|
||||
/// CLAUDE.md 并入段上限
|
||||
const CLAUDE_MD_MAX_CHARS: usize = 4000;
|
||||
/// 投影段总上限(各分区叠加后的二次硬闸)
|
||||
const SECTION_MAX_CHARS: usize = 6000;
|
||||
const SECTION_MAX_CHARS: usize = 3000;
|
||||
/// 目录块尾部总引导(≤60 字符):提示被收敛分区的全文获取方式
|
||||
const INDEX_TAIL_HINT: &str = "以上收敛分区全文用 read_file 读 devflow.md 原文件获取";
|
||||
|
||||
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
|
||||
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
|
||||
@@ -61,9 +65,9 @@ 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] = ["纪律", "规范"];
|
||||
/// 纪律类分区识别的标题关键字(命中 → 全文保留,禁收敛;其余分区收敛为目录行)。
|
||||
/// contains 直判不引正则;中文关键字无大小写,ASCII 侧统一小写后比对(大小写不敏感)。
|
||||
const DISCIPLINE_HEADING_KEYS: [&str; 4] = ["纪律", "规范", "必读", "铁律"];
|
||||
|
||||
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
@@ -116,6 +120,10 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Ar
|
||||
}
|
||||
}
|
||||
let dir = work_dir.map(PathBuf::from);
|
||||
// devflow.md 绝对路径:目录行 read_file 提示的定位锚(先取串,dir 随后移入闭包读盘)
|
||||
let devflow_path = dir
|
||||
.as_ref()
|
||||
.map(|d| d.join("devflow.md").to_string_lossy().replace('\\', "/"));
|
||||
let conv = conv_id.to_string();
|
||||
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
|
||||
let (files, waymark) = tokio::join!(
|
||||
@@ -128,11 +136,12 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Ar
|
||||
}
|
||||
);
|
||||
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 parts = devflow.as_deref().map(extract_partitions).unwrap_or_default();
|
||||
let section = combine_sections(
|
||||
build_section(&parts, claude.as_deref(), devflow_path.as_deref()),
|
||||
waymark,
|
||||
);
|
||||
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if cache.len() >= SECTION_CACHE_MAX {
|
||||
cache.clear();
|
||||
@@ -209,16 +218,28 @@ fn read_trimmed(path: &Path) -> Option<String> {
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
|
||||
/// 拼装规则文件投影段(纯函数):分区分类(纪律全文 / 其余目录行)、上限截断、
|
||||
/// 优先级注明都在这里定形。CLAUDE.md 侧恒全文并入(目录式只作用于 devflow.md 侧)。
|
||||
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
|
||||
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
|
||||
fn build_section(parts: &[Partition], claude: Option<&str>, devflow_path: 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() {
|
||||
// 分类:标题空(扁平回退)或命中纪律关键字 → 全文保留;其余收敛为目录行
|
||||
let mut discipline: Vec<&str> = Vec::new();
|
||||
let mut index: Vec<String> = Vec::new();
|
||||
for p in parts {
|
||||
if p.title.is_empty() || is_discipline_heading(&p.title) {
|
||||
discipline.push(p.body.as_str());
|
||||
} else {
|
||||
index.push(render_index_line(p, devflow_path));
|
||||
}
|
||||
}
|
||||
let discipline = discipline.join("\n");
|
||||
if discipline.is_empty() && index.is_empty() && claude.is_none() {
|
||||
return None;
|
||||
}
|
||||
let has_devflow = !discipline.is_empty() || !claiming.is_empty();
|
||||
let has_devflow = !discipline.is_empty() || !index.is_empty();
|
||||
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
|
||||
// 优先级只在双文件并存时注明,单文件无冲突可注明
|
||||
// 优先级只在双文件并存时注明,单文件无冲突不注明
|
||||
if has_devflow && claude.is_some() {
|
||||
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
|
||||
}
|
||||
@@ -226,18 +247,22 @@ fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Opti
|
||||
|
||||
if !discipline.is_empty() {
|
||||
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
|
||||
sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS));
|
||||
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 !index.is_empty() {
|
||||
sec.push_str("\n\n### 其他分区目录(devflow.md)\n");
|
||||
for line in &index {
|
||||
sec.push_str(line);
|
||||
sec.push('\n');
|
||||
}
|
||||
sec.push_str(INDEX_TAIL_HINT);
|
||||
}
|
||||
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));
|
||||
@@ -245,6 +270,22 @@ fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Opti
|
||||
Some(sec)
|
||||
}
|
||||
|
||||
/// 目录行(单行收敛):`## 标题` + 首行摘要(≤100 字符) + read_file 按需全文提示。
|
||||
/// 标题不截断——它是 read_file 定位原文件标题的锚点,截断会破坏按标题检索。
|
||||
fn render_index_line(p: &Partition, devflow_path: Option<&str>) -> String {
|
||||
let first = p.body.lines().next().unwrap_or("").trim();
|
||||
let count = first.chars().count();
|
||||
let mut summary: String = first.chars().take(INDEX_SUMMARY_MAX_CHARS).collect();
|
||||
if count > INDEX_SUMMARY_MAX_CHARS {
|
||||
summary.push('…');
|
||||
}
|
||||
let mut line = format!("## {} | {summary}", p.title);
|
||||
if let Some(path) = devflow_path {
|
||||
line.push_str(&format!(" (全文按需: read_file {path} 定位「{}」)", p.title));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
|
||||
fn cap_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
@@ -256,15 +297,17 @@ fn cap_chars(s: &str, max: usize) -> String {
|
||||
|
||||
// ── devflow.md 分区解析 ──
|
||||
//
|
||||
// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的
|
||||
// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类
|
||||
// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按
|
||||
// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
|
||||
// 目录式投影:每个 markdown 标题块取为一个分区(块内更深层子标题视为块内子结构
|
||||
// 并入正文)。分类 = 标题含纪律关键字(纪律/规范/必读/铁律)→ 全文保留(完整性
|
||||
// 优先,agent 行为底线禁收敛);其余分区收敛为「标题 + 首行摘要 + read_file 定位」
|
||||
// 单行目录。更深层纪律关键字标题在非纪律容器内强制开新块(防容器吞并);首个
|
||||
// 命中标题前的前言不属任何分区,裁掉;全文无标题(扁平写法)回退为整文件按规范纪律
|
||||
// 处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Partition {
|
||||
Discipline,
|
||||
Claim,
|
||||
/// devflow.md 单个分区:标题 + 正文(trim 后,不含起始标题行;标题空 = 扁平回退块)
|
||||
struct Partition {
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
|
||||
@@ -281,30 +324,32 @@ fn heading_of(line: &str) -> Option<(usize, &str)> {
|
||||
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
|
||||
/// 标题是否命中纪律关键字(命中 → 全文保留,禁收敛)。
|
||||
fn is_discipline_heading(text: &str) -> bool {
|
||||
let lower = text.to_lowercase();
|
||||
DISCIPLINE_HEADING_KEYS.iter().any(|k| lower.contains(k))
|
||||
}
|
||||
|
||||
/// 从 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;
|
||||
/// 从 devflow.md 全文提取分区列表(纯函数,未截断):每个标题开启一个分区,
|
||||
/// 块内更深层子标题并入正文;空白正文的标题块跳过。
|
||||
fn extract_partitions(content: &str) -> Vec<Partition> {
|
||||
let mut parts: Vec<Partition> = Vec::new();
|
||||
// 当前块:(起始标题级别, 标题, 正文);正文不含起始标题行(投影段会重加小标题)
|
||||
let mut cur: Option<(usize, String, 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);
|
||||
// 块边界:块内遇到不深于起始层级的标题收块;更深层纪律关键字标题仅在
|
||||
// 非纪律容器块内强制开新块(防浅层容器标题吞并后续纪律分区),纪律块内
|
||||
// 的更深层纪律子标题(如「### 提交规范」)仍属块内子结构,连行保留
|
||||
let boundary = matches!(
|
||||
(&cur, &heading),
|
||||
(Some((lv, cur_title, _)), Some((h_lv, text)))
|
||||
if *h_lv <= *lv
|
||||
|| (h_lv > lv && is_discipline_heading(text) && !is_discipline_heading(cur_title))
|
||||
);
|
||||
if boundary {
|
||||
let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在");
|
||||
push_block(kind, body, &mut discipline, &mut claiming);
|
||||
push_block(cur.take(), &mut parts);
|
||||
} else if let Some((_, _, body)) = cur.as_mut() {
|
||||
// 更深层级标题属块内子结构,连行保留
|
||||
body.push_str(line);
|
||||
@@ -312,31 +357,29 @@ fn extract_partitions(content: &str) -> (String, String) {
|
||||
continue;
|
||||
}
|
||||
if let Some((lv, text)) = heading {
|
||||
if let Some(kind) = classify_heading(text) {
|
||||
cur = Some((kind, lv, String::new()));
|
||||
}
|
||||
cur = Some((lv, text.to_string(), String::new()));
|
||||
}
|
||||
}
|
||||
if let Some((kind, _, body)) = cur.take() {
|
||||
push_block(kind, body, &mut discipline, &mut claiming);
|
||||
}
|
||||
push_block(cur.take(), &mut parts);
|
||||
|
||||
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());
|
||||
if parts.is_empty() {
|
||||
// 扁平回退:全文无任何标题 → 整文件按规范纪律(标题空串,渲染时归纪律区)
|
||||
let flat = content.trim();
|
||||
if !flat.is_empty() {
|
||||
return vec![Partition { title: String::new(), body: flat.to_string() }];
|
||||
}
|
||||
}
|
||||
(dev, claim)
|
||||
parts
|
||||
}
|
||||
|
||||
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()),
|
||||
/// 收块入分区列表(纯函数):空白正文跳过(渲染不出可读内容的块不入段)。
|
||||
fn push_block(block: Option<(usize, String, String)>, parts: &mut Vec<Partition>) {
|
||||
if let Some((_, title, body)) = block {
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
parts.push(Partition { title, body: trimmed.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,41 +430,46 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ──
|
||||
// ── 双文件并存 → 优先级正确(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, "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 dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须全文注入");
|
||||
let idx_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!(dev_pos < idx_pos && idx_pos < claude_pos, "顺序:纪律全文 → 目录行 → CLAUDE.md");
|
||||
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
|
||||
assert!(out.contains("### 规范纪律(devflow.md)"));
|
||||
assert!(out.contains("### 领单纪律(devflow.md)"));
|
||||
assert!(out.contains("### 其他分区目录(devflow.md)"));
|
||||
assert!(out.contains("read_file"), "目录行须带 read_file 定位提示");
|
||||
assert!(out.contains("### 项目规范(CLAUDE.md)"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── 超长裁剪:分区上限 + 总上限生效并注明 ──
|
||||
// ── 超长裁剪:总闸 3000 收口生效;分区上限与摘要上限机制仍在 ──
|
||||
#[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);
|
||||
// 总闸:纪律 4000 压线 + CLAUDE.md 超量,叠加必超 3000 总闸
|
||||
let discipline = Partition { title: "规范纪律".to_string(), body: "a".repeat(DISCIPLINE_MAX_CHARS) };
|
||||
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
|
||||
let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段");
|
||||
let section = build_section(&[discipline], Some(&claude), None).expect("非空输入必有段");
|
||||
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
|
||||
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
|
||||
|
||||
// 分区上限机制(cap_chars 纯函数):超限截断并注明
|
||||
let capped = cap_chars(&"a".repeat(50), 10);
|
||||
assert!(capped.contains("超上限已截断"), "分区超限须注明");
|
||||
|
||||
// 目录行摘要上限:只取首行,超 100 字符截断注省略号
|
||||
let body = format!("- {}\n- 第二行不进摘要", "词".repeat(101));
|
||||
let line = render_index_line(&Partition { title: "领单".to_string(), body }, None);
|
||||
assert!(line.contains('…'), "摘要超限须截断注省略号");
|
||||
assert!(!line.contains("第二行"), "目录行只取首行摘要");
|
||||
let summary = line.split(" | ").nth(1).unwrap_or("");
|
||||
assert!(summary.chars().count() <= INDEX_SUMMARY_MAX_CHARS + 1, "摘要 ≤100 字符(+省略号)");
|
||||
}
|
||||
|
||||
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
|
||||
@@ -454,14 +502,12 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ──
|
||||
// ── 分区解析:全标题块入分区 / 子标题保留 / 纪律恒开新块 / 扁平回退 ──
|
||||
#[test]
|
||||
fn partition_extraction() {
|
||||
let content = "\
|
||||
# 项目认知
|
||||
|
||||
前言段落应被裁掉。
|
||||
|
||||
## 规范纪律
|
||||
- 纪律条目一
|
||||
|
||||
@@ -469,26 +515,41 @@ mod tests {
|
||||
- 子标题下的纪律条目
|
||||
|
||||
## 看板摘要
|
||||
- 未命中关键字的块应被裁掉
|
||||
- 看板条目应收敛为目录行
|
||||
|
||||
## 领单纪律
|
||||
## 领单
|
||||
- 领单条目一
|
||||
";
|
||||
let (dev, claim) = extract_partitions(content);
|
||||
assert!(dev.contains("纪律条目一"), "规范纪律块须命中");
|
||||
assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留");
|
||||
assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉");
|
||||
assert_eq!(claim, "- 领单条目一", "领单分区须独立提取");
|
||||
let parts = extract_partitions(content);
|
||||
assert_eq!(parts.len(), 3, "空白正文标题块跳过,三个实体分区入场");
|
||||
assert_eq!(parts[0].title, "规范纪律");
|
||||
assert!(parts[0].body.contains("纪律条目一"), "纪律分区正文命中");
|
||||
assert!(parts[0].body.contains("子标题下的纪律条目"), "块内子标题结构须保留");
|
||||
assert!(!parts[0].body.contains("看板条目"), "后续分区不得并入纪律正文");
|
||||
assert!(is_discipline_heading(&parts[0].title), "纪律关键字命中 → 全文类");
|
||||
assert_eq!((parts[1].title.as_str(), parts[2].title.as_str()), ("看板摘要", "领单"));
|
||||
assert!(
|
||||
!is_discipline_heading("领单") && !is_discipline_heading("看板摘要"),
|
||||
"非关键字分区 → 目录类"
|
||||
);
|
||||
|
||||
// 扁平文件(无命中标题)回退:整文件按规范纪律
|
||||
let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
|
||||
assert_eq!(dev, "- 扁平规则一\n- 扁平规则二");
|
||||
assert!(claim.is_empty());
|
||||
// 扁平文件(无任何标题)回退:整文件按规范纪律(标题空串)
|
||||
let parts = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert!(parts[0].title.is_empty());
|
||||
assert_eq!(parts[0].body, "- 扁平规则一\n- 扁平规则二");
|
||||
|
||||
// 深层标题起始的块:同级才截断
|
||||
let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
|
||||
assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断");
|
||||
assert!(!dev.contains("其他块内容"));
|
||||
let parts = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
|
||||
assert_eq!(parts.len(), 2, "深层起始块到同级标题截断,各自成块");
|
||||
assert_eq!((parts[0].title.as_str(), parts[1].title.as_str()), ("领单", "其他"));
|
||||
|
||||
// 纪律块防吞并:浅层非纪律容器标题不得吞并后续纪律分区(全文底线);
|
||||
// 纪律块内的更深纪律子标题仍属子结构(见首例「### 提交规范」)
|
||||
let parts = extract_partitions("# 总标题\n前言一句\n\n## 规范纪律\n- 纪律正文\n");
|
||||
assert_eq!(parts.len(), 2, "容器块与纪律块各自独立");
|
||||
assert_eq!((parts[0].title.as_str(), parts[1].title.as_str()), ("总标题", "规范纪律"));
|
||||
assert_eq!(parts[1].body, "- 纪律正文", "纪律正文不得被容器吞并");
|
||||
}
|
||||
|
||||
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
|
||||
@@ -502,15 +563,34 @@ mod tests {
|
||||
assert_eq!(work_dir_of(&empty), None, "无授权目录返 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("冲突以"), "单文件不得注明双文件优先级");
|
||||
assert!(build_section(&[], None, None).is_none(), "三路全空须 None");
|
||||
assert!(build_section(&[], Some(" "), None).is_none(), "空白 CLAUDE.md 视同缺失");
|
||||
let only_idx = build_section(
|
||||
&[Partition { title: "领单".to_string(), body: "- 领单规则".to_string() }],
|
||||
None,
|
||||
Some("E:/p/devflow.md"),
|
||||
)
|
||||
.expect("目录分区非空须有段");
|
||||
assert!(only_idx.contains("### 其他分区目录(devflow.md)"));
|
||||
assert!(
|
||||
only_idx.contains("## 领单 | - 领单规则 (全文按需: read_file E:/p/devflow.md 定位「领单」)"),
|
||||
"目录行形态:标题 + 首行摘要 + read_file 定位"
|
||||
);
|
||||
assert!(only_idx.ends_with(INDEX_TAIL_HINT), "收敛在场须带尾部总引导");
|
||||
assert!(!only_idx.contains("### 规范纪律"), "空纪律区不得产生空小节");
|
||||
assert!(!only_idx.contains("冲突以"), "单文件不得注明双文件优先级");
|
||||
|
||||
let disc = build_section(
|
||||
&[Partition { title: "自主修复纪律".to_string(), body: "- 明确 bug 直接修".to_string() }],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("纪律分区非空须有段");
|
||||
assert!(disc.contains("### 规范纪律(devflow.md)\n- 明确 bug 直接修"), "纪律正文全文直入");
|
||||
assert!(!disc.contains(INDEX_TAIL_HINT), "无收敛分区时不得出现总引导");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -385,7 +385,7 @@ mod tests {
|
||||
let events = vec![
|
||||
serde_json::json!({"type": "approval_required", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "compressed", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "dir_auth_required", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "max_rounds", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "context_cleared", "conversation_id": "c1"}),
|
||||
];
|
||||
for event in &events {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub mod fetch_search;
|
||||
pub mod download_file;
|
||||
pub(crate) mod generate_image;
|
||||
pub mod get_app_config;
|
||||
pub mod knowledge_distill;
|
||||
pub mod knowledge_inject;
|
||||
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
|
||||
pub mod lease;
|
||||
@@ -178,7 +179,11 @@ pub enum AiChatEvent {
|
||||
/// 需要人工审批
|
||||
/// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff),
|
||||
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
|
||||
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String> },
|
||||
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String>,
|
||||
/// 后端超时收敛到期时刻(ms Unix 时间戳)。前端审批倒计时单一真值(前端已无本地自动拒绝定时器,2026-10-25)。
|
||||
/// None = 超时被禁用(approval_timeout_minutes=0),前端不显倒计时。
|
||||
#[serde(default)]
|
||||
expires_at_millis: Option<i64> },
|
||||
/// 审批结果
|
||||
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
|
||||
/// AI 响应完成
|
||||
@@ -288,19 +293,6 @@ pub enum AiChatEvent {
|
||||
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
|
||||
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
|
||||
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
|
||||
/// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。
|
||||
///
|
||||
/// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。
|
||||
/// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并),
|
||||
/// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。
|
||||
/// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。
|
||||
AiDirAuthRequired {
|
||||
id: String,
|
||||
tool: String,
|
||||
path: String,
|
||||
dir: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
|
||||
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
|
||||
AiContextCleared { conversation_id: Option<String> },
|
||||
@@ -698,16 +690,11 @@ pub struct AiSession {
|
||||
pub active_conversation_id: Option<String>,
|
||||
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
|
||||
pub active_conv_created_at: Option<String>,
|
||||
/// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)。
|
||||
/// 挂起的审批(单真相源单表,键=tool_call_id,`conversation_id` 是业务语义)。
|
||||
///
|
||||
/// 决策1(状态层合 + 决策层分):状态层把两类挂起合并进单 HashMap,
|
||||
/// `PendingApproval.kind` 字段区分语义(`ApprovalKind::Path` vs `ApprovalKind::Risk`);
|
||||
/// 决策层仍双轨 —— `ai_authorize_dir` 只消费 `kind==Path`(once/always 写持久白名单),
|
||||
/// `ai_approve` 只消费 `kind==Risk`(一次性 approve/reject),入口 kind 校验防误调。
|
||||
/// 键为 `tool_call_id`(路由键不变),`conversation_id` 是业务语义(哪个对话产生)。
|
||||
///
|
||||
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
|
||||
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
|
||||
/// path_auth 审批链已退役(2026-10-25):原 Path/Risk 双 kind 合并设计回退为单 Risk kind,
|
||||
/// `ai_approve` 是唯一决策入口。`ApprovalKind::Path` 变体已删,DB pending_approvals JSON 列
|
||||
/// 残留的 `kind:Path` 老行反序列化失败即整体跳过(切会话侧 warn 非阻断,等价挂起丢弃)。
|
||||
pub pending_approvals: HashMap<String, PendingApproval>,
|
||||
/// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经
|
||||
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。
|
||||
@@ -1314,10 +1301,8 @@ impl Default for PerConvState {
|
||||
|
||||
/// 待审批的工具调用
|
||||
///
|
||||
/// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk),
|
||||
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
|
||||
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
|
||||
/// 各只消费自己 kind,入口校验防误调)。
|
||||
/// `kind: ApprovalKind` 保留字段壳:原 Path/Risk 双 kind 设计随 path_auth 退役
|
||||
/// (2026-10-25)回退为单 Risk kind,字段保留作 serde 兼容与未来扩展。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingApproval {
|
||||
pub tool_call_id: String,
|
||||
@@ -1330,8 +1315,7 @@ pub struct PendingApproval {
|
||||
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
|
||||
#[serde(default)]
|
||||
pub created_at: Option<std::time::SystemTime>,
|
||||
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。
|
||||
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。
|
||||
/// 审批类型。path_auth 退役后仅剩 `ApprovalKind::Risk` 单变体(Path 变体已删)。
|
||||
pub kind: ApprovalKind,
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。
|
||||
///
|
||||
@@ -1349,23 +1333,18 @@ pub struct PendingApproval {
|
||||
pub retry_count: u32,
|
||||
}
|
||||
|
||||
/// 阶段3a:审批类型枚举(决策层分离,kind 区分 ai_approve vs ai_authorize_dir 消费)。
|
||||
///
|
||||
/// - `Path(req)`:F-260619-03 Phase B 路径授权挂起,由 `ai_authorize_dir` 消费(once/always/deny)。
|
||||
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
|
||||
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
|
||||
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
|
||||
/// 审批类型枚举。path_auth 退役(2026-10-25)后仅剩 Risk 单变体,
|
||||
/// 原 `Path(PathAuthRequest)` 变体已删(路径授权挂起链整体退役)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ApprovalKind {
|
||||
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
|
||||
Path(PathAuthRequest),
|
||||
/// 普通 RiskLevel 审批(Med/High 风险工具)。
|
||||
/// 普通 RiskLevel 审批(Med/High 风险工具),由 `ai_approve` 消费(一次性 approve/reject)。
|
||||
/// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。
|
||||
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·)·cargo Rust never-read 误报
|
||||
Risk { diff: Option<String> },
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
|
||||
/// 路径授权挂起请求载荷。【已退役】ApprovalKind::Path 变体已删,本结构仅作
|
||||
/// path_auth.rs 壳(恢复点)的 NeedsAuth 载荷类型保留,运行时无消费者。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathAuthRequest {
|
||||
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
|
||||
|
||||
@@ -127,7 +127,9 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
||||
- {}\n\
|
||||
- {}\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\
|
||||
- Never estimate or mention work-hours/duration/man-days — this is an AI-coding product where human + AI ship together, so traditional effort estimates are meaningless; express workload as change surface (files/lines/batches) instead.\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\
|
||||
@@ -137,6 +139,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
||||
duplicate_call_guidance(lang),
|
||||
file_reading_guidance(lang),
|
||||
verification_fast_check_guidance(lang),
|
||||
history_search_guidance(lang),
|
||||
),
|
||||
"\n## Current Projects\n",
|
||||
"\n## Current Tasks\n",
|
||||
@@ -161,7 +164,9 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
||||
- {}\n\
|
||||
- {}\n\
|
||||
- {}\n\
|
||||
- {}\n\
|
||||
- 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\
|
||||
- 禁止预估或提及工时/耗时/人天——本产品是 AI Coding 场景,人与 AI 协作完成开发,传统工时估算无意义;需要表达工作量时用改动面(涉及文件数/代码行数/拆几批)描述。\n\
|
||||
## 聚焦准则\n\
|
||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||
@@ -171,6 +176,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
||||
duplicate_call_guidance(lang),
|
||||
file_reading_guidance(lang),
|
||||
verification_fast_check_guidance(lang),
|
||||
history_search_guidance(lang),
|
||||
),
|
||||
"\n## 当前项目\n",
|
||||
"\n## 当前任务\n",
|
||||
@@ -197,6 +203,10 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
||||
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
|
||||
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
|
||||
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
|
||||
///
|
||||
/// **渐进披露(D1)后角色变更**:本函数 = 完整段,不再无条件常驻 system prompt——
|
||||
/// 常驻改拼路标短段([`doc_strategy_signpost_section`]),完整段仅当
|
||||
/// [`doc_strategy_needed`] 命中时按需附到易变侧(全文 ~12K 字符按需才进上下文)。
|
||||
fn doc_exploration_strategy_section(lang: &str) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
@@ -205,6 +215,44 @@ fn doc_exploration_strategy_section(lang: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// 判定本轮 user 文本是否需要附带完整文档探索策略(渐进披露门,纯函数无 IO)。
|
||||
///
|
||||
/// 命中任一即附带:含 URL(http:// / https:// / www.)或文档关键词
|
||||
/// (「文档」「docs」「documentation」)。ASCII 一律不分大小写(抹平 Docs/DOCS/WWW.
|
||||
/// 等书写差异);「官方文档」不必单查——含「官方文档」必含「文档」,单查子串即等价覆盖。
|
||||
/// 放近使用处(prompt.rs 组装链),不抽 trait 不过度抽象。
|
||||
pub(crate) fn doc_strategy_needed(user_text: &str) -> bool {
|
||||
let lower = user_text.to_ascii_lowercase();
|
||||
lower.contains("http://")
|
||||
|| lower.contains("https://")
|
||||
|| lower.contains("www.")
|
||||
|| user_text.contains("文档")
|
||||
|| lower.contains("docs")
|
||||
|| lower.contains("documentation")
|
||||
}
|
||||
|
||||
/// 文档探索策略路标段(中/英,常驻 system prompt 的收敛短段,≤1200 字符)。
|
||||
///
|
||||
/// 渐进披露(D1):原完整段 ~12K 字符逐轮常驻烧上下文,而多数轮次并不涉及文档任务。
|
||||
/// 常驻只留路标——触发场景(URL/文档站/外部文档类问题)+ 三个工具名(fetch_url /
|
||||
/// fetch_search / obscura)+「完整策略按需自动附带」一句引导,让 LLM 知道策略存在、
|
||||
/// 用对工具;完整段([`doc_exploration_strategy_section`])由组装链按
|
||||
/// [`doc_strategy_needed`] 命中附到易变侧。策略正文零改动,只动拼接结构。
|
||||
fn doc_strategy_signpost_section(lang: &str) -> &'static str {
|
||||
match lang {
|
||||
"en" => "\n\
|
||||
## Documentation Exploration Strategy (signpost)\n\
|
||||
When the user message contains a URL, a docs site link, or an external-docs/integration question, you are in a documentation exploration scenario: pick the right URL tool first, never blind-fire requests at raw URLs.\n\
|
||||
Tool entries: `fetch_url` reads a URL into markdown (`render=true` goes through the obscura JS renderer); `fetch_search` queries a search engine for unknown URLs; obscura is the JS-render backend — when not installed, `fetch_url` silently falls back to static and returns an install `hint` field.\n\
|
||||
For documentation tasks, follow the full documentation exploration strategy (attached on demand): URL tool division rules, sniff → identify → extract → verify → apply flow, never fabricate URLs, switch tactics after 2 `render=true` failures.\n\n",
|
||||
_ => "\n\
|
||||
## 文档探索策略(路标)\n\
|
||||
用户消息含 URL、文档站链接或外部文档/接入类问题时,即进入文档探索场景:先选对 URL 工具再动手,严禁对原始 URL 盲发请求。\n\
|
||||
工具入口:`fetch_url` 读 URL 转 markdown(`render=true` 走 obscura JS 渲染);`fetch_search` 未知 URL 先搜索引擎拿候选;obscura 是 JS 渲染后端——未安装时 `fetch_url` 静态回退并返回安装引导 `hint` 字段。\n\
|
||||
遇到文档类任务,遵循完整文档探索策略(按需自动附带):URL 三工具分工铁律、嗅探→识别→提取→验证→应用流程、严禁臆造 URL、`render=true` 失败 2 次必换方案。\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
|
||||
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
|
||||
fn read_symbol_guidance(lang: &str) -> &'static str {
|
||||
@@ -242,9 +290,21 @@ fn verification_fast_check_guidance(lang: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 跨会话历史检索指引(中/英,不含行首 "- ")。P1-A(u-claw #6 等价):教 AI 跨会话
|
||||
/// 查「上次/之前怎么弄的」用 history_search,严禁 run_command 查 sqlite 代替(查配置
|
||||
/// 绕行的同款失败模式,先例 app_config_query_guidance_section 段式:正向指令+禁令+替代路径)。
|
||||
/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。
|
||||
fn history_search_guidance(lang: &str) -> &'static str {
|
||||
match lang {
|
||||
"en" => "**Cross-session history search**: when the user refers to a previous task, conclusion, or approach (\"last time\", \"earlier\", \"history\"), use history_search to search past conversations (auto keyword extraction; the first hop returns one snippet per conversation, pass conversation_id for a deep dive; omit query to list recent conversations). Never use run_command against the sqlite database file as a substitute; compressed early history of a hit conversation can be read back via read_file from the archive path (app data dir, context-archive/<conversation_id>-archive.jsonl)",
|
||||
_ => "**跨会话历史检索**:用户提到「上次/之前/历史」的任务、结论或做法时,用 history_search 检索历史会话(自动分词;首跳返回每会话一条命中片段,深读再传 conversation_id;query 省略时列最近会话概览)。严禁用 run_command 查 sqlite 数据库文件代替本工具;命中会话被压缩的早期原文可按存档路径(应用数据目录 context-archive/<conversation_id>-archive.jsonl)用 read_file 回读",
|
||||
}
|
||||
}
|
||||
|
||||
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
|
||||
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
|
||||
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
|
||||
/// 2 次必换方案 / 查配置用 get_app_config / 跨会话查历史用 history_search。
|
||||
/// 子代理白名单缺省下发全量 Low 工具,
|
||||
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
|
||||
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
|
||||
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
|
||||
@@ -255,11 +315,13 @@ pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
|
||||
- {}\n\
|
||||
- {}\n\
|
||||
- {}\n\
|
||||
- {}\n\
|
||||
{}{}",
|
||||
read_symbol_guidance(lang),
|
||||
duplicate_call_guidance(lang),
|
||||
file_reading_guidance(lang),
|
||||
verification_fast_check_guidance(lang),
|
||||
history_search_guidance(lang),
|
||||
doc_strategy_core_section(lang),
|
||||
app_config_query_guidance_section(lang),
|
||||
)
|
||||
@@ -452,11 +514,14 @@ async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<Str
|
||||
/// 与「当前项目」清单的 list_active_with_activity 排序输入对齐)——任务更新/归档变化均
|
||||
/// 反映到指纹,清单排序变了缓存串必失效。
|
||||
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
|
||||
/// - 文档策略全文门(D1):完整段按 user 文本条件附带,命中与否改变 system 内容,
|
||||
/// 不入指纹则同指纹下「无全文的缓存串」会被文档类轮次错误复用(陈旧命中)。
|
||||
async fn system_prompt_fingerprint(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
excl_proj: &[String],
|
||||
excl_task: &[String],
|
||||
doc_full: bool,
|
||||
) -> Option<String> {
|
||||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
let custom = state
|
||||
@@ -489,6 +554,7 @@ async fn system_prompt_fingerprint(
|
||||
key.push_str("|t:");
|
||||
key.push_str(id);
|
||||
}
|
||||
key.push_str(if doc_full { "|d:1" } else { "|d:0" });
|
||||
Some(key)
|
||||
}
|
||||
|
||||
@@ -511,7 +577,8 @@ async fn system_prompt_fingerprint(
|
||||
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
||||
build_system_prompt_with_excluded(state, lang, &[], &[]).await
|
||||
// 无 user 文本上下文的预留 API:门判定退化为不命中(仅路标常驻,全文不附)。
|
||||
build_system_prompt_with_excluded(state, lang, &[], &[], "").await
|
||||
}
|
||||
|
||||
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
|
||||
@@ -527,14 +594,21 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
|
||||
///
|
||||
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
|
||||
/// - 注入克制策略不变:仍各 take(20)。
|
||||
///
|
||||
/// **渐进披露(D1)**:`user_text` 为本轮用户文本,过 [`doc_strategy_needed`] 门——
|
||||
/// 常驻只拼路标短段,命中时完整段紧随其后(本函数 = 缓存关单串形态,无 stable/volatile
|
||||
/// 拆分,全文随串整体逐轮重建,无缓存污染问题;BE3 会话内缓存指纹已纳入命中位)。
|
||||
/// 传空串(调用方无 user 上下文)等价于不命中:仅路标,全文不附。
|
||||
pub(crate) async fn build_system_prompt_with_excluded(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
excluded_project_ids: &[String],
|
||||
excluded_task_ids: &[String],
|
||||
user_text: &str,
|
||||
) -> String {
|
||||
let doc_full = doc_strategy_needed(user_text);
|
||||
// BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
|
||||
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
|
||||
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids, doc_full).await;
|
||||
if let Some(key) = &fp_key {
|
||||
let cache = system_prompt_cache();
|
||||
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
|
||||
@@ -544,8 +618,11 @@ pub(crate) async fn build_system_prompt_with_excluded(
|
||||
let (prefix, _, _) = system_prompt_parts(lang);
|
||||
let mut prompt = env_profile_line();
|
||||
prompt.push_str(&prefix);
|
||||
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
|
||||
prompt.push_str(&doc_exploration_strategy_section(lang));
|
||||
// 文档探索策略段(渐进披露 D1):路标常驻;命中门时完整段紧随(治盲目 http_request 瞎试)
|
||||
prompt.push_str(doc_strategy_signpost_section(lang));
|
||||
if doc_full {
|
||||
prompt.push_str(&doc_exploration_strategy_section(lang));
|
||||
}
|
||||
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
|
||||
prompt.push_str(app_config_query_guidance_section(lang));
|
||||
|
||||
@@ -733,6 +810,46 @@ pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool {
|
||||
!(v == "false" || v == "0")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// P0-B 压缩水位判据配置(KV 双键,对齐 audit/bg_run.rs 惯例:bool 开关+数值参数,默认关)
|
||||
// ============================================================
|
||||
|
||||
/// 水位判据开关(`df-ai-compress-watermark`,默认关):关=现行 0.6 体积判据逐字节不变;
|
||||
/// 开=替换为水位主判据(history+sys > ratio×budget),体积兜底=build_for_request 硬门。
|
||||
/// 判据消费点=context_lifecycle.rs::compress_should_trigger。
|
||||
const WATERMARK_SWITCH_KEY: &str = "df-ai-compress-watermark";
|
||||
/// 水位阈值(`df-ai-compress-watermark-ratio`,默认 "0.70")。
|
||||
const WATERMARK_RATIO_KEY: &str = "df-ai-compress-watermark-ratio";
|
||||
|
||||
/// 缺省水位阈值(u-claw 同款 70%)。
|
||||
const DEFAULT_WATERMARK_RATIO: f32 = 0.70;
|
||||
/// ratio 钳位下限:再低会显著早于现行 0.6 判据触发(过度压缩,与治偏频目标反向)。
|
||||
pub(crate) const MIN_WATERMARK_RATIO: f32 = 0.3;
|
||||
/// ratio 钳位上限:headroom 红线(分析 §4.3)——触发点+单轮增长须 < available,
|
||||
/// 0.95 保 ≥5% budget 余量。
|
||||
pub(crate) const MAX_WATERMARK_RATIO: f32 = 0.95;
|
||||
|
||||
/// 水位阈值解析:缺失/解析失败/非正数(含 0、负值、NaN/inf)归默认 0.70
|
||||
/// (对齐 bg_run.rs parse_grace「0 视为配置错误」);合法值钳位 [0.3, 0.95] 防误配。
|
||||
fn parse_watermark_ratio(raw: Option<String>) -> f32 {
|
||||
raw.and_then(|s| s.trim().parse::<f32>().ok())
|
||||
.filter(|r| r.is_finite() && *r > 0.0)
|
||||
.map_or(DEFAULT_WATERMARK_RATIO, |r| {
|
||||
r.clamp(MIN_WATERMARK_RATIO, MAX_WATERMARK_RATIO)
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取压缩水位判据配置(KV 双键一次读齐):`(开关, 阈值)`。
|
||||
/// 默认 `(false, 0.70)`=现行判据逐字节不变(灰度:默认关,狗粮自开 KV 观察后再拍翻转)。
|
||||
pub(crate) async fn compress_watermark_cfg(state: &AppState) -> (bool, f32) {
|
||||
let on = state
|
||||
.settings
|
||||
.get_bool_norm(WATERMARK_SWITCH_KEY, false)
|
||||
.await;
|
||||
let ratio = parse_watermark_ratio(state.settings.get_string_norm(WATERMARK_RATIO_KEY).await);
|
||||
(on, ratio)
|
||||
}
|
||||
|
||||
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
|
||||
///
|
||||
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
|
||||
@@ -762,32 +879,64 @@ pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool {
|
||||
///
|
||||
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
|
||||
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
|
||||
///
|
||||
/// **渐进披露(D1)**:本签名保留给**拿不到本轮 user 文本**的调用方(如 agentic 续跑
|
||||
/// agentic/helpers.rs,本任务禁改该文件)——门判定退化为不命中:路标常驻、完整段不附。
|
||||
/// 有 user 文本的调用方(chat.rs send/regenerate/edit/force_send)改走
|
||||
/// [`build_system_prompt_parts_with_user`]。
|
||||
pub(crate) async fn build_system_prompt_parts(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
excluded_project_ids: &[String],
|
||||
excluded_task_ids: &[String],
|
||||
cache_enabled: bool,
|
||||
) -> (String, String) {
|
||||
build_system_prompt_parts_with_user(state, lang, excluded_project_ids, excluded_task_ids, "", cache_enabled).await
|
||||
}
|
||||
|
||||
/// [`build_system_prompt_parts`] 的 user 感知版(渐进披露 D1 主入口)。
|
||||
///
|
||||
/// 差异仅在文档策略完整段的归属:`doc_strategy_needed(user_text)` 命中时完整段
|
||||
/// **附到易变段**(不进 system 稳定段)——全文随本轮用户消息而变,留稳定段会逐轮失效
|
||||
/// system 缓存;进易变尾段与知识/augmentation 同理(拼消息流末尾,分段符 `\n\n---\n`
|
||||
/// 对齐 chat.rs `append_volatile` 风格)。开关关单串路径委托
|
||||
/// [`build_system_prompt_with_excluded`](全文紧随路标,见其文档注释)。
|
||||
pub(crate) async fn build_system_prompt_parts_with_user(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
excluded_project_ids: &[String],
|
||||
excluded_task_ids: &[String],
|
||||
user_text: &str,
|
||||
cache_enabled: bool,
|
||||
) -> (String, String) {
|
||||
if !cache_enabled {
|
||||
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
|
||||
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids, user_text).await;
|
||||
return (full, String::new());
|
||||
}
|
||||
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
|
||||
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
|
||||
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索路标 + 查配置引导 + 自定义),
|
||||
// 易变段(日期 + 项目/任务清单 + 按需的文档策略全文)挪到消息流末尾,不失效 system 缓存。
|
||||
let (prefix, _, _) = system_prompt_parts(lang);
|
||||
let mut stable = env_profile_stable_body();
|
||||
stable.push_str(&prefix);
|
||||
stable.push_str(&doc_exploration_strategy_section(lang));
|
||||
stable.push_str(doc_strategy_signpost_section(lang));
|
||||
stable.push_str(app_config_query_guidance_section(lang));
|
||||
stable.push_str(&build_custom_section(state).await);
|
||||
|
||||
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
|
||||
let volatile = if lists.is_empty() {
|
||||
let mut volatile = if lists.is_empty() {
|
||||
env_profile_date_volatile()
|
||||
} else {
|
||||
format!("{}\n\n{}", env_profile_date_volatile(), lists)
|
||||
};
|
||||
// 文档策略全文按需附易变侧(D1):命中才带,非命中轮次省 ~12K 字符上下文。
|
||||
if doc_strategy_needed(user_text) {
|
||||
let full = doc_exploration_strategy_section(lang);
|
||||
volatile = if volatile.is_empty() {
|
||||
full
|
||||
} else {
|
||||
format!("{}\n\n---\n{}", volatile, full)
|
||||
};
|
||||
}
|
||||
(stable, volatile)
|
||||
}
|
||||
|
||||
@@ -894,6 +1043,39 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 渐进披露(D1)门判定:URL 三形态 + 文档关键词命中;纯闲聊不命中;ASCII 不分大小写
|
||||
#[test]
|
||||
fn doc_strategy_needed_gate() {
|
||||
assert!(doc_strategy_needed("帮我接入 https://docs.example.com/v1/api"));
|
||||
assert!(doc_strategy_needed("看下 http://example.com 这页写了啥"));
|
||||
assert!(doc_strategy_needed("抓取 WWW.EXAMPLE.COM 首页")); // www. 大写命中
|
||||
assert!(doc_strategy_needed("按照官方文档配置 provider")); // 「文档」子串覆盖「官方文档」
|
||||
assert!(doc_strategy_needed("read the DOCS before coding")); // DOCS 大写命中
|
||||
assert!(doc_strategy_needed("check the Documentation page"));
|
||||
assert!(!doc_strategy_needed("把任务标题改成部署清单"));
|
||||
assert!(!doc_strategy_needed("fix the failing unit test"));
|
||||
assert!(!doc_strategy_needed("")); // 空 user 文本(无上下文调用方)= 不命中
|
||||
}
|
||||
|
||||
// 渐进披露(D1)路标段规格:≤1200 字符 + 三工具名 + 「按需附带」引导句(中/英)
|
||||
#[test]
|
||||
fn doc_strategy_signpost_section_shape() {
|
||||
for lang in ["zh-CN", "en"] {
|
||||
let s = doc_strategy_signpost_section(lang);
|
||||
assert!(
|
||||
s.chars().count() <= 1200,
|
||||
"路标段超 1200 字符({}): {}",
|
||||
lang,
|
||||
s.chars().count()
|
||||
);
|
||||
assert!(s.contains("fetch_url"));
|
||||
assert!(s.contains("fetch_search"));
|
||||
assert!(s.contains("obscura"));
|
||||
}
|
||||
assert!(doc_strategy_signpost_section("zh-CN").contains("按需自动附带"));
|
||||
assert!(doc_strategy_signpost_section("en").contains("attached on demand"));
|
||||
}
|
||||
|
||||
// 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段
|
||||
#[test]
|
||||
fn system_prompt_zh_has_focus_section() {
|
||||
@@ -1118,6 +1300,35 @@ mod tests {
|
||||
assert!(en.contains("Do **NOT**"));
|
||||
}
|
||||
|
||||
// P1-A 跨会话历史检索指引:中/英双语在位(工具名/禁令/两跳用法/压缩原文回读路径),
|
||||
// 且父会话 prefix 与子代理工具纪律段同源(工具下发与纪律下发不脱钩)。
|
||||
#[test]
|
||||
fn history_search_guidance_present_zh_en_and_shared_with_sub_agent() {
|
||||
let zh = history_search_guidance("zh-CN");
|
||||
assert!(zh.contains("history_search"));
|
||||
assert!(zh.contains("跨会话历史检索"));
|
||||
assert!(zh.contains("严禁用 run_command 查 sqlite"), "禁令必须在位: {zh}");
|
||||
assert!(zh.contains("conversation_id"), "两跳用法必须在位: {zh}");
|
||||
assert!(zh.contains("context-archive/<conversation_id>-archive.jsonl"), "压缩原文回读路径必须在位: {zh}");
|
||||
|
||||
let en = history_search_guidance("en");
|
||||
assert!(en.contains("history_search"));
|
||||
assert!(en.contains("Cross-session history search"));
|
||||
assert!(en.contains("Never use run_command against the sqlite database file"));
|
||||
assert!(en.contains("conversation_id"));
|
||||
|
||||
// 父会话 prefix(中/英)与子代理工具纪律段同源拼接
|
||||
let (prefix_zh, _, _) = system_prompt_parts("zh-CN");
|
||||
assert!(prefix_zh.contains(history_search_guidance("zh-CN")), "父会话行为准则须含指引");
|
||||
let (prefix_en, _, _) = system_prompt_parts("en");
|
||||
assert!(prefix_en.contains(history_search_guidance("en")), "EN prefix must carry the guidance");
|
||||
let sub_zh = sub_agent_tool_discipline_section("zh-CN");
|
||||
assert!(
|
||||
sub_zh.contains(history_search_guidance("zh-CN")),
|
||||
"子代理工具纪律段须与父会话同源(工具下发了纪律必须同源下发)"
|
||||
);
|
||||
}
|
||||
|
||||
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
|
||||
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
|
||||
#[test]
|
||||
@@ -1199,4 +1410,30 @@ mod tests {
|
||||
assert_eq!(got120.chars().count(), 121);
|
||||
assert!(got120.ends_with('…'));
|
||||
}
|
||||
|
||||
// P0-B 水位阈值解析:缺失/非法/非正数归默认 0.70(对齐 parse_grace「0 视为配置错误」)
|
||||
#[test]
|
||||
fn watermark_ratio_defaults_on_missing_or_invalid() {
|
||||
assert_eq!(parse_watermark_ratio(None), 0.70);
|
||||
assert_eq!(parse_watermark_ratio(Some(String::new())), 0.70);
|
||||
assert_eq!(parse_watermark_ratio(Some("abc".to_string())), 0.70);
|
||||
assert_eq!(parse_watermark_ratio(Some("0".to_string())), 0.70, "0 视为配置错误归默认");
|
||||
assert_eq!(parse_watermark_ratio(Some("-0.5".to_string())), 0.70);
|
||||
assert_eq!(parse_watermark_ratio(Some("NaN".to_string())), 0.70);
|
||||
assert_eq!(parse_watermark_ratio(Some("inf".to_string())), 0.70);
|
||||
}
|
||||
|
||||
// P0-B 水位阈值钳位:合法值收进 [0.3, 0.95] 防误配,界内原样采纳
|
||||
#[test]
|
||||
fn watermark_ratio_clamps_into_safe_range() {
|
||||
assert_eq!(parse_watermark_ratio(Some("0.1".to_string())), 0.3, "过低钳到下限");
|
||||
assert_eq!(parse_watermark_ratio(Some("0.99".to_string())), 0.95, "过高钳到上限");
|
||||
assert_eq!(parse_watermark_ratio(Some("2".to_string())), 0.95);
|
||||
assert_eq!(parse_watermark_ratio(Some("0.5".to_string())), 0.5);
|
||||
assert_eq!(parse_watermark_ratio(Some("0.85".to_string())), 0.85);
|
||||
assert_eq!(parse_watermark_ratio(Some(" 0.8 ".to_string())), 0.8, "容忍首尾空白");
|
||||
// 常量绑定:钳位边界即红线边界(防止常量被误改无人知)
|
||||
assert_eq!(MIN_WATERMARK_RATIO, 0.3);
|
||||
assert_eq!(MAX_WATERMARK_RATIO, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! | `stop` | `ai_chat_stop` | conversation_id? |
|
||||
//! | `regenerate` | `ai_regenerate` | conversation_id, language?, model_override? |
|
||||
//! | `approve` | `ai_approve` | tool_call_id, approved |
|
||||
//! | `authorize_dir` | `ai_authorize_dir` | tool_call_id, decision |
|
||||
//! | `authorize_dir` | (已退役,path_auth 链下线) | — |
|
||||
//! | `continue_loop` | `ai_continue_loop` | conversation_id |
|
||||
//! | `stop_loop` | `ai_stop_loop` | conversation_id |
|
||||
//! | `switch_conversation` | (无,MVP 不处理) | — |
|
||||
@@ -63,7 +63,7 @@ use crate::state::AppState;
|
||||
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
|
||||
// 故 import 不含 Manager(避免 unused)。
|
||||
use crate::commands::ai::{
|
||||
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
|
||||
ai_approve, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
|
||||
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
|
||||
ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event,
|
||||
record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval,
|
||||
@@ -240,22 +240,7 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
|
||||
}
|
||||
}
|
||||
|
||||
// ── authorize_dir:ai_authorize_dir(tool_call_id, decision) ──
|
||||
"authorize_dir" => {
|
||||
match (
|
||||
args_get_string(&command.args, "tool_call_id"),
|
||||
args_get_string(&command.args, "decision"),
|
||||
) {
|
||||
(Some(tool_call_id), Some(decision)) => {
|
||||
let _ = ai_authorize_dir(app, state, tool_call_id, decision).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[remote_bridge] authorize_dir 缺 tool_call_id 或 decision 参数,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── authorize_dir:【已退役】path_auth 审批链下线(2026-10-25),命令静默忽略 ──
|
||||
|
||||
// ── continue_loop:ai_continue_loop(conversation_id) ──
|
||||
"continue_loop" => {
|
||||
@@ -344,7 +329,7 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
|
||||
}
|
||||
|
||||
// ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ──
|
||||
// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired,
|
||||
// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired,
|
||||
// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,
|
||||
// device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件,
|
||||
// miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。
|
||||
@@ -523,7 +508,7 @@ async fn route_load_messages(state: &State<'_, AppState>, conversation_id: Strin
|
||||
|
||||
/// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。
|
||||
///
|
||||
/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired 事件,
|
||||
/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired 事件,
|
||||
/// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读
|
||||
/// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent
|
||||
/// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。
|
||||
@@ -551,6 +536,9 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
|
||||
|
||||
for p in approvals {
|
||||
let conv_id = Some(conversation_id.clone());
|
||||
// 到期时刻透传(前端倒计时单一真值=后端);created_at 缺失/禁用超时 → None 不显倒计时。
|
||||
let expires_at_millis = p.created_at.and_then(|c| super::audit::approval_expires_at_millis(state.inner(), c));
|
||||
// path_auth 退役(2026-10-25):单 Risk kind,直接重推 AiApprovalRequired。
|
||||
let ev = match &p.kind {
|
||||
ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired {
|
||||
id: p.tool_call_id,
|
||||
@@ -559,17 +547,7 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
|
||||
reason: "等待审批(重连恢复)".to_string(),
|
||||
diff: diff.clone(),
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
ApprovalKind::Path(req) => AiChatEvent::AiDirAuthRequired {
|
||||
id: p.tool_call_id,
|
||||
tool: p.tool_name,
|
||||
path: req.raw_paths.first().cloned().unwrap_or_default(),
|
||||
dir: req
|
||||
.dirs
|
||||
.first()
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
conversation_id: conv_id,
|
||||
expires_at_millis,
|
||||
},
|
||||
};
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
@@ -1203,7 +1181,7 @@ fn args_get_parts(args: &Value, key: &str) -> Option<Vec<ContentPart>> {
|
||||
// 覆盖:
|
||||
// 1. MiniCommand 反序列化(标准结构 / args 缺省 / 非 Object payload 失败)
|
||||
// 2. match 各 cmd 臂的路由分支(send_message R1 拒绝 / stop / regenerate / approve /
|
||||
// authorize_dir / continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略)
|
||||
// (authorize_dir 已退役) continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略)
|
||||
// 3. R1 拒绝路径(generating=true 时 emit AiError 不调 ai_chat_send)
|
||||
//
|
||||
// 注:完整 Tauri command 调用链(需 AppHandle + State 真实构造)不在单元测试范围,
|
||||
|
||||
@@ -141,12 +141,22 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// 进程级静态 memory() 被全部测试共享:并行运行时 A 测试的 record/clear 会清掉
|
||||
/// B 测试刚记录的计数(实测 precheck_blocks 偶发 unwrap None)。全 mod 测试统一
|
||||
/// 持本锁串行化,消除竞态。
|
||||
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn clear() {
|
||||
memory().lock().unwrap().clear();
|
||||
}
|
||||
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precheck_blocks_after_two_failures() {
|
||||
let _serial = serial();
|
||||
clear();
|
||||
let args = json!({"path": "/tmp/x.rs"});
|
||||
assert!(precheck("read_file", &args).is_none());
|
||||
@@ -166,6 +176,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn memory_key_normalizes_irrelevant_fields() {
|
||||
let _serial = serial();
|
||||
let a = json!({"path": "/a.rs", "limit": 10});
|
||||
let b = json!({"path": "/a.rs", "limit": 999});
|
||||
assert_eq!(memory_key("read_file", &a), memory_key("read_file", &b));
|
||||
@@ -177,12 +188,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn run_command_key_is_command_itself() {
|
||||
let _serial = serial();
|
||||
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() {
|
||||
let _serial = serial();
|
||||
clear();
|
||||
timeout_streaks().lock().unwrap().clear();
|
||||
// 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦;
|
||||
@@ -206,6 +219,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn non_timeout_failures_do_not_aggregate_to_timeout_streak() {
|
||||
let _serial = serial();
|
||||
clear();
|
||||
timeout_streaks().lock().unwrap().clear();
|
||||
for path in ["/a.rs", "/b.rs", "/c.rs"] {
|
||||
|
||||
@@ -158,8 +158,8 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> {
|
||||
anyhow::bail!("禁止访问敏感系统目录");
|
||||
}
|
||||
// AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
|
||||
// AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
|
||||
// → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
|
||||
// AppData 原走白名单流程(已随 path_auth 白名单机制退役,2026-10-25,现为默认放行)。
|
||||
// 真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
|
||||
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
|
||||
// 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。
|
||||
Ok(())
|
||||
@@ -559,9 +559,20 @@ pub fn build_ai_tool_registry(
|
||||
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
||||
register_uclaw_tool(&mut registry, db);
|
||||
super::lease::register_lease_tool(&mut registry);
|
||||
register_history_tool(&mut registry, db);
|
||||
registry
|
||||
}
|
||||
|
||||
/// history_search AI 工具注册(1 个:跨会话历史检索,P1-A,u-claw #5/#6 等价)。
|
||||
///
|
||||
/// 工具职责:LLM 调 history_search(query, conversation_id?, limit?) 检索全部历史会话
|
||||
/// 消息正文(含已压缩原文,ai_messages=全量账本),两跳式瘦身(首跳每会话一条 120 字
|
||||
/// 片段,深查二跳传 conversation_id);query 空降级列最近 10 会话。风险 Low(纯只读)。
|
||||
/// 查询内核与 IPC search_messages 同源(commands/search.rs),声明式注册在 tools/history.rs。
|
||||
fn register_history_tool(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::history::register(registry, db);
|
||||
}
|
||||
|
||||
/// ask_uclaw AI 工具注册(1 个:委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议)。
|
||||
///
|
||||
/// 工具职责:LLM 调 ask_uclaw(prompt) 把任务下发给 u-claw(channel 级单链粘性会话),
|
||||
@@ -609,7 +620,7 @@ fn register_read_skill_tool(registry: &mut AiToolRegistry) {
|
||||
/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原
|
||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||
///
|
||||
/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给
|
||||
/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。
|
||||
@@ -750,7 +761,7 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
/// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 +
|
||||
/// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更),
|
||||
/// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::list_projects::register(registry, db);
|
||||
super::tools::project::register(registry, db);
|
||||
@@ -762,7 +773,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
///
|
||||
/// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs),
|
||||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::task::register(registry, db);
|
||||
}
|
||||
@@ -783,7 +794,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs),
|
||||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||
/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试
|
||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::task_graph::register(registry, db);
|
||||
}
|
||||
@@ -797,7 +808,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。
|
||||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||
/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试
|
||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::git::register(registry, db);
|
||||
}
|
||||
@@ -808,7 +819,7 @@ fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原
|
||||
/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||
/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试
|
||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
||||
super::tools::workflow::register(registry);
|
||||
}
|
||||
@@ -819,7 +830,7 @@ fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
||||
/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原
|
||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||
/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::idea::register(registry, db);
|
||||
}
|
||||
@@ -845,7 +856,7 @@ fn register_knowledge_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原
|
||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||
/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
super::tools::trash::register(registry, db);
|
||||
}
|
||||
@@ -860,7 +871,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
/// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原
|
||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||
/// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||||
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||
fn register_file_tools(
|
||||
registry: &mut AiToolRegistry,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
@@ -1516,10 +1527,14 @@ mod tests {
|
||||
// W-h1 FileLeaseRegistry 兄弟感知入口,写类文件工具跨会话互斥的可视面,
|
||||
// 注册在 lease.rs register_lease_tool)。
|
||||
// 80 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease。
|
||||
// history_search(2026-10-21): history 层 0→1(P1-A 跨会话历史检索,只读 Low,
|
||||
// u-claw #5/#6 等价:关键词检索全部会话消息正文+无参列最近会话,两跳式瘦身,
|
||||
// 查询内核与 IPC search_messages 同源,声明式注册 tools/history.rs)。
|
||||
// 81 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease + 1 history。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
80,
|
||||
"工具总数应为 80(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease),实际 {}", registry.len()
|
||||
81,
|
||||
"工具总数应为 81(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease + 1 history),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
@@ -1595,6 +1610,9 @@ mod tests {
|
||||
"ask_uclaw",
|
||||
// ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs)
|
||||
"query_leases",
|
||||
// ── history 层 (1) ──(P1-A 跨会话历史检索,只读 Low,u-claw #5/#6 等价,
|
||||
// 注册在 tools/history.rs)
|
||||
"history_search",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
|
||||
@@ -1002,7 +1002,7 @@ pub fn register(
|
||||
registry,
|
||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
"grep",
|
||||
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||||
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
@@ -1273,6 +1273,15 @@ pub fn register(
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
|
||||
.to_string();
|
||||
// 执行前最后闸(2026-10-25 走查补位):all 档/信任豁免下唯一保留硬线,与 approval/boundary 判定同源。
|
||||
// 审批链是前置闸(会话信任 check_trust_hits/boundary all 档都已各自拦截破坏性命令),
|
||||
// 但兜底放行路径(如未来新增 auto 通道)若漏拦,此处是命令真正 spawn 前的最后防线。
|
||||
if crate::commands::ai::is_destructive_command(&command) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"破坏性命令已拦截(执行层硬线):`{}` 属不可逆破坏类操作,拒绝执行。请改用安全方案",
|
||||
command
|
||||
));
|
||||
}
|
||||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! history_search AI 工具声明式注册(跨会话历史检索,devflow 等价 u-claw #5/#6)。
|
||||
//!
|
||||
//! 背景:P1-A 跨会话检索(分析:temp/分析-P1A-跨会话检索-2026-10-21.md)。ai_messages
|
||||
//! 是全量账本(compressed 消息原文完整保留),检索面覆盖即全覆盖,零迁移零回填。
|
||||
//! 查询内核与 IPC search_messages 同源(commands/search.rs::keyword_search /
|
||||
//! recent_conversation_briefs),本工具是 AI 通道的薄包装(DRY 无双轨)。
|
||||
//!
|
||||
//! ## 两跳式瘦身(拍板②)
|
||||
//! 首跳(不带 conversation_id)= 每会话一条 120 字片段 + 会话元信息的会话级概览;
|
||||
//! 二跳传 conversation_id+query 深查该会话逐条命中(片段 300 字)。
|
||||
//! 预算纪律:工具结果整体对齐 2K 收敛(namespace_store.rs:32 NAMESPACE_BYTE_THRESHOLD=
|
||||
//! 2048,超限由 namespace 引用化机制兜底转引用);首跳默认 5 会话 × 120 字天然在预算内,
|
||||
//! 深跳若超预算走 namespace 引用化属机制设计路径,不做人为硬截。
|
||||
//!
|
||||
//! ## 无参降级(拍板③)
|
||||
//! query 空 = 列最近 10 会话概览(标题+摘要+时间,一工具两语义,u-claw #5 同行为);
|
||||
//! 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字。
|
||||
//!
|
||||
//! 风险:Low(纯只读),免审批,knowledge_search 同级。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
|
||||
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||
use crate::commands::ai::commands::search::{keyword_search, recent_conversation_briefs};
|
||||
use crate::commands::ai::knowledge_inject::extract_query_keywords;
|
||||
|
||||
/// 首跳单会话片段长度(会话级概览,两跳式瘦身核心参数)。
|
||||
const FIRST_HOP_SNIPPET_CHARS: usize = 120;
|
||||
/// 深跳逐条命中片段长度(单跳备选方案 limit5×300 字口径)。
|
||||
const DEEP_HOP_SNIPPET_CHARS: usize = 300;
|
||||
/// 无参降级列最近会话条数(u-claw #5 口径)。
|
||||
const RECENT_CONVS_COUNT: usize = 10;
|
||||
/// 工具 limit 默认与上限(与 knowledge_search 同手感)。
|
||||
const TOOL_DEFAULT_LIMIT: usize = 5;
|
||||
const TOOL_MAX_LIMIT: usize = 10;
|
||||
/// 首跳为凑满 limit 个会话多取的消息数(单会话多命中会挤占名额,6 倍起步封顶 60)。
|
||||
const FIRST_HOP_FETCH_CAP: usize = 60;
|
||||
|
||||
/// 命中会话压缩原文回读指引(存档路径约定,与 archive.rs 召回句柄同款文案)。
|
||||
const ARCHIVE_RECALL_HINT: &str =
|
||||
"被压缩的早期原文可按存档路径回读:应用数据目录 context-archive/<conversation_id>-archive.jsonl(read_file)。";
|
||||
|
||||
/// 注册 history_search 工具。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"history_search",
|
||||
"跨会话历史检索:在全部历史会话的消息正文(user/assistant/system,含已压缩原文)中按关键词检索。使用时机:用户提到「上次/之前/历史」的任务、结论或做法时调用,如「上次那个部署怎么弄的」「之前说过的 XX 约定」。两跳式:首跳不带 conversation_id,返回每会话一条命中片段(会话级概览,含 conversation_id);需要某会话更多上下文时二跳传 conversation_id 深查逐条命中。参数:query=关键词或自然语言(可省略,省略时降级列最近会话概览:标题+摘要+时间);conversation_id=限定会话深查(可选,须与 query 同传);limit=返回会话/消息条数(可选,默认 5,上限 10)",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("query", "string", false),
|
||||
("conversation_id", "string", false),
|
||||
("limit", "integer", false),
|
||||
]),
|
||||
args => {
|
||||
let query = args["query"].as_str().unwrap_or("").trim().to_string();
|
||||
let conversation_id = args["conversation_id"]
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let limit = args["limit"]
|
||||
.as_u64()
|
||||
.unwrap_or(TOOL_DEFAULT_LIMIT as u64)
|
||||
.clamp(1, TOOL_MAX_LIMIT as u64) as usize;
|
||||
|
||||
// 无参降级(拍板③):query 空 → 列最近会话概览。conversation_id 与空 query
|
||||
// 同传语义不明,显式报错防 LLM 猜。
|
||||
if query.is_empty() {
|
||||
if conversation_id.is_some() {
|
||||
anyhow::bail!("conversation_id 深查须与 query 同传;列最近会话请省略 query");
|
||||
}
|
||||
let briefs = recent_conversation_briefs(&db, RECENT_CONVS_COUNT)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
return Ok(serde_json::json!({
|
||||
"mode": "recent_conversations",
|
||||
"conversations": briefs,
|
||||
"hint": "以上为最近会话概览(标题+摘要+时间)。按关键词检索全部历史消息请传 query;深查某会话传 conversation_id+query。",
|
||||
}));
|
||||
}
|
||||
|
||||
match conversation_id {
|
||||
Some(conv_id) => {
|
||||
// 二跳深查:会话内逐条命中(片段 300 字,超 2K 由 namespace 引用化兜底)
|
||||
let hits = keyword_search(&db, &query, Some(&conv_id), limit, 0)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if hits.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"mode": "messages",
|
||||
"conversation_id": conv_id,
|
||||
"results": [],
|
||||
"hint": "该会话内无命中。可换关键词重试,或去掉 conversation_id 全局检索定位会话。",
|
||||
}));
|
||||
}
|
||||
let title = hits[0].title.clone();
|
||||
let results: Vec<serde_json::Value> = hits
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
serde_json::json!({
|
||||
"seq": h.seq,
|
||||
"role": h.role,
|
||||
"timestamp": h.timestamp,
|
||||
"snippet": truncate_chars_ellipsis(&h.snippet, DEEP_HOP_SNIPPET_CHARS),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"mode": "messages",
|
||||
"conversation_id": conv_id,
|
||||
"title": title,
|
||||
"results": results,
|
||||
"hint": ARCHIVE_RECALL_HINT,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
// 首跳:每会话一条首命中片段的会话级概览(拍板②两跳式瘦身)。
|
||||
// 命中已按 timestamp DESC,保序按会话去重即「每会话最新一条」。
|
||||
let fetch = (limit * 6).clamp(TOOL_DEFAULT_LIMIT * 6, FIRST_HOP_FETCH_CAP);
|
||||
let hits = keyword_search(&db, &query, None, fetch, 0)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if hits.is_empty() {
|
||||
// 分词后无有效关键词与「真无命中」分开给指引(前者换词,后者换法)
|
||||
let hint = if extract_query_keywords(&query).is_empty() {
|
||||
"query 分词后无有效关键词(全是停用词/标点),请换更具体的关键词(任务名/文件名/技术词)。"
|
||||
} else {
|
||||
"历史会话中无命中。可换关键词重试;或省略 query 列最近会话概览。"
|
||||
};
|
||||
return Ok(serde_json::json!({
|
||||
"mode": "conversations",
|
||||
"results": [],
|
||||
"hint": hint,
|
||||
}));
|
||||
}
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let results: Vec<serde_json::Value> = hits
|
||||
.into_iter()
|
||||
.filter(|h| seen.insert(h.conversation_id.clone()))
|
||||
.take(limit)
|
||||
.map(|h| {
|
||||
serde_json::json!({
|
||||
"conversation_id": h.conversation_id,
|
||||
"title": h.title,
|
||||
"updated_at": h.updated_at,
|
||||
"origin": h.origin,
|
||||
"snippet": truncate_chars_ellipsis(&h.snippet, FIRST_HOP_SNIPPET_CHARS),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"mode": "conversations",
|
||||
"results": results,
|
||||
"hint": format!("以上为每会话一条首命中片段(会话级概览)。需某会话更多上下文时传 conversation_id+query 深查逐条命中;{ARCHIVE_RECALL_HINT}"),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::{AiConversationRepo, AiMessageRepo};
|
||||
use df_storage::models::{AiConversationRecord, AiMessageRecord};
|
||||
use serde_json::json;
|
||||
|
||||
async fn test_db() -> Arc<Database> {
|
||||
Arc::new(Database::open_in_memory().await.unwrap())
|
||||
}
|
||||
|
||||
async fn ins_conv(db: &Database, id: &str, title: &str, updated_at: &str) {
|
||||
AiConversationRepo::new(db)
|
||||
.insert(AiConversationRecord {
|
||||
id: id.into(),
|
||||
title: Some(title.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: "100".into(),
|
||||
updated_at: updated_at.into(),
|
||||
origin: None,
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord {
|
||||
AiMessageRecord {
|
||||
id: format!("m_{conv}_{seq}"),
|
||||
conversation_id: conv.into(),
|
||||
seq,
|
||||
role: role.into(),
|
||||
content: content.into(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: "active".into(),
|
||||
reasoning_content: None,
|
||||
timestamp: Some(timestamp),
|
||||
created_at: "100".into(),
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 首跳瘦身(拍板②):单会话多命中只出一条(会话级去重),片段截 120 字,
|
||||
/// 带 conversation_id 供二跳深查;空结果给指引非裸空数组。
|
||||
#[tokio::test]
|
||||
async fn first_hop_groups_per_conversation_with_slim_snippet() {
|
||||
let db = test_db().await;
|
||||
ins_conv(&db, "c1", "部署踩坑复盘", "200").await;
|
||||
ins_conv(&db, "c2", "端口约定", "300").await;
|
||||
AiMessageRepo::new(&db)
|
||||
.insert_batch(vec![
|
||||
msg("c1", 0, "user", "部署第一次尝试失败", 100),
|
||||
msg("c1", 1, "assistant", "部署第二次改用代理成功", 200),
|
||||
msg("c2", 0, "user", "部署统一走 3307 端口", 300),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = registry
|
||||
.execute("history_search", json!({"query": "部署", "limit": 5}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out["mode"], "conversations");
|
||||
let results = out["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 2, "两会话各一条,单会话多命中去重: {out}");
|
||||
let ids: Vec<&str> = results.iter().map(|r| r["conversation_id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, vec!["c2", "c1"], "timestamp DESC,c2 最新在前");
|
||||
for r in results {
|
||||
let snippet = r["snippet"].as_str().unwrap();
|
||||
assert!(
|
||||
snippet.chars().count() <= FIRST_HOP_SNIPPET_CHARS + 1,
|
||||
"首跳片段应截 120 字(+省略号),实际 {}: {snippet}",
|
||||
snippet.chars().count()
|
||||
);
|
||||
}
|
||||
let hint = out["hint"].as_str().unwrap();
|
||||
assert!(hint.contains("conversation_id"), "首跳应提示可二跳深查: {hint}");
|
||||
}
|
||||
|
||||
/// 二跳深查:conversation_id+query 返回该会话逐条命中(mode=messages)。
|
||||
#[tokio::test]
|
||||
async fn deep_hop_returns_message_level_hits() {
|
||||
let db = test_db().await;
|
||||
ins_conv(&db, "c1", "部署复盘", "200").await;
|
||||
AiMessageRepo::new(&db)
|
||||
.insert_batch(vec![
|
||||
msg("c1", 0, "user", "部署怎么弄", 100),
|
||||
msg("c1", 1, "assistant", "部署分三步:构建/上传/重启", 200),
|
||||
msg("c1", 2, "tool", "部署工具输出", 300),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = registry
|
||||
.execute(
|
||||
"history_search",
|
||||
json!({"query": "部署", "conversation_id": "c1"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out["mode"], "messages");
|
||||
assert_eq!(out["title"], "部署复盘");
|
||||
let results = out["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 2, "深跳逐条命中但 tool role 仍排除: {out}");
|
||||
assert_eq!(results[0]["seq"], 1, "timestamp DESC,assistant 在前");
|
||||
}
|
||||
|
||||
/// 无参降级(拍板③+⑤):query 空列最近会话,摘要走「system 压缩摘要 > 首条 user」
|
||||
/// 降级链;conversation_id 与空 query 同传显式报错。
|
||||
#[tokio::test]
|
||||
async fn no_param_falls_back_to_recent_conversations_with_summary_chain() {
|
||||
let db = test_db().await;
|
||||
ins_conv(&db, "c_sum", "有摘要", "200").await;
|
||||
ins_conv(&db, "c_user", "无摘要", "300").await;
|
||||
AiMessageRepo::new(&db)
|
||||
.insert_batch(vec![
|
||||
msg("c_sum", 0, "user", "c_sum 的用户消息不应入选", 1),
|
||||
msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要优先", 2),
|
||||
msg("c_user", 0, "user", "c_user 首条消息作摘要兜底", 3),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = registry.execute("history_search", json!({})).await.unwrap();
|
||||
assert_eq!(out["mode"], "recent_conversations");
|
||||
let convs = out["conversations"].as_array().unwrap();
|
||||
assert_eq!(convs.len(), 2, "c_user(300)在前 c_sum(200)在后: {out}");
|
||||
let c_sum = convs.iter().find(|c| c["id"] == "c_sum").unwrap();
|
||||
assert!(c_sum["summary"].as_str().unwrap().contains("压缩摘要优先"), "摘要走 system 优先: {out}");
|
||||
let c_user = convs.iter().find(|c| c["id"] == "c_user").unwrap();
|
||||
assert!(c_user["summary"].as_str().unwrap().contains("首条消息"), "无 system 走首条 user 兜底: {out}");
|
||||
|
||||
// conversation_id 与空 query 同传 → 明确报错,不静默当列表用
|
||||
let err = registry
|
||||
.execute("history_search", json!({"conversation_id": "c1"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("query"), "报错应指引带 query: {err}");
|
||||
}
|
||||
|
||||
/// 空命中给可执行指引(非裸空数组);分词无有效关键词与真无命中分开指引。
|
||||
#[tokio::test]
|
||||
async fn empty_results_carry_actionable_hint() {
|
||||
let db = test_db().await;
|
||||
ins_conv(&db, "c1", "无关会话", "200").await;
|
||||
AiMessageRepo::new(&db)
|
||||
.insert_batch(vec![msg("c1", 0, "user", "完全无关的内容", 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
// 真无命中
|
||||
let out = registry
|
||||
.execute("history_search", json!({"query": "量子纠缠实验"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out["results"].as_array().unwrap().len(), 0);
|
||||
assert!(out["hint"].as_str().unwrap().contains("换关键词"), "真无命中指引: {out}");
|
||||
// 分词后无有效关键词(纯停用词)
|
||||
let out2 = registry
|
||||
.execute("history_search", json!({"query": "帮我看看一下这个"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out2["hint"].as_str().unwrap().contains("关键词"), "分词空指引: {out2}");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ pub mod get_app_config;
|
||||
pub mod workflow;
|
||||
pub mod idea;
|
||||
pub mod knowledge;
|
||||
// history_search 跨会话历史检索(P1-A,u-claw #5/#6 等价;查询内核与 IPC search_messages
|
||||
// 同源在 commands/search.rs,本文件是 AI 通道薄包装)。
|
||||
pub mod history;
|
||||
pub mod trash;
|
||||
pub mod file;
|
||||
pub mod download_file;
|
||||
|
||||
@@ -224,6 +224,10 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
|
||||
// 24 审批可见性:渠道生命周期订阅任务,审批挂起事件推卡到绑定 chat(跨重连常驻,
|
||||
// 与 run_loop 的按连接重建无关)
|
||||
tauri::async_runtime::spawn(run_approval_notifier(ctx.clone()));
|
||||
// 换代通知(P1-B 方案甲,2026-10-21):停靠/压缩换代事件推绑定 chat,同款生命周期
|
||||
// 订阅任务。此前两事件已广播(emit_ai_chat_event 双写底座)但本文件零消费,
|
||||
// 停靠/换代对飞书侧完全无感知
|
||||
tauri::async_runtime::spawn(run_generation_notifier(ctx.clone()));
|
||||
// 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死
|
||||
// 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。
|
||||
// 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻),
|
||||
@@ -1168,7 +1172,7 @@ async fn handle_message(
|
||||
false, // 不劫持用户活跃面板(编排节点同款语义)
|
||||
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
|
||||
Some(df_ai::provider::MessageSource {
|
||||
channel: "feishu".into(),
|
||||
channel: SOURCE_CHANNEL_FEISHU.into(),
|
||||
message_id: message_id.clone(),
|
||||
chat_id: Some(chat_id.clone()),
|
||||
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
|
||||
@@ -1294,6 +1298,18 @@ fn delivery_blocked(path: &str) -> Option<&'static str> {
|
||||
.map(|_| "敏感文件(凭据/密钥特征)")
|
||||
}
|
||||
|
||||
/// 入站消息渠道标识(source.channel 值,#958 溯源同参;spawn 链渠道段判定用)
|
||||
pub(crate) const SOURCE_CHANNEL_FEISHU: &str = "feishu";
|
||||
|
||||
/// 交付引导(#958 生产端配套,2026-10-21 修「devflow 不会用飞书发文件」):出站回传消费端
|
||||
/// (extract_delivery_paths/reply_deliveries/delivery_blocked)早已在位,但全仓 prompt 层
|
||||
/// 零处教 AI 写 `[交付]` 标记行——AI 永远不会自发写标记,链路空转。本段经 spawn 链渠道
|
||||
/// 标识透传(source.channel==feishu)注入飞书渠道会话的 AI 语境(落点 chat.rs
|
||||
/// spawn_agentic_session 稳定段之后,与 aug_seg 同构分流)。文案与解析器是同一契约,
|
||||
/// 示例格式必须能被 extract_delivery_paths 原样提取(单测 delivery_guidance 锁死);
|
||||
/// 敏感拦截只一句交底不重复造(拦截器本尊在 delivery_blocked)。
|
||||
pub(crate) const DELIVERY_GUIDANCE_PROMPT: &str = "(渠道提示)如需给用户发送本机文件:在报告正文写 `[交付] <文件的本机绝对路径>` 标记行,每个文件单独一行;渠道会自动把这些文件上传为飞书原生附件发给用户,无需其他操作。名字含凭据/密钥特征(如 .env、token、secret、password)的文件会被拦截拒发,不要尝试交付敏感文件。";
|
||||
|
||||
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
|
||||
fn feishu_file_type(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
@@ -1441,6 +1457,10 @@ async fn run_completion_watcher(
|
||||
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
||||
if !t.trim().is_empty() {
|
||||
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
|
||||
// #958 出站回传补口(2026-10-21):快任务兜底此前早退直投终版,漏过主循环
|
||||
// 统一出口的 reply_deliveries——秒级任务报告里的 [交付] 标记行全部落空。
|
||||
// 迟到终版(35min 降级 continue 后到达)走 rx.recv() 臂汇入主出口,无缺口
|
||||
reply_deliveries(&ctx.client, &chat_id, &t).await;
|
||||
let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await;
|
||||
let _ = stream_task.await;
|
||||
return;
|
||||
@@ -1609,6 +1629,148 @@ async fn run_approval_notifier(ctx: Arc<Ctx>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 换代通知聚合窗(P1-B 方案甲 §1.4 节流从严):首条换代事件开 30s 窗,窗尾同 chat 合并
|
||||
/// 一卡——多会话并行时同窗多代,逐条直推刷屏
|
||||
const COMPRESS_NOTIFY_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// 停靠原因类别中文映射(stop_kind 词表 7 值,ai/mod.rs tests_task_parked 词表锁定;
|
||||
/// 未知/缺省值走兜底,新增词表值须同步此处)
|
||||
fn stop_kind_zh(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"approval_timeout" => "审批超时",
|
||||
"cancelled" => "已取消",
|
||||
"circuit_breaker" => "连续失败熔断",
|
||||
"stall_breaker" => "进展停滞熔断",
|
||||
"max_rounds" => "轮次预算用尽",
|
||||
"fatal" => "致命错误",
|
||||
"midstream" => "中途断流",
|
||||
_ => "未知原因",
|
||||
}
|
||||
}
|
||||
|
||||
/// 停靠通知卡文案(纯函数,单测锁死三要素:类别+原因+恢复指引)。恢复指引只交底真实
|
||||
/// 出路:桌面停靠卡「继续」或直接发新指令接力(飞书侧无 /continue 命令,文案不承诺)
|
||||
fn park_notify_text(stop_kind: &str, reason: &str) -> String {
|
||||
format!(
|
||||
"⏸ 任务已停靠({}):{}\n桌面端会话可点「继续」恢复执行,也可直接在此发新指令接力。",
|
||||
stop_kind_zh(stop_kind),
|
||||
truncate(reason, 200),
|
||||
)
|
||||
}
|
||||
|
||||
/// 换代通知单行文案(纯函数):代号+摘要首行(压缩换代对用户的可感知面就一句;
|
||||
/// 空摘要退化为纯换代告知,不出悬空冒号)
|
||||
fn compress_notify_line(seq: u32, summary: &str) -> String {
|
||||
let first = summary.lines().next().unwrap_or("").trim();
|
||||
if first.is_empty() {
|
||||
format!("🔄 会话上下文已自动压缩换代(第 {seq} 代)。")
|
||||
} else {
|
||||
format!(
|
||||
"🔄 会话上下文已自动压缩换代(第 {seq} 代):{}",
|
||||
truncate(first, 60)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 聚合窗合并卡文案(纯函数):单会话单行直出;同 chat 多会话合并为清单
|
||||
fn merge_compress_notify(lines: &[String]) -> String {
|
||||
match lines {
|
||||
[one] => one.clone(),
|
||||
many => format!(
|
||||
"🔄 {} 个会话上下文已自动换代:\n{}",
|
||||
many.len(),
|
||||
many.iter().map(|l| format!("- {l}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换代通知(分析 P1-B §1.4,2026-10-21):渠道生命周期订阅任务(与 run_approval_notifier
|
||||
/// 同款骨架,跨重连常驻)。消费 ai_event_bus 的 AiTaskParked/AiAutoCompressed,反查 conv
|
||||
/// 绑定飞书 chat 推文本卡;无绑定(纯桌面会话)不推,零打扰。此前两事件已广播(双写底座
|
||||
/// emit_ai_chat_event)但本文件零消费——停靠/换代对飞书侧完全无感知。
|
||||
/// AiTaskParked 同因去重(重放/多重终局汇聚不重推,恢复后再停靠因内容不同自然放行);
|
||||
/// AiAutoCompressed 走 30s 聚合窗(同 chat 合并一卡);换代代号=进程内递增计数(方案甲
|
||||
/// 零 DDL,持久化代号建模留 P1 检索批),重启归 1。
|
||||
async fn run_generation_notifier(ctx: Arc<Ctx>) {
|
||||
let state = ctx.app.state::<crate::state::AppState>();
|
||||
let mut rx = state.ai_event_bus.subscribe();
|
||||
// 停靠去重记忆:conv → 最近一次已推 (reason, stop_kind)
|
||||
let mut pushed_parks: HashMap<String, (String, String)> = HashMap::new();
|
||||
// 换代号:conv → 进程内压缩换代计数
|
||||
let mut gen_seq: HashMap<String, u32> = HashMap::new();
|
||||
// 聚合窗:chat → 待推行;flush_at=None=无窗,Some=窗尾时刻
|
||||
let mut pending_compress: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut flush_at: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
let item = if let Some(deadline) = flush_at {
|
||||
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||
// 窗到:同 chat 合并推送后重开等事件(无窗期零 select 开销)
|
||||
Err(_) => {
|
||||
flush_at = None;
|
||||
let chats = pending_compress.len();
|
||||
for (chat_id, lines) in pending_compress.drain() {
|
||||
let body = merge_compress_notify(&lines);
|
||||
let _ = send_markdown_card(&ctx.client, "", &chat_id, &body).await;
|
||||
}
|
||||
feishu_log(&format!("换代通知聚合窗推送 chats={chats}"));
|
||||
continue;
|
||||
}
|
||||
Ok(item) => item,
|
||||
}
|
||||
} else {
|
||||
rx.recv().await
|
||||
};
|
||||
match item {
|
||||
Ok(v) => match v.get("type").and_then(|t| t.as_str()) {
|
||||
Some("AiTaskParked") => {
|
||||
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string();
|
||||
let kind = v.get("stop_kind").and_then(|k| k.as_str()).unwrap_or("").to_string();
|
||||
if pushed_parks.get(cid) == Some(&(reason.clone(), kind.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
|
||||
continue;
|
||||
};
|
||||
// 去重记忆防无界(低频事件,超限全清只失去去重,无正确性影响)
|
||||
if pushed_parks.len() > 256 {
|
||||
pushed_parks.clear();
|
||||
}
|
||||
pushed_parks.insert(cid.to_string(), (reason.clone(), kind.clone()));
|
||||
let text = park_notify_text(&kind, &reason);
|
||||
let _ = send_markdown_card(&ctx.client, "", &chat_id, &text).await;
|
||||
feishu_log(&format!("停靠通知推送 chat={chat_id} conv={cid} kind={kind}"));
|
||||
}
|
||||
Some("AiAutoCompressed") => {
|
||||
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
|
||||
continue;
|
||||
};
|
||||
let seq = gen_seq
|
||||
.entry(cid.to_string())
|
||||
.and_modify(|n| *n += 1)
|
||||
.or_insert(1);
|
||||
let line = compress_notify_line(
|
||||
*seq,
|
||||
v.get("summary").and_then(|s| s.as_str()).unwrap_or(""),
|
||||
);
|
||||
pending_compress.entry(chat_id).or_default().push(line);
|
||||
if flush_at.is_none() {
|
||||
flush_at = Some(tokio::time::Instant::now() + COMPRESS_NOTIFY_WINDOW);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片按钮回调(2026-10-08 审批卡按钮化):解析按钮 value 的 {approval_id, approved},
|
||||
/// 经 ai_approve 原语决议。与文本「批准/拒绝」通路同原语,双通道并存(按钮优先,
|
||||
/// 文本作降级)。回调在独立 spawn 内执行,不阻塞事件泵。
|
||||
@@ -2679,11 +2841,13 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
|
||||
file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
|
||||
is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
|
||||
msg_after_acceptance, parse_approval_verdict, parse_command, sniff_image_mime,
|
||||
strip_mention_placeholders, stream_preview, within_resolved_window,
|
||||
compress_notify_line, ContentPart, DebounceEntry, DELIVERY_GUIDANCE_PROMPT,
|
||||
delivery_blocked, extract_delivery_paths, feishu_file_type, file_display_name,
|
||||
file_inbound_prompt, file_size_over_limit_error, heartbeat_due, is_terminal_push,
|
||||
md_to_lark_friendly, merge_compress_notify, merge_debounce_parts, merge_debounce_text,
|
||||
msg_after_acceptance, park_notify_text, parse_approval_verdict, parse_command,
|
||||
sniff_image_mime, stop_kind_zh, stream_preview, strip_mention_placeholders,
|
||||
within_resolved_window,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -2706,6 +2870,72 @@ mod tests {
|
||||
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_guidance_teaches_extractable_format() {
|
||||
// 件三(2026-10-21):引导文案与解析器是同一契约——文案教的标记行格式必须能被
|
||||
// extract_delivery_paths 原样提取,教错格式=引导写出的标记全部落空(链路二次空转)
|
||||
assert!(DELIVERY_GUIDANCE_PROMPT.contains("[交付]"), "缺标记格式: {DELIVERY_GUIDANCE_PROMPT}");
|
||||
let marked = "[交付] E:\\out\\report.md\n其余为叙述文本";
|
||||
assert_eq!(
|
||||
extract_delivery_paths(marked),
|
||||
vec!["E:\\out\\report.md".to_string()],
|
||||
"引导教的标记行格式必须可被消费端解析"
|
||||
);
|
||||
// 三要素交底:自动上传机制 / 敏感拦截 / 绝对路径口径
|
||||
assert!(DELIVERY_GUIDANCE_PROMPT.contains("附件"), "缺自动上传机制交底");
|
||||
assert!(DELIVERY_GUIDANCE_PROMPT.contains("拦截"), "缺敏感拦截交底");
|
||||
assert!(DELIVERY_GUIDANCE_PROMPT.contains("绝对路径"), "缺路径口径交底");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_kind_zh_maps_full_vocabulary() {
|
||||
// 停靠词表 7 值全映射(与 ai/mod.rs tests_task_parked 词表对齐),词表值不得落兜底
|
||||
for k in [
|
||||
"approval_timeout",
|
||||
"cancelled",
|
||||
"circuit_breaker",
|
||||
"stall_breaker",
|
||||
"max_rounds",
|
||||
"fatal",
|
||||
"midstream",
|
||||
] {
|
||||
assert_ne!(stop_kind_zh(k), "未知原因", "词表值 {k} 不应落兜底");
|
||||
}
|
||||
assert_eq!(stop_kind_zh("__unknown__"), "未知原因");
|
||||
assert_eq!(stop_kind_zh(""), "未知原因", "stop_kind 缺省走兜底");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn park_and_compress_notify_texts_carry_essentials() {
|
||||
// 停靠卡三要素:类别+原因+恢复指引(指引不承诺飞书侧不存在的 /continue 命令)
|
||||
let park = park_notify_text("approval_timeout", "审批超时(15分钟)自动取消,任务已安全停靠");
|
||||
assert!(park.contains("审批超时"), "缺类别: {park}");
|
||||
assert!(park.contains("审批超时(15分钟)自动取消"), "缺原因: {park}");
|
||||
assert!(park.contains("「继续」"), "缺恢复指引: {park}");
|
||||
assert!(!park.contains("/continue"), "不得承诺不存在的命令: {park}");
|
||||
// 换代行:代号+摘要首行,不带出后续行;空摘要退化纯告知(无悬空冒号)
|
||||
let line = compress_notify_line(3, "第一行摘要\n第二行细节");
|
||||
assert!(line.contains("第 3 代"), "缺代号: {line}");
|
||||
assert!(line.contains("第一行摘要"), "缺摘要首行: {line}");
|
||||
assert!(!line.contains("第二行"), "不得带出后续行: {line}");
|
||||
let bare = compress_notify_line(1, "");
|
||||
assert!(
|
||||
bare.contains("第 1 代") && !bare.contains(':') && !bare.contains(':'),
|
||||
"空摘要应退化纯告知: {bare}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_aggregate_window_merges_by_chat() {
|
||||
// 聚合窗合并:同 chat 单会话单行直出;多会话合并为计数+清单
|
||||
let one = vec!["🔄 会话上下文已自动压缩换代(第 1 代)。".to_string()];
|
||||
assert_eq!(merge_compress_notify(&one), one[0], "单行直出不加壳");
|
||||
let many = vec!["换代A".to_string(), "换代B".to_string()];
|
||||
let merged = merge_compress_notify(&many);
|
||||
assert!(merged.contains("2 个会话"), "缺计数: {merged}");
|
||||
assert!(merged.contains("- 换代A") && merged.contains("- 换代B"), "缺清单项: {merged}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feishu_file_type_maps_common_exts() {
|
||||
assert_eq!(feishu_file_type("pdf"), "pdf");
|
||||
|
||||
@@ -496,6 +496,8 @@ pub fn run() {
|
||||
commands::ai::ai_pending_tool_calls,
|
||||
commands::ai::ai_chat_clear,
|
||||
commands::ai::ai_is_generating,
|
||||
// 桌面拖拽/粘贴文件落盘(治「文件内容被当文本塞进输入框」)
|
||||
commands::ai::ai_save_dropped_file,
|
||||
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
|
||||
commands::ai::ai_inject_runtime,
|
||||
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
|
||||
@@ -532,6 +534,8 @@ pub fn run() {
|
||||
commands::ai::ai_conversation_archive,
|
||||
commands::ai::ai_conversation_set_pinned,
|
||||
commands::ai::ai_update_conversation_goals,
|
||||
// P1-A 跨会话消息检索(F4-1 后端搜索面;AI 工具 history_search 共用同源内核)
|
||||
commands::ai::search_messages,
|
||||
// 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批
|
||||
commands::ai::ai_task_boundary_get,
|
||||
commands::ai::ai_task_boundary_set,
|
||||
@@ -547,8 +551,6 @@ pub fn run() {
|
||||
commands::ai::ai_set_allowed_dirs,
|
||||
// 工具风险元数据投影(前端启动构建 High 风险名单,审批二次确认门控)
|
||||
commands::ai::ai_list_tools,
|
||||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
||||
commands::ai::ai_authorize_dir,
|
||||
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
|
||||
commands::ai::audit::record::list_tool_executions,
|
||||
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
|
||||
@@ -566,6 +568,9 @@ pub fn run() {
|
||||
commands::knowledge::knowledge_record_reuse,
|
||||
commands::knowledge::knowledge_list_candidates,
|
||||
commands::knowledge::knowledge_archive,
|
||||
commands::knowledge::knowledge_archive_stale,
|
||||
commands::knowledge::knowledge_list_related,
|
||||
commands::knowledge::knowledge_distill_group,
|
||||
commands::knowledge::knowledge_retry_embedding,
|
||||
commands::knowledge::knowledge_get_config,
|
||||
commands::knowledge::knowledge_save_config,
|
||||
@@ -604,7 +609,6 @@ pub fn run() {
|
||||
commands::settings::save_template,
|
||||
commands::settings::delete_template,
|
||||
// CI 状态
|
||||
commands::ci_status::get_commit_status,
|
||||
// Phase3: 内嵌 HTTP MCP server 状态查询
|
||||
crate::mcp::mcp_get_status,
|
||||
])
|
||||
|
||||
@@ -103,6 +103,29 @@ impl MediaStore {
|
||||
.map(|(url, _)| url)
|
||||
}
|
||||
|
||||
/// 桌面拖拽/粘贴的非图片文件落盘(attachments/ 目录,本机 read_file 语境)。
|
||||
/// 与 media/ 图片分目录:文件不进 OSS(跨设备 https 引用对本机 read_file 无意义),
|
||||
/// hash 去重命名复用 content_key(原名+扩展保真)。返回本机绝对路径供前端插引用。
|
||||
pub async fn store_attachment(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
original_name: &str,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
let key = content_key(bytes, "application/octet-stream", Some(original_name));
|
||||
let dir = self.data_dir.join("attachments");
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| format!("创建附件目录失败: {e}"))?;
|
||||
let path = dir.join(&key);
|
||||
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
|
||||
if let Err(e) = tokio::fs::write(&path, bytes).await {
|
||||
tokio::fs::remove_file(&path).await.ok();
|
||||
return Err(format!("附件写入失败: {e}"));
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
|
||||
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
|
||||
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
|
||||
|
||||
+6
-62
@@ -45,8 +45,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
||||
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer 分离窗不挂)。 -->
|
||||
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||
|
||||
<!-- 人工审批对话框(全局,任意路由响应 store.pendingApproval)。
|
||||
@@ -73,20 +72,16 @@ import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||
import ApprovalDialog from './components/project/ApprovalDialog.vue'
|
||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||
import AppLayout from './components/layout/AppLayout.vue'
|
||||
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
|
||||
import { startUpdateAutoCheck } from '@/composables/useUpdateCheck'
|
||||
import { aiApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached' || route.path === '/approval-popup')
|
||||
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached')
|
||||
const aiStore = useAiStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer / approval-popup 等其他分离窗口弹)
|
||||
// approval-popup 是系统级浮窗本身,内部已显式渲染 pending 列表,再挂 ApprovalOverlay 会重复
|
||||
const showApprovalOverlay = computed(() =>
|
||||
route.path !== '/file-explorer-detached' && route.path !== '/approval-popup'
|
||||
)
|
||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
||||
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
||||
|
||||
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||
@@ -122,7 +117,6 @@ let _unlistenTrayHideNotice: (() => void) | null = null
|
||||
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
|
||||
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
||||
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
||||
// (系统级浮窗 useApprovalPopup 是窗口失焦时的提示;此处是聚焦时弹的对话框,二者职责互补)
|
||||
const showApprovalDialog = ref(false)
|
||||
watch(() => projectStore.pendingApproval, (newApproval) => {
|
||||
if (newApproval) {
|
||||
@@ -146,7 +140,7 @@ const restoreMaximizedOnReturn = ref(false)
|
||||
|
||||
watch(() => route.path, (path) => {
|
||||
// 分离窗口不参与主面板最大化联动:分离窗是独立 JS context,state 与主窗无关;
|
||||
// 且其 path(/ai-detached、/file-explorer-detached、/approval-popup)不在 AI 域内,
|
||||
// 且其 path(/ai-detached、/file-explorer-detached)不在 AI 域内,
|
||||
// 若不守卫会因分离窗默认 maximized=true 误触发 setMaximized(false) 并落盘,
|
||||
// 覆盖主窗口的 df-ai-ui 布局
|
||||
if (isDetached.value) return
|
||||
@@ -189,28 +183,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 系统级审批浮窗(独立窗口):仅主窗口启动(ai-detached/approval-popup 自身不启动,避免重复 watch)
|
||||
let _stopApprovalPopup: (() => void) | null = null
|
||||
let _unlistenPopupReady: (() => void) | null = null
|
||||
async function activateForPopup() {
|
||||
// 浮窗「打开审批面板」按钮 → 聚焦主窗口 + 打开 AI 面板/激活分离窗口
|
||||
// 修复 BUG1:setFocus 先行(已授权,失焦场景有效),show 兜底(需 core:window:allow-show,
|
||||
// capabilities/default.json 已补)。原顺序 show→setFocus,show reject 直接吞掉 setFocus,
|
||||
// 主窗口最小化/隐藏时 show 失败 → 用户看到「点了没反应」。
|
||||
try {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (mainWin) {
|
||||
// setFocus 先行(对未聚焦但可见的窗口即可恢复焦点);show 兜底取消最小化/隐藏
|
||||
try { await mainWin.setFocus() } catch { /* setFocus 失败不阻塞 show */ }
|
||||
try { await mainWin.show() } catch { /* show 未授权或不可用,忽略(setFocus 已尽力) */ }
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
if (!aiStore.state.detached && !aiStore.state.panelOpen) {
|
||||
aiStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
||||
// 包 try/catch 防 loadAll reject 中断 onMounted 后续初始化(对齐下方各步 try/catch 模式)
|
||||
@@ -272,33 +244,9 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
console.error('关窗后台运行提示监听失败:', e)
|
||||
}
|
||||
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
|
||||
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
||||
if (route.path !== '/approval-popup') {
|
||||
_stopApprovalPopup = startApprovalPopup(
|
||||
() => aiStore.state.pendingApprovals,
|
||||
activateForPopup,
|
||||
)
|
||||
// 浮窗 webview 挂载后会 emit 'approval-popup-ready' 请求初始快照(防主窗口在浮窗 listen 前
|
||||
// emit 丢失)。此处 listen 后立即重推当前 pendingApprovals。
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
_unlistenPopupReady = await listen('approval-popup-ready', async () => {
|
||||
const popup = await WebviewWindow.getByLabel('approval-popup')
|
||||
if (popup) {
|
||||
await popup.emit('approval-popup-update', { approvals: aiStore.state.pendingApprovals })
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[ApprovalPopup] ready 监听失败:', e)
|
||||
}
|
||||
}
|
||||
// B2 应用内升级:自动检查调度(启动后 3s 首查 + 周期检查,同版本只提示一次)。
|
||||
// 幂等(模块级单例),仅主窗口启动;auto_check=false 时内部自动跳过。
|
||||
if (route.path !== '/approval-popup') {
|
||||
startUpdateAutoCheck()
|
||||
}
|
||||
startUpdateAutoCheck()
|
||||
})
|
||||
|
||||
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
||||
@@ -339,10 +287,6 @@ const knowledgeStore = useKnowledgeStore()
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
projectStore.stopDataChangedListener?.()
|
||||
_stopApprovalPopup?.()
|
||||
_unlistenPopupReady?.()
|
||||
_stopApprovalPopup = null
|
||||
_unlistenPopupReady = null
|
||||
_unlistenTrayHideNotice?.()
|
||||
_unlistenTrayHideNotice = null
|
||||
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
|
||||
|
||||
+2
-21
@@ -75,15 +75,6 @@ export const aiApi = {
|
||||
return invoke('ai_approve', { toolCallId, approved })
|
||||
},
|
||||
|
||||
/**
|
||||
* 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
||||
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||
*/
|
||||
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
|
||||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
||||
},
|
||||
|
||||
/**
|
||||
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
|
||||
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
||||
@@ -107,8 +98,8 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||||
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||||
* 返 kind 字段(恒 'risk')与后端超时到期时刻(前端倒计时单一真值)。 */
|
||||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path'; expires_at_millis?: number | null }[]> {
|
||||
return invoke('ai_pending_tool_calls', { conversationId })
|
||||
},
|
||||
|
||||
@@ -290,21 +281,11 @@ export const aiApi = {
|
||||
return invoke('ai_reload_skills')
|
||||
},
|
||||
|
||||
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
||||
getAllowedDirs(): Promise<string[]> {
|
||||
return invoke<string[]>('ai_get_allowed_dirs')
|
||||
},
|
||||
|
||||
/** 列出全部已注册 AI 工具的名称与风险级(审批高风险名单的权威数据源) */
|
||||
listTools(): Promise<AiToolMeta[]> {
|
||||
return invoke('ai_list_tools')
|
||||
},
|
||||
|
||||
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
||||
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
||||
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
||||
},
|
||||
|
||||
/** 监听 AI 聊天事件 */
|
||||
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
||||
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
||||
|
||||
+5
-32
@@ -439,14 +439,10 @@ export type AiChatEvent = ({
|
||||
// stream: "stdout"|"stderr"(来源,前端差异化着色);line:累积文本块(多行 '\n' 连接)。
|
||||
type: 'AiCommandOutput'; id: string; stream: string; line: string
|
||||
} | {
|
||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
||||
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
||||
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
||||
// 开关 df-ai-unified-approval:true=归一进 pendingApprovals 带 kind;false=回退 DirAuthDialog 老链路。
|
||||
// 普通 RiskLevel 审批(approve/reject)。原 path_auth 双 kind 设计已随路径授权机制退役(2026-10-25)。
|
||||
// expires_at_millis:后端超时收敛到期时刻(ms Unix;超时收敛单一真值=后端,前端无本地自动拒绝)。
|
||||
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string; diff?: string
|
||||
kind?: 'path' | 'risk'
|
||||
expires_at_millis?: number | null
|
||||
} | {
|
||||
type: 'AiApprovalResult'; id: string; approved: boolean
|
||||
} | {
|
||||
@@ -491,10 +487,6 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
|
||||
type: 'AiMaxRoundsReached'
|
||||
} | {
|
||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
// 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||
} | {
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
|
||||
@@ -749,25 +741,6 @@ export interface AiToolCallInfo {
|
||||
/** write_file 行级 unified diff(旧文件 vs 新内容),审批卡即时预览。
|
||||
* 旧文件不存在(新建)或非 write_file 为 undefined,前端回退显新 content。 */
|
||||
diff?: string
|
||||
/**
|
||||
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项,
|
||||
* 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
|
||||
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
||||
* 调 aiApi.approve(一次性)。
|
||||
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
||||
* 开关 df-ai-unified-approval 控制 path 类是否归一进 pendingApprovals 带此字段。
|
||||
*/
|
||||
kind?: 'path' | 'risk'
|
||||
/**
|
||||
* path_auth 审批链阶段3b:'path' 类挂起携带的目录信息(规范化后的待授权目录)。
|
||||
* AiDirAuthRequired 归一为 pendingApprovals 项时透传,ToolCard 显提示"LLM 想访问 X 目录"。
|
||||
* 'risk' 类为 undefined。
|
||||
*/
|
||||
dir?: string
|
||||
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
|
||||
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
|
||||
path?: string
|
||||
/** 工具开始执行时间(前端在 AiToolCallStarted 时记录,毫秒)。历史消息从 DB 回载无此字段,
|
||||
* 后端事件协议不含耗时,卡片耗时/轨迹总耗时由前端时间差计算,缺字段时隐藏不显 0。 */
|
||||
startedAt?: number
|
||||
@@ -782,11 +755,11 @@ export interface AiToolCallInfo {
|
||||
streamLines?: string[]
|
||||
/**
|
||||
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
||||
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
||||
* 来源:AiApprovalRequired 事件透传 conversation_id
|
||||
* (缺失时兜底 activeConversationId);switchConversation 恢复历史挂起时填目标会话 id。
|
||||
* cleanupTerminatedConversation 据此仅清目标会话的待审批项 + 对称清其审批超时计时器,
|
||||
* 不连累并发会话正在审批的卡(conv-scoped,误清其他会话审批 = 回归)。
|
||||
* undefined = 非挂起项或无法归属(保守保留,不清)。对齐 PendingDirAuth.conversationId。
|
||||
* undefined = 非挂起项或无法归属(保守保留,不清)。
|
||||
*/
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
|
||||
/>
|
||||
|
||||
<!-- 「信任所有目录」放行横幅:开关开启期间常驻提示(设置-安全-AI 工具授权目录),
|
||||
提醒当前 AI 可直接访问任意非受保护目录;黑名单目录仍被拒绝 -->
|
||||
<div v-if="pathAuthBypass" class="ai-auth-bypass-banner">{{ $t('aiChat.pathAuthBypassBanner') }}</div>
|
||||
|
||||
<!-- 第四批抽离至 ai/MessageList.vue(消息列表 + 流式 Markdown 分块渲染 + 工具卡转发 +
|
||||
折叠归档段 + 滚动跟随/回底部,零行为变更,store/useMarkdown 单例共享) -->
|
||||
<MessageList
|
||||
@@ -62,11 +58,6 @@
|
||||
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
|
||||
<TaskParkedCard @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,兜底回退)时挂载
|
||||
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
||||
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)。
|
||||
C4b 拆分至 ai-chat/queue-panel.vue(L2 发送韧性 + V7 排队时钟 + 队列编辑/回填随迁,
|
||||
confirm 自持同 ChatInput 模式;hasUnsentInput/fillDraft 经 props/emits 接 ChatInput ref) -->
|
||||
@@ -114,7 +105,6 @@ import MessageList from './ai/MessageList.vue'
|
||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||
import TaskParkedCard from './ai/TaskParkedCard.vue'
|
||||
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
@@ -137,13 +127,7 @@ const router = useRouter()
|
||||
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
|
||||
const { autoCompressedFlash } = useAiContext()
|
||||
|
||||
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
|
||||
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
|
||||
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
|
||||
const appSettings = useAppSettingsStore()
|
||||
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
|
||||
// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅
|
||||
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
|
||||
// 「信任所有目录」总开关横幅已随授权目录机制退役移除(2026-10-25 默认全放行,横幅失去信息量)
|
||||
|
||||
// ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
|
||||
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
|
||||
@@ -739,16 +723,6 @@ async function initContextEventListeners(): Promise<void> {
|
||||
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
|
||||
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
|
||||
|
||||
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */
|
||||
.ai-auth-bypass-banner {
|
||||
padding: 5px 14px;
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
border-bottom: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ═══ Input ═══ */
|
||||
.ai-input-area {
|
||||
padding: 10px 14px;
|
||||
|
||||
@@ -84,41 +84,8 @@
|
||||
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
||||
<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
|
||||
(与 DirAuthDialog.vue 同款四按钮;IPC authorizeDir 后端 once/session/always/deny 全支持)。
|
||||
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项,调 approve。 -->
|
||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeOnce">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--session"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeSession">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthSession') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--always"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeAlways">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthAlways') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onDeny">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
|
||||
{{ $t('aiChat.dirAuthDeny') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="ai-tool-actions">
|
||||
<!-- 普通 RiskLevel 审批:approve/reject 两选项(path_auth 四选项链已退役 2026-10-25)。 -->
|
||||
<div class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove">
|
||||
@@ -171,14 +138,6 @@
|
||||
/>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
|
||||
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
|
||||
<!-- 审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
|
||||
<Transition name="ai-tool-toast">
|
||||
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
|
||||
@@ -205,7 +164,6 @@ import { useTimerOwnership } from '@/composables/useTimerOwnership'
|
||||
import { callDurationText } from '@/composables/ai/useTurnTrace'
|
||||
import { formatDate, formatDuration } from '@/utils/time'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ToolResultBody from './ToolResultBody.vue'
|
||||
import OffloadedResultBar from './OffloadedResultBar.vue'
|
||||
|
||||
@@ -234,18 +192,14 @@ const emit = defineEmits<{
|
||||
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
|
||||
*/
|
||||
'install-obscura': []
|
||||
/**
|
||||
* 审批按钮 → 父级转发 store.approveToolCall。
|
||||
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'session'|'always'|'deny')。
|
||||
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
|
||||
*/
|
||||
/** 审批按钮 → 父级转发 store.approveToolCall(一次性 approve/reject)。 */
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
||||
}>()
|
||||
|
||||
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
||||
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
||||
|
||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
|
||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject)抽离至 composable。
|
||||
const {
|
||||
approving,
|
||||
approveToast,
|
||||
@@ -256,12 +210,6 @@ const {
|
||||
expireLevel,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAuthorizeOnce,
|
||||
onAuthorizeSession,
|
||||
onAuthorizeAlways,
|
||||
onDeny,
|
||||
confirmState,
|
||||
answerConfirm,
|
||||
} = useToolApproval(toRef(props, 'tc'), emit)
|
||||
|
||||
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
||||
@@ -597,16 +545,6 @@ const visibleArgs = computed(() => {
|
||||
}
|
||||
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.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);
|
||||
color: var(--df-accent);
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
.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); }
|
||||
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
|
||||
/* 审批 loading禁用两按钮防重复点击,spinner 转在原位 */
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
|
||||
{{ $t('aiTool.rejectAll') }}
|
||||
</button>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
@@ -43,14 +39,6 @@
|
||||
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -58,11 +46,8 @@
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ToolCard from './ToolCard.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { toolRiskLevel } from '@/composables/ai/useToolApproval'
|
||||
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||||
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
||||
@@ -74,10 +59,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 审批按钮 → 父组件转发 store.approveToolCall。
|
||||
* path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。
|
||||
*/
|
||||
/** 审批按钮 → 父组件转发 store.approveToolCall(一次性 approve/reject)。 */
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
||||
/** 批量审批 → 父组件调用 approveAll/rejectAll */
|
||||
'batch-approve': [decision: 'approve' | 'reject']
|
||||
@@ -154,27 +136,15 @@ const pendingCount = computed(() =>
|
||||
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
|
||||
)
|
||||
|
||||
// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) --
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */
|
||||
const highRiskPendingCount = computed(() =>
|
||||
props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length
|
||||
)
|
||||
// 批量批准 High 风险二次确认已退役(2026-10-25 批C):审批按钮本身即确认(与单卡/浮窗同口径)。
|
||||
/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */
|
||||
const batchProcessing = ref(false)
|
||||
|
||||
/**
|
||||
* 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义,
|
||||
* 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。
|
||||
* 批量批准入口:直接向父级派发 batch-approve('approve')。
|
||||
* 拒绝批量无破坏性,不拦。
|
||||
*/
|
||||
async function onBatchApprove() {
|
||||
if (highRiskPendingCount.value > 0) {
|
||||
const ok = await confirmDialog(
|
||||
t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }),
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
batchProcessing.value = true
|
||||
emit('batch-approve', 'approve')
|
||||
// emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位
|
||||
@@ -366,7 +336,7 @@ function scrollToFirstPending(): void {
|
||||
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
|
||||
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
|
||||
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
|
||||
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
||||
// 超时自动 approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
||||
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
|
||||
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
|
||||
let _unlistenPending: UnlistenFn | null = null
|
||||
|
||||
@@ -1,221 +1,20 @@
|
||||
<template>
|
||||
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 -->
|
||||
|
||||
<!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
|
||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
||||
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
||||
<span v-if="parsed?.search" class="ai-tool-file-meta">「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
|
||||
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
|
||||
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
|
||||
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
|
||||
<button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
|
||||
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
|
||||
<!-- 行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
|
||||
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 默认模式:全文预览 -->
|
||||
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- list_directory 结果:文件树 -->
|
||||
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
||||
</div>
|
||||
<div class="ai-tool-dir-entries">
|
||||
<div
|
||||
v-for="(entry, i) in parsed?.entries"
|
||||
:key="i"
|
||||
class="ai-tool-dir-entry"
|
||||
:class="'ai-tool-dir-entry--' + entry.type"
|
||||
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
|
||||
>
|
||||
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ entry.name }}</span>
|
||||
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染,修 results 被丢弃致 body 仅显「找到 N 项」) -->
|
||||
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
||||
</div>
|
||||
<div class="ai-tool-dir-entries">
|
||||
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ entry.path }}</span>
|
||||
<span class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grep 结果:跨文件内容搜索(content/files_with_matches/count 三模式,F-260621)
|
||||
对齐 search_files 文件栏样式;content 模式按命中行渲染(文件:行:内容),支持 -C 上下文折叠 -->
|
||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
|
||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
||||
</div>
|
||||
<!-- files_with_matches 模式:仅文件列表 -->
|
||||
<div v-if="parsed?.output_mode === 'files_with_matches'" class="ai-tool-dir-entries">
|
||||
<div v-for="(f, i) in parsed?.files" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ f }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- count 模式:文件 + 命中行数 -->
|
||||
<div v-else-if="parsed?.output_mode === 'count'" class="ai-tool-dir-entries">
|
||||
<div v-for="(c, i) in parsed?.counts" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ c.file }}</span>
|
||||
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
|
||||
<!-- 文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
|
||||
<div v-else class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
<span class="ai-tool-grep-file">{{ m.file }}</span>
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- write_file 结果 -->
|
||||
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
||||
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
|
||||
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
|
||||
<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="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 红删绿增行级预览(复用 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">
|
||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
|
||||
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
|
||||
</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="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>
|
||||
</div>
|
||||
|
||||
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
|
||||
<div
|
||||
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
|
||||
class="ai-tool-cmd-result"
|
||||
>
|
||||
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
|
||||
<span class="ai-tool-cmd-prompt">$</span>
|
||||
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
|
||||
<button
|
||||
v-if="cmdOutput"
|
||||
class="ai-tool-cmd-toggle"
|
||||
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||
@click="cmdOutputExpanded = !cmdOutputExpanded"
|
||||
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||
</div>
|
||||
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 ) -->
|
||||
<div
|
||||
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
|
||||
class="ai-tool-http-result"
|
||||
>
|
||||
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
|
||||
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
|
||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
||||
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
|
||||
<button
|
||||
v-if="parsed?.body"
|
||||
class="ai-tool-cmd-toggle"
|
||||
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||
@click="httpBodyExpanded = !httpBodyExpanded"
|
||||
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||
</div>
|
||||
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
|
||||
<span aria-hidden="true">⚠</span> {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
|
||||
</div>
|
||||
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- fetch_url 结果:URL + title + render_mode 徽标 + 可折叠 markdown
|
||||
render_mode='static_fallback'(obscura 未装/失败)→ 显 obscura 未装提示 + "AI 帮装"按钮
|
||||
(按钮 emit install-obscura → MessageList 调 store.sendMessage 触发 AI 装流式) -->
|
||||
<div
|
||||
v-else-if="tc.name === 'fetch_url' && tc.result && tc.status === 'completed' && parsed"
|
||||
class="ai-tool-fetch-result"
|
||||
>
|
||||
<div class="ai-tool-fetch-line">
|
||||
<span class="ai-tool-http-method ai-tool-fetch-mode" :class="'ai-tool-fetch-mode--' + (parsed?.render_mode || 'static')">
|
||||
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered')
|
||||
: parsed?.render_mode === 'obscura_failed' ? $t('aiTool.fetchUrlFallback')
|
||||
: $t('aiTool.fetchUrlStatic') }}
|
||||
</span>
|
||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
||||
<span v-if="typeof parsed?.length === 'number' && parsed?.length > 0" class="ai-tool-file-meta">{{ parsed?.length }} {{ $t('aiTool.charsUnit') }}</span>
|
||||
<button
|
||||
v-if="parsed?.markdown"
|
||||
class="ai-tool-cmd-toggle"
|
||||
:title="fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||
@click="fetchMdExpanded = !fetchMdExpanded"
|
||||
>{{ fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||
</div>
|
||||
<div v-if="parsed?.title" class="ai-tool-fetch-title">{{ parsed?.title }}</div>
|
||||
<!-- obscura 未装引导(render=true 但回退静态):提示 + "AI 帮装"按钮 -->
|
||||
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
|
||||
<div class="ai-tool-obscura-hint-text"><span aria-hidden="true">⚠</span> {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
|
||||
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{{ $t('aiTool.installObscura') }}
|
||||
</button>
|
||||
</div>
|
||||
<pre v-if="fetchMdExpanded && parsed?.markdown" class="ai-tool-cmd-output"><code>{{ parsed.markdown }}</code></pre>
|
||||
</div>
|
||||
|
||||
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。
|
||||
C4a 拆分:九个工具专属分支下沉 tool-result-body/ 两族子件,本组件持分族派发 + 通用兜底。 -->
|
||||
<!-- 读取浏览族:read_file / list_directory / search_files / grep(原链第 1-4 支) -->
|
||||
<FileBrowseResult
|
||||
v-if="browseBranch"
|
||||
:tc="tc"
|
||||
:is-content-expanded="isContentExpanded"
|
||||
@expand-content="emit('expand-content', $event)"
|
||||
/>
|
||||
<!-- 写入执行族:write_file / patch_file / run_command / http_request / fetch_url(原链第 5-9 支) -->
|
||||
<WriteRunResult
|
||||
v-else-if="writeRunBranch"
|
||||
:tc="tc"
|
||||
@install-obscura="emit('install-obscura')"
|
||||
/>
|
||||
<!-- 通用 CRUD 结果 -->
|
||||
<div
|
||||
v-else-if="tc.result && tc.status === 'completed'"
|
||||
@@ -228,24 +27,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
isToolFailure,
|
||||
parseResult,
|
||||
formatBytes,
|
||||
argString,
|
||||
formatSizeDiff,
|
||||
combineOutputs,
|
||||
formatToolResult,
|
||||
formatGrep,
|
||||
shortPath,
|
||||
dirIcon,
|
||||
fileIcon,
|
||||
searchIcon,
|
||||
DIR_TRUNCATE_LIMIT,
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
/**
|
||||
* 工具结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||
*
|
||||
* C4a 大文件拆分:九个工具专属分支分两族下沉 tool-result-body/
|
||||
* - FileBrowseResult = 读取浏览族(read_file / list_directory / search_files / grep,原链第 1-4 支)
|
||||
* - WriteRunResult = 写入执行族(write_file / patch_file / run_command / http_request / fetch_url,原链第 5-9 支)
|
||||
* 本组件持分族派发 + 通用 CRUD 兜底分支。
|
||||
*
|
||||
* 派发口径与原 v-else-if 链等价:各支条件原样,组判定=组内各支条件析取,组序=原链序——
|
||||
* 原链渲染首个命中支;同一支必落同组,组内首个命中支即该组渲染内容,故渲染结果一一对应。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { parseResult, formatToolResult } from '@/composables/ai/useToolCard'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import { resultIsFailed } from './tool-result-body/shared'
|
||||
import FileBrowseResult from './tool-result-body/FileBrowseResult.vue'
|
||||
import WriteRunResult from './tool-result-body/WriteRunResult.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单条工具调用数据(结果展示只读消费) */
|
||||
@@ -264,135 +62,35 @@ const emit = defineEmits<{
|
||||
'install-obscura': []
|
||||
}>()
|
||||
|
||||
// ── R6 结果路径可复制 ──
|
||||
// AI 读过/写过的文件,用户想拿去本地操作(编辑器打开/再 grep)只能手动重敲路径。
|
||||
// file-bar 的 path 改为可点击:复制完整路径(parsed.path 原文,非 shortPath 截断展示),
|
||||
// 按钮文字短暂变「✓」作反馈(1.2s 还原,同 R4 代码块复制反馈节奏)。
|
||||
let _pathCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
async function copyResultPath(e: MouseEvent, path: string): Promise<void> {
|
||||
if (!path) return
|
||||
const btn = e.currentTarget as HTMLButtonElement | null
|
||||
let ok = false
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(path)
|
||||
ok = true
|
||||
}
|
||||
} catch { /* 落 execCommand 兜底 */ }
|
||||
if (!ok) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = path
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
if (btn) {
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '✓' : '✕'
|
||||
if (_pathCopiedTimer) clearTimeout(_pathCopiedTimer)
|
||||
_pathCopiedTimer = setTimeout(() => { btn.textContent = prev }, 1200)
|
||||
}
|
||||
}
|
||||
|
||||
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
||||
const cmdOutputExpanded = ref(false)
|
||||
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
||||
const httpBodyExpanded = ref(false)
|
||||
// fetch_url markdown 折叠态:默认折叠(可能很长,头部已有 title + 截断字符数摘要)
|
||||
const fetchMdExpanded = ref(false)
|
||||
|
||||
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
/**
|
||||
* 是否工具失败(completed 语义)。复用 parsed 不重 parse。逻辑与模块级 isToolFailure 同源,
|
||||
* 但因 run_command 失败判定依赖 parsed.exit_code(本组件已 computed),此处就近复用避免重复 parse。
|
||||
*/
|
||||
const isFailed = computed(() => {
|
||||
/** 是否工具失败(completed 语义,通用兜底分支失败态);判定逻辑本体在 shared.resultIsFailed */
|
||||
const isFailed = computed(() => resultIsFailed(props.tc, parsed.value))
|
||||
|
||||
/** 读取浏览族命中(原链第 1-4 支条件原样析取) */
|
||||
const browseBranch = computed(() => {
|
||||
const tc = props.tc
|
||||
if (tc.status !== 'completed') return false
|
||||
const r = parsed.value
|
||||
if (tc.name === 'run_command') {
|
||||
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
|
||||
if (r) return true
|
||||
}
|
||||
// 可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
|
||||
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
|
||||
if (r && r.success === false) return true
|
||||
if (!r) {
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (!raw) return false
|
||||
return /^(执行失败|Error:|Failed:)/m.test(raw)
|
||||
}
|
||||
return false
|
||||
return (tc.name === 'read_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'list_directory' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'search_files' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'grep' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
})
|
||||
|
||||
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
|
||||
const cmdOutput = computed(() => {
|
||||
const r = parsed.value
|
||||
if (!r) return ''
|
||||
return combineOutputs(r.stdout, r.stderr)
|
||||
/** 写入执行族命中(原链第 5-9 支条件原样析取;run_command 原条件本就无 parsed 判定,照抄) */
|
||||
const writeRunBranch = computed(() => {
|
||||
const tc = props.tc
|
||||
return (tc.name === 'write_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'patch_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'run_command' && !!tc.result && tc.status === 'completed')
|
||||
|| (tc.name === 'http_request' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'fetch_url' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* patch_file / write_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
|
||||
* 数据源:parsed.diff(工具执行结果字段,write_file 覆盖写入时后端同款生成)。
|
||||
* R12a:默认截断前 120 行防撑爆卡片,expanded 后全量渲染(toggle 入口在 diff 块尾部)。
|
||||
*/
|
||||
const DIFF_PREVIEW_LINES = 120
|
||||
const diffExpanded = ref(false)
|
||||
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, diffExpanded.value ? undefined : DIFF_PREVIEW_LINES))
|
||||
/** diff 全量行数(不受预览截断影响),驱动展开按钮显隐与行数文案 */
|
||||
const diffTotal = computed(() => parseDiffLines(parsed.value?.diff).length)
|
||||
|
||||
/**
|
||||
* grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
|
||||
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)。
|
||||
* parsed 为 null 返空串,等价原逻辑;非 null 委托 formatGrep。
|
||||
*/
|
||||
const grepHitLabel = computed(() => {
|
||||
const r = parsed.value
|
||||
return r ? formatGrep(r) : ''
|
||||
})
|
||||
|
||||
/**
|
||||
* fetch_url obscura 未装检测(render=true 但 obscura 未装):
|
||||
* 后端 render=true 且 obscura 未装 → result.render_mode='obscura_not_installed' + hint 引导。
|
||||
* 装了但失败/超时 → 'obscura_failed'(不弹引导,只 hint)。此 computed 仅未装弹"AI 帮装"。
|
||||
*/
|
||||
const obscuraMissing = computed(() => {
|
||||
if (props.tc.name !== 'fetch_url') return false
|
||||
const r = parsed.value
|
||||
if (!r) return false
|
||||
const mode = typeof r.render_mode === 'string' ? r.render_mode : ''
|
||||
return mode === 'obscura_not_installed'
|
||||
})
|
||||
|
||||
/**
|
||||
* fetch_url "AI 帮装 obscura" → emit 透传到 MessageList,调 store.sendMessage
|
||||
* 触发 AI run_command 装流式。安装完成后用户重试即可用 obscura 渲染。
|
||||
* 提示语足够具体(给出两种装法),AI 拿到能直接 run_command 不需再问。
|
||||
*/
|
||||
function onInstallObscura() {
|
||||
emit('install-obscura')
|
||||
}
|
||||
|
||||
// status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
|
||||
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
|
||||
watch(() => props.tc.status, (s) => {
|
||||
if (s === 'completed') {
|
||||
cmdOutputExpanded.value = isToolFailure(props.tc)
|
||||
httpBodyExpanded.value = isToolFailure(props.tc)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<!-- ═══ ToolResultBody scoped 样式(C4a 后本组件只持通用兜底分支样式;
|
||||
读取浏览/写入执行两族样式随子件迁 tool-result-body/,路径条族在 path-bar.css 两子件共享) ═══ -->
|
||||
<style scoped>
|
||||
/* 结果展示区样式(从 ToolCard.vue 迁移,仅含结果分支用到的 class;卡片容器/header/审批/骨架
|
||||
等样式留 ToolCard.vue)。失败横幅/通用结果失败态样式一并迁移,因结果区独立判定 isFailed。 */
|
||||
.ai-tool-failed-banner {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 11px;
|
||||
@@ -409,263 +107,6 @@ watch(() => props.tc.status, (s) => {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
|
||||
/* -- File Content (read_file) -- */
|
||||
.ai-tool-file-content {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-file-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--df-info);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-file-icon--dir {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-tool-file-path {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-accent);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-file-meta {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-file-meta--warn {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
/* R6:路径可点击复制(按钮语义,视觉保持原 span 形态;hover 变 accent 提示可点) */
|
||||
.ai-tool-file-path--copy {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-file-path--copy:hover {
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
||||
.ai-tool-grep-result {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-grep-matches {
|
||||
padding: 4px 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-tool-grep-match {
|
||||
padding: 3px 12px;
|
||||
border-bottom: 0.5px solid var(--df-hover);
|
||||
}
|
||||
.ai-tool-grep-match:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
/* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
|
||||
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
|
||||
.ai-tool-grep-match-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-grep-file {
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ai-tool-grep-line {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
min-width: 2em;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
user-select: none;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ai-tool-grep-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: rgba(61,219,160,0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.ai-tool-grep-context {
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: var(--df-hover);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.ai-tool-grep-context code {
|
||||
display: block;
|
||||
padding: 4px 6px;
|
||||
white-space: pre;
|
||||
}
|
||||
.ai-tool-expand-btn {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: var(--df-hover);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-expand-btn:hover {
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: var(--df-border-strong);
|
||||
}
|
||||
.ai-tool-file-pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--df-text-secondary);
|
||||
overflow-x: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
.ai-tool-file-pre--collapsed {
|
||||
max-height: 180px;
|
||||
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.ai-tool-file-pre--collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* -- Directory List (list_directory / search_files) -- */
|
||||
.ai-tool-dir-list {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-dir-entries {
|
||||
padding: 4px 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-tool-dir-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 12px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ai-tool-dir-entry:hover {
|
||||
background: var(--df-hover);
|
||||
}
|
||||
.ai-tool-dir-entry--directory .ai-tool-dir-name {
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-tool-dir-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-dir-icon--folder { color: var(--df-warning); }
|
||||
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
|
||||
.ai-tool-dir-name {
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.ai-tool-dir-size {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* -- Write File Result -- */
|
||||
.ai-tool-write-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
background: rgba(61,219,160,0.04);
|
||||
}
|
||||
.ai-tool-write-path {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-write-meta {
|
||||
font-size: 10px;
|
||||
color: var(--df-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -- patch_file diff 预览(复用审批 diff 同款 token) -- */
|
||||
.ai-tool-approval-reason {
|
||||
padding: 4px 10px 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--df-warning);
|
||||
background: var(--df-warning-bg);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
.ai-tool-approval-diff {
|
||||
margin: 0 10px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 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 行,默认滚动条挤压可读性;
|
||||
对齐 ai-tool-file-pre--collapsed(180px) 与 ai-tool-dir-entries(220px) 量级。 */
|
||||
@@ -683,195 +124,4 @@ watch(() => props.tc.status, (s) => {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
|
||||
.ai-tool-cmd-result {
|
||||
margin: 0;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-cmd-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(61,219,160,0.04);
|
||||
}
|
||||
.ai-tool-cmd-line--failed {
|
||||
background: var(--df-danger-bg);
|
||||
}
|
||||
.ai-tool-cmd-prompt {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-cmd-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-tool-cmd-toggle {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-cmd-toggle:hover {
|
||||
color: var(--df-text);
|
||||
background: var(--df-hover);
|
||||
}
|
||||
.ai-tool-cmd-output {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: var(--df-text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
|
||||
.ai-tool-http-result {
|
||||
margin: 0;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-http-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
|
||||
}
|
||||
.ai-tool-http-line--failed {
|
||||
background: var(--df-danger-bg);
|
||||
}
|
||||
.ai-tool-http-method {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--df-text);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ai-tool-http-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-tool-http-status {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-http-status--ok {
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-http-trunc-warn {
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* -- fetch_url 结果:复用 http-line 视觉 token;render_mode 徽标按 mode 着色 -- */
|
||||
.ai-tool-fetch-result {
|
||||
margin: 0;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-fetch-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(94,175,240,0.04);
|
||||
}
|
||||
.ai-tool-fetch-mode {
|
||||
/* 复用 .ai-tool-http-method 基样(template 已加该 class),此处仅覆盖 mode 着色 */
|
||||
font-size: 10px;
|
||||
}
|
||||
.ai-tool-fetch-mode--static,
|
||||
.ai-tool-fetch-mode--obscura {
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-fetch-mode--static_fallback {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-tool-fetch-title {
|
||||
padding: 4px 10px 2px;
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
/* obscura 未装引导(static_fallback):warning 色调提示 + AI 帮装按钮 */
|
||||
.ai-tool-obscura-hint {
|
||||
margin: 4px 10px 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--df-warning-bg);
|
||||
border: 0.5px solid var(--df-warning);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-tool-obscura-hint-text {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* AI 帮装按钮(accent 色调,对齐 batch-approve 视觉) */
|
||||
.ai-tool-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border: none;
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: var(--df-font-sans);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-btn:hover { filter: brightness(1.1); }
|
||||
|
||||
/* R12: write_file diff 回看 + 长 diff 展开按钮 */
|
||||
.ai-tool-write-diff { width: 100%; margin-top: 6px; }
|
||||
.ai-tool-diff-toggle {
|
||||
display: block;
|
||||
margin: 4px auto 0;
|
||||
background: transparent;
|
||||
border: 0.5px dashed var(--df-border);
|
||||
border-radius: 4px;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 3px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<!-- F-260619-03 Phase B + path_auth 审批链阶段1: 路径授权弹窗(LLM 想访问白名单外目录,
|
||||
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
||||
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
||||
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。
|
||||
点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。
|
||||
点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" ref="dirAuthRefs" class="ai-dir-auth">
|
||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||
<span class="ai-dir-auth-hint">
|
||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
||||
</span>
|
||||
<div class="ai-dir-auth-actions">
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--once"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'once')"
|
||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--session"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'session')"
|
||||
>{{ $t('aiChat.dirAuthSession') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'always')"
|
||||
>{{ $t('aiChat.dirAuthAlways') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'deny')"
|
||||
>{{ $t('aiChat.dirAuthDeny') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- P1-e-c: deny 拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试;对齐 ToolCard/ApprovalPopup) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="$t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingDirAuths } from '../../composables/ai/useAiPendingState'
|
||||
import { aiApi } from '../../api'
|
||||
import { useConfirm } from '../../composables/useConfirm'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// P1-e-c: deny 拒绝二次确认统一弹层(useConfirm 状态机 + ConfirmDialog 组件,同 ToolCard/ApprovalPopup)
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// F-260620 根治(授权挂起无声卡死):挂起态弹窗显示只依赖 isGenerating(conv),不依赖 streaming。
|
||||
// streaming 会被流式看门狗超时清(useAiStream onStreamTimeout),而 AiDirAuthRequired 挂起常发生在
|
||||
// 首轮慢响应(看门狗已超时清 streaming)之后——此时 streaming=false 但后端 generating=true、
|
||||
// generatingConvs 含 conv(useAiEvents handleEvent 对非完成/错误事件 add)。若守卫仍 && streaming,
|
||||
// 挂起弹窗永不显示 → loop 无声卡死(用户发消息无响应)。去 streaming,isGenerating(conv) 单条件覆盖。
|
||||
// 多会话并发 — isGenerating(active) 替代单值比对
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// pendingDirAuths 非空 + 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// 阶段1:数组化后,visibleDirAuths 是 pendingDirAuths 的派生视图(按 conv 过滤当前会话挂起项),
|
||||
// 与 showDirAuthCard 合并为单一 computed(非空即显)。
|
||||
const visibleDirAuths = computed(() => {
|
||||
if (!isViewingGenerating.value) return []
|
||||
// 仅显当前会话的挂起项(active conv);其他会话的挂起不在本视图弹窗。
|
||||
const active = store.state.activeConversationId
|
||||
return pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === active)
|
||||
})
|
||||
|
||||
// 阶段1:逐 id 追踪"处理中"态(数组多挂起并存,单 boolean 不够)。
|
||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
||||
const actingIds = reactive(new Set<string>())
|
||||
|
||||
// M8:卡片根元素 ref 数组(v-for ref 收集)。挂起弹窗出现在输入区上方,常被消息流/进度条挤出视口,
|
||||
// 用户看不到按钮 → 挂起无声卡死。新增项时滚动定位到卡片让审批入口立即可见。
|
||||
const dirAuthRefs = ref<HTMLElement[]>([])
|
||||
|
||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
|
||||
if (actingIds.has(id)) return
|
||||
actingIds.add(id)
|
||||
// P1-e-c: deny 拒绝前二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试;与 ToolCard onDeny 同款)。
|
||||
// 放 actingIds.add 之后、IPC 之前;取消则删 actingIds 复位处理中态(保持 pending 可重新决策),不调用 IPC。
|
||||
if (decision === 'deny') {
|
||||
const ok = await confirmDialog(t('aiChat.dirAuthDenyConfirm'))
|
||||
if (!ok) {
|
||||
actingIds.delete(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await aiApi.authorizeDir(id, decision)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
|
||||
actingIds.delete(id) // IPC 失败:复位该 id,允许重试
|
||||
return
|
||||
}
|
||||
// F-260620 根治(授权按钮永转圈):IPC 成功后 try_continue spawn 续跑 loop,续跑 loop 可能立刻又
|
||||
// 触发 AiDirAuthRequired(同 id 不可能,但其他 id 可能)置新 pendingDirAuths 项;该 id 的项会由
|
||||
// AiApprovalResult 按 id filter 移除。这里不依赖 pendingDirAuths 状态复位 actingIds——改为
|
||||
// watch pendingDirAuths,某 id 离开数组(已被后端消费)即删 actingIds,精准复位。
|
||||
}
|
||||
|
||||
/** pendingDirAuths 变化时,离开数组(已被后端 AiApprovalResult/AiCompleted/AiError 按 id 移除)的
|
||||
* id 从 actingIds 删,精准复位"处理中"态(数组化后多挂起,单 watch===null 模式不再适用)。 */
|
||||
watch(() => pendingDirAuths.value, (items) => {
|
||||
const liveIds = new Set(items.map(p => p.id))
|
||||
for (const id of [...actingIds]) {
|
||||
if (!liveIds.has(id)) actingIds.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
/** M8:visibleDirAuths 出现新挂起项时,scrollIntoView 让审批入口立刻可见(挂起弹窗在输入区上方,
|
||||
* 易被消息流/进度条挤出视口,用户不滚动看不到 → 挂起无声卡死)。仅新增项触发滚动,避免已可见项
|
||||
* 反复跳动打扰。 */
|
||||
watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||
await nextTick()
|
||||
// 滚到最新(数组末)项,block:'center' 让按钮组居中可见
|
||||
const last = dirAuthRefs.value[dirAuthRefs.value.length - 1]
|
||||
if (last) last.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-dir-auth {
|
||||
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-dir-auth-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-dir-auth-hint {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-dir-auth-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ai-dir-auth-btn {
|
||||
border: none;
|
||||
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-dir-auth-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-dir-auth-btn--once {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--session {
|
||||
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
||||
color: var(--df-accent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--always {
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
}
|
||||
.ai-dir-auth-btn--always:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--deny {
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-dir-auth-btn--deny:hover:not(:disabled) {
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref。
|
||||
显 reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)。
|
||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||
@@ -64,7 +64,7 @@ watch(helpActive, async (p) => {
|
||||
|
||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||
* 路径授权链路已退役(path_auth 2026-10-25),无需此卡代发。
|
||||
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
|
||||
function handleSelectOption(opt: string): void {
|
||||
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
|
||||
|
||||
@@ -1782,7 +1782,7 @@ defineExpose({
|
||||
v-if="m.toolCalls && m.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
:toolCalls="m.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
@@ -1817,7 +1817,7 @@ defineExpose({
|
||||
<div v-if="m.toolCalls && m.toolCalls.length > 0" class="ai-trace-round">
|
||||
<ToolCardList
|
||||
:toolCalls="m.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
@@ -2124,13 +2124,11 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
|
||||
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path 类 once/always/deny),
|
||||
store.approveToolCall 据 decision 走 authorizeDir,缺省走 approve。 -->
|
||||
<ToolCardList
|
||||
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
:toolCalls="item.msg.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// 无人值守边界配置卡(P1 总装简化):授权面收敛单一档——风险上限固定 Medium、
|
||||
// 目录白名单不开放(空=不额外扩权,全局 allowed_dirs 照常生效),破坏性命令恒禁止;
|
||||
// 目录白名单不开放(全局路径授权已退役为默认全放行,2026-10-25),破坏性命令恒禁止;
|
||||
// 可调项只剩预算两项(最大轮次/最长运行时长),各带「不限」勾选(0 直传,后端 0=不限)。
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
+30
-108
@@ -7,15 +7,15 @@
|
||||
//! 下沉范围(严格限定为「无依赖、被多个 composable 共用」的成员):
|
||||
//! - nextMsgId / _msgCounter:events/stream/send/window 四个 composable 共用同一计数器
|
||||
//! - findToolCall:events/send 两个 composable 共用的纯查找函数(仅读 state.messages)
|
||||
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
|
||||
//! - 审批到期登记(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! 前端 auto-reject 定时器已退役(2026-10-25),现为后端透传到期时刻的登记/清除,供倒计时 UI 读
|
||||
|
||||
import { reactive, ref } from 'vue'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types'
|
||||
import type { AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
|
||||
|
||||
/** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */
|
||||
export function notifyConversationChanged(payload?: { deletedConvId?: string }): void {
|
||||
@@ -64,7 +64,7 @@ let _messagesGetter: (() => AiMessage[]) | null = null
|
||||
|
||||
/**
|
||||
* @internal 由 stores/ai.ts 在 state 创建后调用,注入 getter 函数。
|
||||
* 本模块内 findToolCall / startApprovalTimer 通过 getMessages() 访问。
|
||||
* 本模块内 findToolCall 通过 getMessages() 访问。
|
||||
* 用 getter 而非缓存数组引用,确保 switchConversation 等整体替换 messages 后仍可读到最新数组。
|
||||
*/
|
||||
export function __bindMessages(getter: (() => AiMessage[]) | AiMessage[]): void {
|
||||
@@ -114,98 +114,45 @@ export function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
|
||||
// ── 审批超时到期时刻(原前端 auto-reject 计时器,2026-10-25 退役为后端单一真值) ──
|
||||
//
|
||||
// 超时收敛单一真值 = 后端(arm_approval_timeout_timers 到点主动取消 + cleanup_expired_approvals
|
||||
// 惰性兜底)。前端本地 setTimeout 自动 ai_approve(false) 定时器已退役:双定时器并存存在
|
||||
// 口径漂移(前端 Settings 阈值 vs 后端 approval_timeout_minutes 各算各的,先后到点互相打架),
|
||||
// 且后台会话超时串视图问题随自动拒绝一并消失。本 Map 仅存后端透传的到期时刻,供倒计时 UI 读取。
|
||||
|
||||
/// 审批超时阈值来源的 appSettings key(运行期可调,默认 15min)。
|
||||
/// 值语义:正整数=毫秒超时;0=不限时(跳过计时器);缺省=900000(15min)。
|
||||
const APPROVAL_TIMEOUT_KEY = 'df-approval-timeout'
|
||||
const APPROVAL_TIMEOUT_DEFAULT_MS = 900_000 // 15 分钟
|
||||
/** toolCallId → 后端透传的审批到期时刻(ms Unix 时间戳,AiApprovalRequired.expires_at_millis) */
|
||||
const _approvalExpiresAt = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 读取当前审批超时阈值(ms):从 appSettings 取 `df-approval-timeout`,
|
||||
* 缺省 900000(15min),返回 0 表示用户配置为「不限时」(调用方应跳过计时器)。
|
||||
*
|
||||
* 每次 startApprovalTimer 调用时取最新值 —— 用户在 Settings 页调整后,下一笔审批即生效
|
||||
* (已在跑的计时器沿用旧值,符合「不影响已发起审批」的预期)。
|
||||
* 登记审批到期时刻(幂等:同 id 重复登记不覆盖)。
|
||||
* @param expiresAtMs 后端透传的到期时刻;null=超时禁用(approval_timeout_minutes=0)或不可得,
|
||||
* 不登记(倒计时回退挂起时长展示)。
|
||||
*/
|
||||
function getApprovalTimeoutMs(): number {
|
||||
const appSettings = useAppSettingsStore()
|
||||
const raw = appSettings.get<number>(APPROVAL_TIMEOUT_KEY, APPROVAL_TIMEOUT_DEFAULT_MS)
|
||||
// 防御:负数 / NaN 视为默认值;0 保留(语义=不限时)
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
|
||||
return APPROVAL_TIMEOUT_DEFAULT_MS
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** toolCallId → 审批超时计时器 + 起点信息(startedAt/timeoutMs 供前端到期倒计时读剩余) */
|
||||
interface ApprovalTimerInfo {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
/** 计时器启动时刻(ms)——即本笔审批超时窗口的真实起点 */
|
||||
startedAt: number
|
||||
/** 本计时器生效的超时阈值(ms) */
|
||||
timeoutMs: number
|
||||
}
|
||||
const _approvalTimers = new Map<string, ApprovalTimerInfo>()
|
||||
|
||||
/**
|
||||
* 启动审批超时计时器(幂等:同 id 重复启动不重建)。
|
||||
* 到点回调按 kind 分派拒绝路径 + push 系统错误消息:
|
||||
* - kind='risk'(risk 类审批,如 write_file):调 aiApi.approve(id, false) → 后端 ai_approve(kind==Risk)。
|
||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(id, 'deny') → 后端 ai_authorize_dir 续 loop 返 Err。
|
||||
* 修复回归:path 类若错调 ai_approve,后端 ai_approve 校验 kind==Risk 直接拒,IPC 不进续 loop → 卡死。
|
||||
* memory: aiShared.startApprovalTimer 回调按 kind 分派(risk→approve / path→authorizeDir('deny')),
|
||||
* path 类错误复用 risk 的 ai_approve 致后端 kind 校验拒、续 loop 不触发 → 卡死。
|
||||
*/
|
||||
export function startApprovalTimer(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
kind: 'risk' | 'path' = 'risk',
|
||||
): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
// 0 = 用户配置为「不限时」,跳过计时器(对齐旧 Infinity 行为)
|
||||
const timeoutMs = getApprovalTimeoutMs()
|
||||
if (timeoutMs === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
// 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死
|
||||
const denyP = kind === 'path'
|
||||
? aiApi.authorizeDir(toolCallId, 'deny')
|
||||
: aiApi.approve(toolCallId, false)
|
||||
denyP.catch(e => {
|
||||
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
|
||||
})
|
||||
// UX-P2-A3:气泡推送改经 per-conv 守卫(函数追加在本文件尾部),后台会话超时不串当前视图
|
||||
pushApprovalTimeoutBubble(toolName, toolCallId)
|
||||
}, timeoutMs)
|
||||
_approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs })
|
||||
export function startApprovalTimer(toolCallId: string, expiresAtMs: number | null): void {
|
||||
if (expiresAtMs === null) return
|
||||
if (!_approvalExpiresAt.has(toolCallId)) _approvalExpiresAt.set(toolCallId, expiresAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某审批距前端自动拒绝的剩余毫秒。
|
||||
* 审批超时是前端本计时器驱动的(到点才调 IPC 拒绝),故剩余值与实际拒绝时刻精确对齐,
|
||||
* 优于后端 created_at(不序列化透传)或 ToolCard 本地到达时间的近似。
|
||||
* 返回 null = 无活跃计时器(未挂起/已到期/用户配置不限时),调用方应回退其他展示。
|
||||
* 取某审批距后端超时收敛的剩余毫秒。
|
||||
* 到期时刻为后端 armed 时刻透传,与后端定时任务到点取消精确对齐。
|
||||
* 返回 null = 无到期记录(超时禁用/未挂起/已清),调用方回退挂起时长展示;过期后返回负数。
|
||||
*/
|
||||
export function getApprovalRemainingMs(toolCallId: string): number | null {
|
||||
const info = _approvalTimers.get(toolCallId)
|
||||
if (!info) return null
|
||||
return info.timeoutMs - (Date.now() - info.startedAt)
|
||||
const expiresAt = _approvalExpiresAt.get(toolCallId)
|
||||
if (expiresAt === undefined) return null
|
||||
return expiresAt - Date.now()
|
||||
}
|
||||
|
||||
/** 清除单个审批计时器 */
|
||||
/** 清除单个审批到期记录 */
|
||||
export function clearApprovalTimer(toolCallId: string): void {
|
||||
const info = _approvalTimers.get(toolCallId)
|
||||
if (info) {
|
||||
clearTimeout(info.timer)
|
||||
_approvalTimers.delete(toolCallId)
|
||||
}
|
||||
_approvalExpiresAt.delete(toolCallId)
|
||||
}
|
||||
|
||||
/** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */
|
||||
/** 清除全部审批到期记录(整流超时收尾/组件卸载/切会话时调用) */
|
||||
export function clearAllApprovalTimers(): void {
|
||||
for (const info of _approvalTimers.values()) clearTimeout(info.timer)
|
||||
_approvalTimers.clear()
|
||||
_approvalExpiresAt.clear()
|
||||
}
|
||||
|
||||
// ── #14 任务式委托:per-conv 轮次追踪 convRounds(#14 桌面任务卡数据源) ──
|
||||
@@ -772,33 +719,8 @@ export async function backfillConvAgentFinished(convId: string | null | undefine
|
||||
}
|
||||
}
|
||||
|
||||
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
|
||||
//
|
||||
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
|
||||
// (当前活跃视图),后台会话 A 的审批超时,气泡落在用户正查看的会话 B 里(跨会话串扰)。
|
||||
// 本守卫按审批所属 conv 过滤:仅当审批所属会话是当前活跃会话(或无法归属)才推视图气泡;
|
||||
// 后台会话的超时拒绝 IPC 照常执行,只是不串视图。
|
||||
// 归属反查:到点时刻从 per-conv 待审批集合(convPendingApprovals)按 toolCallId 反查 convId——
|
||||
// 此时拒绝 IPC 才刚发出,审批结果事件未到,集合项必在;查不到(旧数据/已被清)保守回落为推,
|
||||
// 不改变无归属场景的原行为。
|
||||
export function pushApprovalTimeoutBubble(toolName: string, toolCallId: string): void {
|
||||
let convId: string | null = null
|
||||
for (const [k, arr] of convPendingApprovals) {
|
||||
if (arr.some(p => p.id === toolCallId)) {
|
||||
convId = k
|
||||
break
|
||||
}
|
||||
}
|
||||
const active = getState()?.activeConversationId ?? null
|
||||
if (convId && convId !== active) return
|
||||
getMessages().push({
|
||||
id: `approval-timeout-${nextMsgId()}` as MessageId,
|
||||
role: 'assistant',
|
||||
content: t('ai.approvalTimeout', { toolName }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
// ── UX-P2-A3 审批超时错误气泡已随前端 auto-reject 定时器一并退役(2026-10-25) ──
|
||||
// 超时终态由后端定时任务 emit AiApprovalResult 收敛,不再注入本地错误气泡。
|
||||
|
||||
/** 队列被清空的统一提示(UX-P2-A2:AiError/regenerate/editMessage 清队从静默改 toast,
|
||||
* 清 0 条不提示;经 ai-toast 事件总线,AiChat 本地 toast 消费) */
|
||||
|
||||
@@ -594,7 +594,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
if (_switchBufferedText) state.currentText = _switchBufferedText
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
|
||||
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
|
||||
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
|
||||
// 恢复后置 pending_approval 态(path_auth 双 kind 已退役,2026-10-25)。
|
||||
// F2-1纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
|
||||
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
|
||||
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
|
||||
@@ -607,8 +607,6 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
if (pending.length) {
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
|
||||
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||
const restored: AiToolCallInfo[] = []
|
||||
for (const m of state.messages) {
|
||||
@@ -616,31 +614,17 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
if (pendingIds.has(tc.id)) {
|
||||
tc.status = 'pending_approval'
|
||||
bumpToolEpoch()
|
||||
const kind = pendingKindMap.get(tc.id) ?? 'risk'
|
||||
tc.kind = kind
|
||||
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
|
||||
// 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。
|
||||
if (kind === 'path') {
|
||||
const p = (tc.args as { path?: string } | null)?.path
|
||||
tc.path = p
|
||||
tc.dir = p ?? undefined
|
||||
tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' })
|
||||
}
|
||||
restored.push({
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
status: 'pending_approval',
|
||||
kind,
|
||||
path: tc.path,
|
||||
dir: tc.dir,
|
||||
reason: tc.reason,
|
||||
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
|
||||
conversationId: id,
|
||||
})
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
|
||||
startApprovalTimer(tc.id, tc.name, kind)
|
||||
// 登记后端透传的到期时刻(保持与 AiApprovalRequired 事件处理对称;超时收敛单一真值=后端)
|
||||
const exp = pending.find(p => p.tool_call_id === tc.id)?.expires_at_millis
|
||||
startApprovalTimer(tc.id, typeof exp === 'number' ? exp : null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! - useAiStreamingEvents.handleStreamingEvent(流式)
|
||||
//! - useAiToolEvents.handleToolEvent(工具)
|
||||
//! - useAiLifecycleEvents.handleLifecycleEvent(生命周期)
|
||||
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingDirAuths/pendingHelp、工具慢执行
|
||||
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingHelp、工具慢执行
|
||||
//! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到
|
||||
//! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。
|
||||
//!
|
||||
@@ -46,9 +46,9 @@ let _leaseCount = 0
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/求助/停靠由各自 case 内 clear)。
|
||||
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired', 'AiTaskParked'])
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiHelpRequired', 'AiTaskParked'])
|
||||
|
||||
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
|
||||
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
|
||||
@@ -57,7 +57,7 @@ const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
|
||||
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
|
||||
* 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */
|
||||
function isGlobalApprovalEvent(event: AiChatEvent): boolean {
|
||||
return event.type === 'AiApprovalRequired' || event.type === 'AiDirAuthRequired'
|
||||
return event.type === 'AiApprovalRequired'
|
||||
}
|
||||
|
||||
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
|
||||
|
||||
@@ -11,7 +11,7 @@ import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { aiApi } from '@/api/ai'
|
||||
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||
@@ -129,8 +129,6 @@ async function cleanupTerminatedConversation(convId: string, reason: 'AiComplete
|
||||
pendingTaskParked.delete(convId)
|
||||
// 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建)
|
||||
pendingTaskProgress.delete(convId)
|
||||
// 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
|
||||
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
|
||||
// conversationId 缺失的旧项无法归属,保守保留防误清正在审批的卡)
|
||||
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! AI 待决/流式信号共享态(叶子)—— textIdle / pendingMaxRounds / pendingDirAuths / pendingHelp
|
||||
//! AI 待决/流式信号共享态(叶子)—— textIdle / pendingMaxRounds / pendingHelp
|
||||
//! 及各自的定时器与 push/remove。
|
||||
//!
|
||||
//! 叶子定位:仅被导入,不导入任何子 composable(依赖仅 vue + appSettings),故无环风险。
|
||||
//! 叶子定位:仅被导入,不导入任何子 composable(依赖仅 vue),故无环风险。
|
||||
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
|
||||
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
|
||||
//! HelpRequiredCard)统一从这里取这些跨域信号。
|
||||
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 文本空闲信号:streaming=true 且 300ms 无新 delta 时=true,按钮据此显「发送」而非「停止」。
|
||||
// 不动 streaming(streaming 是渲染态,控制 MessageList 流式 block 显隐,不能中途翻转)。
|
||||
@@ -35,37 +32,6 @@ export function clearTextIdleTimer(): void {
|
||||
// 消费方(MaxRoundsCard)按 pendingMaxRounds === activeConversationId 精确比对,多会话不串台。
|
||||
export const pendingMaxRounds = ref<string | null>(null)
|
||||
|
||||
// 路径授权挂起(后端 AiDirAuthRequired 置,用户决策后清)。数组 push 多条并存:
|
||||
// 同轮多个文件工具落不同未授权目录时,后端连发多条,单 ref 会被后到覆盖致弹窗丢失。
|
||||
export interface PendingDirAuth {
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversationId?: string
|
||||
}
|
||||
export const pendingDirAuths = ref<PendingDirAuth[]>([])
|
||||
|
||||
// 多挂起并存开关(appSettings key `df-ai-multi-pending-auth`):开=数组 push 多条;
|
||||
// 关=单 ref 覆盖(后到覆盖先到,行为对齐旧版)。
|
||||
function isMultiPendingAuth(): boolean {
|
||||
return appSettings.get<boolean>('df-ai-multi-pending-auth', true)
|
||||
}
|
||||
|
||||
/** 向 pendingDirAuths 追加一条(开关控制多挂起并存 vs 单 ref 覆盖) */
|
||||
export function pushPendingDirAuth(p: PendingDirAuth): void {
|
||||
if (isMultiPendingAuth()) {
|
||||
pendingDirAuths.value.push(p)
|
||||
} else {
|
||||
pendingDirAuths.value = [p]
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 id 从 pendingDirAuths 移除一条 */
|
||||
export function removePendingDirAuth(id: string): void {
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// 求助协议挂起(agent 断路器熔断时后端 AiHelpRequired 置):显 reason + options 驱动求助卡。
|
||||
// 存挂起 convId,消费方按 activeConversationId 精确比对(多会话并发不串台)。
|
||||
export interface PendingHelp {
|
||||
@@ -78,7 +44,7 @@ export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
// 无人值守停靠挂起(后端 AiTaskParked 置):convId 键控 Map 而非单 ref。
|
||||
// 与 pendingHelp 单 ref 的差异原因:停靠是无人值守长任务的常态终局,多会话并发下
|
||||
// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批 pendingDirAuths 同款教训)。
|
||||
// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批卡多挂起并存同款教训)。
|
||||
// 消费方(TaskParkedCard)按 activeConversationId 取卡,per-conversation 归位不串台。
|
||||
// 幂等:同会话重复停靠按键覆盖(后端单出口单发,覆盖仅防监听重放双卡)。
|
||||
export interface PendingTaskParked {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
//! 流式事件域 — handleStreamingEvent
|
||||
//!
|
||||
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiDirAuthRequired。
|
||||
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry。
|
||||
//! AiMaxRoundsReached 不处理:后端达 max 已改 AiCompleted(incomplete=true) 自动完成(暂停态
|
||||
//! 语义已废),该事件后端零 emit;pendingMaxRounds/MaxRoundsCard 续停操作卡为遗留死链待清理。
|
||||
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
|
||||
//! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。
|
||||
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound, getConvStreamState } from './aiShared'
|
||||
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
|
||||
import { clearStreamWatchdog, resetStreamWatchdog } from './useAiStream'
|
||||
import { nextMsgId, getLastDelta, setLastDelta, flushCurrentText, setConvRound, getConvStreamState } from './aiShared'
|
||||
import { resetTextIdleTimer, clearTextIdleTimer } from './useAiPendingState'
|
||||
import { resetStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { aiApi } from '@/api/ai'
|
||||
import { shouldSelfHealStreaming } from './streamSelfHeal'
|
||||
@@ -20,12 +19,6 @@ import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// path_auth 统一审批模型开关(appSettings key `df-ai-unified-approval`):开=path 挂起归一进
|
||||
// pendingApprovals(kind='path',ToolCard 内联审批);关=走 pendingDirAuths 独立弹窗(DirAuthDialog)。
|
||||
function isUnifiedApproval(): boolean {
|
||||
return appSettings.get<boolean>('df-ai-unified-approval', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳自愈(火后不理,看板 949 兜底):前端流式态丢失而后端仍在生成时恢复 streaming + 重启看门狗。
|
||||
*
|
||||
@@ -131,54 +124,6 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearToolSlowTimer(event.id)
|
||||
// 统一审批模型开:path 挂起归一进待审批集合(kind='path'),ToolCard 内联审批。
|
||||
// per-conv 归会话追加(同 id 幂等):跨会话事件互不覆盖;首次到达才做工具卡改写等一次性副作用。
|
||||
if (isUnifiedApproval()) {
|
||||
const added = addPendingApproval({
|
||||
id: event.id,
|
||||
name: event.tool,
|
||||
args: { path: event.path },
|
||||
status: 'pending_approval',
|
||||
kind: 'path',
|
||||
dir: event.dir,
|
||||
path: event.path,
|
||||
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
|
||||
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
|
||||
})
|
||||
if (added) {
|
||||
// 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'pending_approval'
|
||||
tc.kind = 'path'
|
||||
tc.dir = event.dir
|
||||
tc.path = event.path
|
||||
tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path })
|
||||
}
|
||||
// path 类挂起同样启动审批超时计时器(到点回调调 authorizeDir(id,'deny'))
|
||||
startApprovalTimer(event.id, event.tool, 'path')
|
||||
}
|
||||
// 通知 ToolCardList 自动展开折叠组 + scroll(挂起卡落折叠分组 display:none 用户不可见)
|
||||
void emit('ai-pending-arrived', { toolCallId: event.id })
|
||||
return true
|
||||
}
|
||||
// 统一审批模型关(兜底):走 DirAuthDialog 独立弹窗(push pendingDirAuths,开关控多挂起并存)
|
||||
if (!pendingDirAuths.value.some(p => p.id === event.id)) {
|
||||
pushPendingDirAuth({
|
||||
id: event.id,
|
||||
tool: event.tool,
|
||||
path: event.path,
|
||||
dir: event.dir,
|
||||
conversationId: event.conversation_id,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { emit } from '@tauri-apps/api/event'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById, bumpToolEpoch } from './aiShared'
|
||||
import { removePendingDirAuth } from './useAiPendingState'
|
||||
import { clearStreamWatchdog } from './useAiStream'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -92,8 +91,7 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
kind: event.kind ?? 'risk',
|
||||
// reason 必须写入 info,否则浮窗 ApprovalPopup 的 tc.reason 永远空(审批说明丢失)
|
||||
// reason 必须写入 info,否则 ToolCard/浮层的 tc.reason 永远空(审批说明丢失)
|
||||
reason: event.reason,
|
||||
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
|
||||
}
|
||||
@@ -106,9 +104,10 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
// write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览
|
||||
if (event.diff) tc.diff = event.diff
|
||||
}
|
||||
// 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),再启动审批超时计时器
|
||||
// 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),
|
||||
// 再登记后端透传的到期时刻(超时收敛单一真值=后端,前端无本地自动拒绝)
|
||||
clearToolSlowTimer(event.id)
|
||||
startApprovalTimer(event.id, event.name, 'risk')
|
||||
startApprovalTimer(event.id, event.expires_at_millis ?? null)
|
||||
// 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时)
|
||||
void emit('ai-pending-arrived', { toolCallId: event.id })
|
||||
}
|
||||
@@ -134,8 +133,6 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
startToolSlowTimer(event.id, tc?.name || '')
|
||||
clearApprovalTimer(event.id)
|
||||
}
|
||||
// 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条)
|
||||
removePendingDirAuth(event.id)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
*
|
||||
* 两层导出:
|
||||
* - `toolDisplayName(tc)` 模块级纯函数(i18n 走 i18n-helpers 全局 t,project store 走单例):
|
||||
* 无 setup 上下文依赖,可被 ToolCard(经 composable 包装)与 ApprovalPopup(独立 WebviewWindow,
|
||||
* 无 composable 上下文)共用。抽离自原 composable 内部函数,消除 ApprovalPopup 的 _placeholder hack。
|
||||
* 无 setup 上下文依赖,ToolCard 与其他无 composable 上下文的调用方均可共用。
|
||||
* - `useToolCardHeader(getTc)` composable:封装 displayArgValue/argsEntries(依赖 getTc 取当前 tc,
|
||||
* 响应式由 store 保证),返回 { toolDisplayName(转发模块级), displayArgValue, argsEntries }。
|
||||
*
|
||||
@@ -68,8 +67,7 @@ function taskNameById(id: string): string | undefined {
|
||||
* 工具显示名称(含目标摘要);文件类按 path,项目类按 id 解析项目名,create_* 用固定文案。
|
||||
*
|
||||
* 模块级纯函数:无 setup 依赖(i18n 走 i18n-helpers 的全局 t,project store 走单例),
|
||||
* 故可被 ToolCard(经 useToolCardHeader 包装)与 ApprovalPopup(独立 WebviewWindow,无 store/emit
|
||||
* 上下文)共用,消除 ApprovalPopup 原 `_placeholder` 占位 hack(构造空 AiToolCallInfo 喂 composable)。
|
||||
* 故可被 ToolCard(经 useToolCardHeader 包装)及其他无 store/emit 上下文的调用方共用。
|
||||
*/
|
||||
export function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
const p = argString(tc.args, 'path')
|
||||
|
||||
@@ -54,7 +54,6 @@ export default {
|
||||
queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.',
|
||||
// Success toast when a runtime supplement is merged into the in-flight answer (u-claw semantics: no new round)
|
||||
injectMerged: 'Merged into the current answer; takes effect next round.',
|
||||
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
|
||||
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
|
||||
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
|
||||
switchConvFail: 'Failed to switch conversation. Please retry.',
|
||||
|
||||
@@ -81,13 +81,6 @@ export default {
|
||||
approvalWaitingTitle: 'Approval required',
|
||||
approvalWaitingDesc: 'Click to review and handle',
|
||||
|
||||
// ── System-level approval popup (standalone window, visible when app unfocused) ──
|
||||
popupTitle: 'Approval pending',
|
||||
popupTagPath: 'Path auth',
|
||||
popupTagRisk: 'Risk action',
|
||||
popupOpenPanel: 'Open approval panel',
|
||||
// 空态文案:正常空态会被 maybeAutoClose 即时关窗,此 hint 仅 close 失败时兜底
|
||||
popupEmptyHint: 'Closing…',
|
||||
|
||||
// ── Provider status ──
|
||||
clickToSwitchProvider: 'Click to switch provider',
|
||||
@@ -240,19 +233,6 @@ export default {
|
||||
// ── 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}',
|
||||
dirAuthOnce: 'This time',
|
||||
dirAuthSession: 'This session',
|
||||
dirAuthAlways: 'Always',
|
||||
dirAuthDeny: 'Deny',
|
||||
dirAuthFailed: 'Authorization failed: {msg}',
|
||||
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
|
||||
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
|
||||
// Persistent bypass banner shown at the top of the chat area while "trust all directories" is on
|
||||
pathAuthBypassBanner: 'Authorization fully bypassed (including sensitive directories): AI can access any file directly',
|
||||
|
||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||
searchFilesBlocked: "search_files only works inside authorized dirs. Use {'@'}[project] to reference or provide an absolute path.",
|
||||
|
||||
@@ -12,13 +12,10 @@ export default {
|
||||
// args folding (multi-arg default shows first, "N more" expands)
|
||||
argsMore: '{n} more',
|
||||
argsCollapse: 'Collapse args',
|
||||
// batch approve high-risk interception (count badge + confirm text)
|
||||
batchHighRiskHint: '{n} high-risk',
|
||||
// R7 group-header failure badge: folded group contains failed tool cards
|
||||
groupFailCount: '{n} failed',
|
||||
// #14 batch approve partial failure toast
|
||||
batchPartialFail: 'Batch done: {ok} approved, {fail} failed.',
|
||||
batchHighRiskConfirm: 'Batch approval includes {n} high-risk operations (e.g. delete/command execution/network requests). Approve all?',
|
||||
rejectedHint: 'User rejected this action',
|
||||
// tool execution slow hint (frontend fallback, no backend cancel IPC)
|
||||
executionSlow: 'Tool "{name}" is taking longer than expected, please wait…',
|
||||
@@ -165,11 +162,6 @@ export default {
|
||||
taskLabel: '"{name}"',
|
||||
taskIdNotFound: '(task not in current list, id={id})',
|
||||
|
||||
// High-risk approval second confirm (ask once more before approve; misclick cancellable)
|
||||
confirmHighDelete: 'Permanently delete? This action cannot be undone',
|
||||
confirmHighExec: 'Run this command?',
|
||||
confirmHighGeneric: 'Proceed with this high-risk action?',
|
||||
|
||||
// Tool result detail format (plain-text result + approval placeholder appends arg summary)
|
||||
resultDetail: '{raw} ({detail})',
|
||||
|
||||
@@ -199,9 +191,6 @@ export default {
|
||||
// tool failure detection copy (regex narrowed to line-anchored err_msg prefix)
|
||||
// (reuse executionFailed/executionFailedHint, no new key)
|
||||
|
||||
// http_request High-risk confirm: external network call, data may leak via URL/body
|
||||
confirmHighHttp: 'Send this HTTP request to an external server?',
|
||||
|
||||
// Approval argument key labels
|
||||
argLabel: {
|
||||
id: 'ID',
|
||||
|
||||
@@ -54,7 +54,6 @@ export default {
|
||||
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
|
||||
// 运行中补充注入成功(u-claw 语义:纯文本补充并进当前回答,不开新轮)的 toast 提示
|
||||
injectMerged: '已并入当前回答,下一轮生效',
|
||||
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
|
||||
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
||||
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
||||
switchConvFail: '对话切换失败,请重试',
|
||||
|
||||
@@ -81,13 +81,6 @@ export default {
|
||||
approvalWaitingTitle: '有待审批请求',
|
||||
approvalWaitingDesc: '点击查看并处理',
|
||||
|
||||
// ── 系统级审批浮窗(独立窗口,应用失焦时可见)──
|
||||
popupTitle: '审批待办',
|
||||
popupTagPath: '路径授权',
|
||||
popupTagRisk: '风险操作',
|
||||
popupOpenPanel: '打开审批面板',
|
||||
// 空态文案:正常空态会被 maybeAutoClose 即时关窗,此 hint 仅 close 失败时兜底
|
||||
popupEmptyHint: '正在关闭…',
|
||||
|
||||
// ── Provider 状态 ──
|
||||
clickToSwitchProvider: '点击切换 Provider',
|
||||
@@ -241,19 +234,6 @@ export default {
|
||||
// ── L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断求助卡 ──
|
||||
helpRequiredAck: '已记录选择,请重新描述需求以换思路/继续',
|
||||
|
||||
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
||||
dirAuthRequired: '需要路径授权',
|
||||
dirAuthHint: '{tool} 想访问:{path}',
|
||||
dirAuthOnce: '本次',
|
||||
dirAuthSession: '当前会话',
|
||||
dirAuthAlways: '始终放行',
|
||||
dirAuthDeny: '拒绝',
|
||||
dirAuthFailed: '授权失败:{msg}',
|
||||
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
|
||||
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
|
||||
// 「信任所有目录」总开关开启期间的常驻放行横幅(对话区顶部)
|
||||
pathAuthBypassBanner: '授权已完全放行(含敏感目录):AI 可直接访问任意文件',
|
||||
|
||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
|
||||
|
||||
@@ -12,13 +12,10 @@ export default {
|
||||
// args 折叠(多参数默认显首条,「更多 N 项」展开)
|
||||
argsMore: '更多 {n} 项',
|
||||
argsCollapse: '收起参数',
|
||||
// 批量批准 High 风险拦截(计数徽标 + 二次确认文案)
|
||||
batchHighRiskHint: '含 {n} 项高风险',
|
||||
// R7 组头失败徽标:折叠组内含失败工具卡
|
||||
groupFailCount: '{n} 失败',
|
||||
// #14 批量审批部分失败提示
|
||||
batchPartialFail: '批量完成:{ok} 项成功,{fail} 项失败',
|
||||
batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?',
|
||||
rejectedHint: '用户拒绝了此操作',
|
||||
// 工具执行超时提示(前端降级,无后端取消 IPC)
|
||||
executionSlow: '工具「{name}」执行较久,请稍候…',
|
||||
@@ -167,13 +164,6 @@ export default {
|
||||
taskLabel: '「{name}」',
|
||||
taskIdNotFound: '(任务不在当前列表, id={id})',
|
||||
|
||||
// High 风险审批二次确认文案(批准前再问一次,误点可取消)
|
||||
confirmHighDelete: '确定永久删除?此操作不可恢复',
|
||||
confirmHighExec: '确定执行此命令?',
|
||||
confirmHighGeneric: '确定执行此高危操作?',
|
||||
// http_request 高危二次确认:外发网络请求,数据可经 URL/body 泄露
|
||||
confirmHighHttp: '确定向外部服务器发起此 HTTP 请求?',
|
||||
|
||||
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
|
||||
resultDetail: '{raw}({detail})',
|
||||
|
||||
|
||||
+3
-4
@@ -18,8 +18,7 @@ app.config.errorHandler = (err, instance, info) => {
|
||||
|
||||
app.use(router).use(i18n);
|
||||
|
||||
// 等路由就绪再 mount:分离窗口(approval-popup / ai-detached / fe-detached)的 hash 路由须先解析完,
|
||||
// App.vue 的 isDetached(route.path === '/approval-popup' ...)才命中真值。否则初始帧 route.path='/'
|
||||
// → isDetached=false → AppLayout 把完整主布局(侧栏+主区+AI 面板)渲染进分离小窗
|
||||
// (approval-popup 380×480 尤为明显,看起来像「小小的完整 DevFlow 界面」)。
|
||||
// 等路由就绪再 mount:分离窗口(ai-detached / fe-detached)的 hash 路由须先解析完,
|
||||
// App.vue 的 isDetached(route.path === '/ai-detached' ...)才命中真值。否则初始帧 route.path='/'
|
||||
// → isDetached=false → AppLayout 把完整主布局(侧栏+主区+AI 面板)渲染进分离小窗。
|
||||
router.isReady().then(() => app.mount("#app"));
|
||||
|
||||
@@ -241,7 +241,6 @@ const LIST_COMMANDS = new Set([
|
||||
'knowledge_list',
|
||||
'knowledge_list_candidates',
|
||||
'knowledge_events',
|
||||
'ai_get_allowed_dirs',
|
||||
'ai_pending_tool_calls',
|
||||
'ai_list_tools',
|
||||
])
|
||||
|
||||
@@ -92,13 +92,6 @@ const routes = [
|
||||
component: () => import('../views/FileExplorerDetached.vue'),
|
||||
meta: { title: '文件浏览器' },
|
||||
},
|
||||
{
|
||||
// 系统级审批提示浮窗(独立 WebviewWindow 内容):无边框 + alwaysOnTop + skipTaskbar
|
||||
path: '/approval-popup',
|
||||
name: 'ApprovalPopup',
|
||||
component: () => import('../views/ApprovalPopup.vue'),
|
||||
meta: { title: '审批待办' },
|
||||
},
|
||||
// catch-all:无效路由重定向首页(防白屏)
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
|
||||
@@ -207,7 +207,7 @@ html, body, #app {
|
||||
/* ═══ 2026-10-18 整洁性波:跨文件共享样式抽取(共享才抽取原则) ═══
|
||||
来源: dup-style-scan.json(49 条跨文件重复规则),按文件族消费波替换引用 */
|
||||
|
||||
/* — Toast 族(双轨合一:AiChat/ApprovalPopup 的 .ai-toast-* 与 Knowledge/Settings 的 .toast-*) — */
|
||||
/* — Toast 族(双轨合一:AiChat 的 .ai-toast-* 与 Knowledge/Settings 的 .toast-*) — */
|
||||
.df-toast {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
padding: 8px 16px; border-radius: var(--df-radius); font-size: var(--df-fs-ctrl);
|
||||
@@ -226,7 +226,7 @@ html, body, #app {
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes df-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* — 确认弹层按钮行(ConfirmDialog/AdvancedSection/AllowedDirsPanel 共享) — */
|
||||
/* — 确认弹层按钮行(ConfirmDialog/AdvancedSection 共享) — */
|
||||
.df-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.df-confirm-enter-active, .df-confirm-leave-active { transition: opacity 0.15s; }
|
||||
.df-confirm-enter-from, .df-confirm-leave-to { opacity: 0; }
|
||||
@@ -346,7 +346,7 @@ html, body, #app {
|
||||
.btn-accent { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success-bd); }
|
||||
.btn-accent:hover { background: rgba(61,219,160,0.22); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
/* 2026-10-18 整洁波收敛:实底危险钮(AdvancedSection/AllowedDirsPanel 两处重复抽取;
|
||||
/* 2026-10-18 整洁波收敛:实底危险钮(AdvancedSection 重复抽取;
|
||||
Projects 页描边款 btn-danger 属本页特有,保留其本地覆盖) */
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ export function formatDuration(ms: number | undefined | null, precise = false):
|
||||
return `${hour}h ${min % 60}m`
|
||||
}
|
||||
|
||||
// ── 审批等待时长(ToolCard / ApprovalPopup 双写收敛) ──
|
||||
// ── 审批等待时长 ──
|
||||
|
||||
export type WaitLevel = 'normal' | 'long' | 'urgent'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user