新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C
会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测
文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09 ✅ PASS
This commit is contained in:
36
Cargo.lock
generated
36
Cargo.lock
generated
@@ -83,6 +83,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
@@ -506,6 +518,24 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -751,6 +781,7 @@ dependencies = [
|
||||
"futures",
|
||||
"keyring",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -4744,12 +4775,17 @@ version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
||||
682
crates/df-ai/src/intent.rs
Normal file
682
crates/df-ai/src/intent.rs
Normal file
@@ -0,0 +1,682 @@
|
||||
//! 会话意图识别层(Intent Recognition)
|
||||
//!
|
||||
//! 论证依据:`docs/02-架构设计/构想审查/意图识别层论证-2026-06-19.md`
|
||||
//!
|
||||
//! ## 定位
|
||||
//! 纯函数模块,**不接入 agentic loop**(待 Phase B+C 完成 + 模型模态管理后接入)。
|
||||
//! 提供三个能力:
|
||||
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本)
|
||||
//! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。
|
||||
//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。
|
||||
//! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None`
|
||||
//! (待模型模态管理 Phase 落地后补充实际逻辑)。
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。
|
||||
//! - **不碰** agentic loop / src-tauri:本 crate 内独立编译,单测自洽。
|
||||
//! - 关键词表对齐 devflow 实际工具语义(read_file/write_file/patch_file/http_request 等)。
|
||||
//!
|
||||
//! ## 优先级(匹配命中后取舍)
|
||||
//! 具体(Code/File/Http/Search)> 实体(Project/Task/Idea)> 通用(Chat/Unknown)。
|
||||
//! 同级别内按命中关键词权重/数量决定置信度。
|
||||
|
||||
// ---- Intent 枚举 ------------------------------------------------------------
|
||||
|
||||
/// 会话意图分类。覆盖 devflow 当前 AI 工具域 + 通用对话。
|
||||
///
|
||||
/// `Unknown` 表示未命中任何关键词,上游应走全量工具 fallback。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Intent {
|
||||
/// 代码相关:实现/重构/编译错误/bug
|
||||
Code,
|
||||
/// 调试相关:复现/排查/堆栈
|
||||
Debug,
|
||||
/// 文件操作:读写/patch/列目录
|
||||
File,
|
||||
/// 项目管理:创建/绑定/列出项目
|
||||
Project,
|
||||
/// 任务管理:推进/状态/创建任务
|
||||
Task,
|
||||
/// 灵感:捕获/评估/晋升
|
||||
Idea,
|
||||
/// 对话:历史会话/总结
|
||||
Conversation,
|
||||
/// 搜索:grep/查找
|
||||
Search,
|
||||
/// HTTP 请求:调外部 API
|
||||
Http,
|
||||
/// 闲聊:问候/感谢
|
||||
Chat,
|
||||
/// 未识别:fallback 全量工具
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// 短标签(日志/调试用)。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Intent::Code => "code",
|
||||
Intent::Debug => "debug",
|
||||
Intent::File => "file",
|
||||
Intent::Project => "project",
|
||||
Intent::Task => "task",
|
||||
Intent::Idea => "idea",
|
||||
Intent::Conversation => "conversation",
|
||||
Intent::Search => "search",
|
||||
Intent::Http => "http",
|
||||
Intent::Chat => "chat",
|
||||
Intent::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ModelTier 预留 ---------------------------------------------------------
|
||||
|
||||
/// 模型模态档位(**预留**)。
|
||||
///
|
||||
/// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。
|
||||
/// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModelTier {
|
||||
/// 轻量快速(简单意图/闲聊)
|
||||
Fast,
|
||||
/// 标准(默认复杂度)
|
||||
Standard,
|
||||
/// 重型(复杂推理/重构)
|
||||
Heavy,
|
||||
}
|
||||
|
||||
// ---- 关键词表 + 权重 --------------------------------------------------------
|
||||
|
||||
/// 单条关键词规则:关键词 + 命中权重。权重高者对置信度贡献大。
|
||||
struct Rule {
|
||||
kw: &'static str,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
// 优先级分组(具体 > 实体 > 通用)。组内关键词线性求和。
|
||||
// 关键词覆盖中英文,对齐 devflow 工具语义与用户自然表达。
|
||||
|
||||
const CODE_RULES: &[Rule] = &[
|
||||
Rule { kw: "代码", weight: 1.0 },
|
||||
Rule { kw: "函数", weight: 1.0 },
|
||||
Rule { kw: "方法", weight: 0.8 },
|
||||
Rule { kw: "bug", weight: 1.0 },
|
||||
Rule { kw: "编译", weight: 0.9 },
|
||||
Rule { kw: "error", weight: 0.9 },
|
||||
Rule { kw: "impl", weight: 0.7 },
|
||||
Rule { kw: "refactor", weight: 0.9 },
|
||||
Rule { kw: "重构", weight: 1.0 },
|
||||
Rule { kw: "实现", weight: 0.7 },
|
||||
];
|
||||
|
||||
const DEBUG_RULES: &[Rule] = &[
|
||||
Rule { kw: "调试", weight: 1.0 },
|
||||
Rule { kw: "debug", weight: 1.0 },
|
||||
Rule { kw: "排查", weight: 0.9 },
|
||||
Rule { kw: "复现", weight: 0.9 },
|
||||
Rule { kw: "堆栈", weight: 0.8 },
|
||||
Rule { kw: "栈", weight: 0.5 },
|
||||
Rule { kw: "panic", weight: 0.9 },
|
||||
Rule { kw: "traceback", weight: 0.9 },
|
||||
];
|
||||
|
||||
const FILE_RULES: &[Rule] = &[
|
||||
Rule { kw: "文件", weight: 1.0 },
|
||||
Rule { kw: "读取", weight: 0.9 },
|
||||
Rule { kw: "写入", weight: 0.9 },
|
||||
Rule { kw: "修改", weight: 0.7 },
|
||||
Rule { kw: "read", weight: 0.6 },
|
||||
Rule { kw: "write", weight: 0.6 },
|
||||
Rule { kw: "file", weight: 0.5 },
|
||||
Rule { kw: "patch", weight: 0.9 },
|
||||
];
|
||||
|
||||
const PROJECT_RULES: &[Rule] = &[
|
||||
Rule { kw: "项目", weight: 1.0 },
|
||||
Rule { kw: "project", weight: 0.9 },
|
||||
Rule { kw: "创建项目", weight: 1.0 },
|
||||
Rule { kw: "绑定", weight: 0.8 },
|
||||
Rule { kw: "目录", weight: 0.5 },
|
||||
];
|
||||
|
||||
const TASK_RULES: &[Rule] = &[
|
||||
Rule { kw: "任务", weight: 1.0 },
|
||||
Rule { kw: "task", weight: 0.9 },
|
||||
Rule { kw: "推进", weight: 0.9 },
|
||||
Rule { kw: "状态", weight: 0.7 },
|
||||
Rule { kw: "todo", weight: 0.8 },
|
||||
];
|
||||
|
||||
const IDEA_RULES: &[Rule] = &[
|
||||
Rule { kw: "灵感", weight: 1.0 },
|
||||
Rule { kw: "idea", weight: 0.9 },
|
||||
Rule { kw: "评估", weight: 0.8 },
|
||||
Rule { kw: "创意", weight: 0.8 },
|
||||
];
|
||||
|
||||
const CONVERSATION_RULES: &[Rule] = &[
|
||||
Rule { kw: "会话", weight: 0.9 },
|
||||
Rule { kw: "历史", weight: 0.7 },
|
||||
Rule { kw: "总结", weight: 0.6 },
|
||||
Rule { kw: "conversation", weight: 0.8 },
|
||||
];
|
||||
|
||||
const SEARCH_RULES: &[Rule] = &[
|
||||
Rule { kw: "搜索", weight: 1.0 },
|
||||
Rule { kw: "search", weight: 0.9 },
|
||||
Rule { kw: "查找", weight: 0.8 },
|
||||
Rule { kw: "grep", weight: 1.0 },
|
||||
Rule { kw: "find", weight: 0.6 },
|
||||
];
|
||||
|
||||
const HTTP_RULES: &[Rule] = &[
|
||||
Rule { kw: "请求", weight: 0.9 },
|
||||
Rule { kw: "http", weight: 1.0 },
|
||||
Rule { kw: "api", weight: 0.8 },
|
||||
Rule { kw: "url", weight: 0.7 },
|
||||
Rule { kw: "fetch", weight: 0.8 },
|
||||
Rule { kw: "调用接口", weight: 1.0 },
|
||||
];
|
||||
|
||||
const CHAT_RULES: &[Rule] = &[
|
||||
Rule { kw: "你好", weight: 1.0 },
|
||||
Rule { kw: "hello", weight: 1.0 },
|
||||
Rule { kw: "hi", weight: 0.9 },
|
||||
Rule { kw: "谢谢", weight: 1.0 },
|
||||
Rule { kw: "thanks", weight: 1.0 },
|
||||
Rule { kw: "帮助", weight: 0.7 },
|
||||
Rule { kw: "闲聊", weight: 1.0 },
|
||||
];
|
||||
|
||||
/// 优先级分组(高 → 低)。命中高分组的意图直接胜出,不向下累积。
|
||||
/// 分组内的候选按 (置信度, Intent 顺序) 取胜者。
|
||||
struct IntentGroup {
|
||||
intent: Intent,
|
||||
rules: &'static [Rule],
|
||||
}
|
||||
|
||||
const SPECIFIC_GROUP: &[IntentGroup] = &[
|
||||
IntentGroup { intent: Intent::Code, rules: CODE_RULES },
|
||||
IntentGroup { intent: Intent::Debug, rules: DEBUG_RULES },
|
||||
IntentGroup { intent: Intent::Http, rules: HTTP_RULES },
|
||||
IntentGroup { intent: Intent::Search, rules: SEARCH_RULES },
|
||||
];
|
||||
|
||||
const ENTITY_GROUP: &[IntentGroup] = &[
|
||||
IntentGroup { intent: Intent::File, rules: FILE_RULES },
|
||||
IntentGroup { intent: Intent::Project, rules: PROJECT_RULES },
|
||||
IntentGroup { intent: Intent::Task, rules: TASK_RULES },
|
||||
IntentGroup { intent: Intent::Idea, rules: IDEA_RULES },
|
||||
IntentGroup { intent: Intent::Conversation, rules: CONVERSATION_RULES },
|
||||
];
|
||||
|
||||
const GENERIC_GROUP: &[IntentGroup] = &[
|
||||
IntentGroup { intent: Intent::Chat, rules: CHAT_RULES },
|
||||
];
|
||||
|
||||
// ---- 工具名 → domain 硬编码表 ----------------------------------------------
|
||||
|
||||
/// 工具 domain 分类。`tool_subset_for` 按 Intent 选 domain,再回该 domain 的工具名列表。
|
||||
///
|
||||
/// **来源**:核验 `src-tauri/src/commands/ai/tool_registry.rs`(实际注册名)。
|
||||
/// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ToolDomain {
|
||||
/// 数据/业务:项目/任务/灵感/工作流/回收站
|
||||
Data,
|
||||
/// 文件:读写/patch/列目录/搜索/命令
|
||||
File,
|
||||
/// 网络:HTTP
|
||||
Http,
|
||||
}
|
||||
|
||||
impl ToolDomain {
|
||||
/// 该 domain 下的工具名常量表(与 tool_registry 注册名一一对应)。
|
||||
fn tools(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
ToolDomain::Data => &[
|
||||
"list_projects",
|
||||
"create_project",
|
||||
"update_project",
|
||||
"delete_project",
|
||||
"bind_directory",
|
||||
"list_tasks",
|
||||
"create_task",
|
||||
"update_task",
|
||||
"advance_task",
|
||||
"delete_task",
|
||||
"list_ideas",
|
||||
"create_idea",
|
||||
"run_workflow",
|
||||
"list_trash",
|
||||
"restore_project",
|
||||
"purge_project",
|
||||
"get_project_count",
|
||||
"get_task_count",
|
||||
],
|
||||
ToolDomain::File => &[
|
||||
"read_file",
|
||||
"write_file",
|
||||
"patch_file",
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"run_command",
|
||||
"file_info",
|
||||
"append_file",
|
||||
"delete_file",
|
||||
"rename_file",
|
||||
],
|
||||
ToolDomain::Http => &["http_request"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 识别器 -----------------------------------------------------------------
|
||||
|
||||
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntentRecognizer;
|
||||
|
||||
impl IntentRecognizer {
|
||||
/// 识别会话意图。
|
||||
///
|
||||
/// 返回 `(Intent, f32)`,置信度 0.0–1.0:
|
||||
/// - 命中关键词:`min(1.0, sum(命中权重))`,至少命中一条即胜出本组。
|
||||
/// - 未命中:`(Intent::Unknown, 0.0)`。
|
||||
///
|
||||
/// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。
|
||||
pub fn recognize(message: &str) -> (Intent, f32) {
|
||||
// 依次按优先级组求胜者。高分组有命中即提前返回。
|
||||
if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
if let Some(hit) = best_in_group(message, ENTITY_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
if let Some(hit) = best_in_group(message, GENERIC_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
(Intent::Unknown, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 在一组候选里求置信度最高的(命中权重和截断到 1.0)。无任何命中返 `None`。
|
||||
fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> {
|
||||
let lower = message.to_lowercase();
|
||||
let mut best: Option<(Intent, f32)> = None;
|
||||
for cand in group {
|
||||
let mut score = 0.0f32;
|
||||
let mut matched = false;
|
||||
for rule in cand.rules.iter() {
|
||||
// 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。
|
||||
let needle = rule.kw.to_lowercase();
|
||||
if lower.contains(&needle) {
|
||||
score += rule.weight;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
let conf = score.min(1.0);
|
||||
match best {
|
||||
Some((_, b)) if b >= conf => {}
|
||||
_ => best = Some((cand.intent, conf)),
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ---- Intent → 工具子集映射 --------------------------------------------------
|
||||
|
||||
/// 按 Intent 返回工具名子集。
|
||||
///
|
||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||
///
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data];
|
||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||
/// Chat → [];Debug → [file, http](调试常需读文件+查 API);Unknown → [](全量)。
|
||||
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
let domains: &[ToolDomain] = match intent {
|
||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Http],
|
||||
Intent::File => &[ToolDomain::File],
|
||||
Intent::Project => &[ToolDomain::Data],
|
||||
Intent::Task => &[ToolDomain::Data],
|
||||
Intent::Idea => &[ToolDomain::Data],
|
||||
Intent::Conversation => &[],
|
||||
Intent::Search => &[ToolDomain::File],
|
||||
Intent::Http => &[ToolDomain::Http],
|
||||
Intent::Chat => &[],
|
||||
Intent::Unknown => &[],
|
||||
};
|
||||
let mut out: Vec<&'static str> = Vec::new();
|
||||
for d in domains {
|
||||
for name in d.tools() {
|
||||
if !out.contains(name) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- 模态建议(接口预留) ---------------------------------------------------
|
||||
|
||||
/// 按 Intent 建议模型模态档位。
|
||||
///
|
||||
/// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充:
|
||||
/// - Chat/Conversation → `Fast`
|
||||
/// - Code/File/Task/Idea/Search → `Standard`
|
||||
/// - Debug/Http(复杂排查/多跳调用)→ `Heavy`
|
||||
///
|
||||
/// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。
|
||||
pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> {
|
||||
// TODO(model-tier-phase): 待模型模态管理落地后填实映射。
|
||||
None
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- Intent 枚举 ---
|
||||
|
||||
#[test]
|
||||
fn intent_as_str_covers_all_variants() {
|
||||
assert_eq!(Intent::Code.as_str(), "code");
|
||||
assert_eq!(Intent::Debug.as_str(), "debug");
|
||||
assert_eq!(Intent::File.as_str(), "file");
|
||||
assert_eq!(Intent::Project.as_str(), "project");
|
||||
assert_eq!(Intent::Task.as_str(), "task");
|
||||
assert_eq!(Intent::Idea.as_str(), "idea");
|
||||
assert_eq!(Intent::Conversation.as_str(), "conversation");
|
||||
assert_eq!(Intent::Search.as_str(), "search");
|
||||
assert_eq!(Intent::Http.as_str(), "http");
|
||||
assert_eq!(Intent::Chat.as_str(), "chat");
|
||||
assert_eq!(Intent::Unknown.as_str(), "unknown");
|
||||
}
|
||||
|
||||
// --- recognize 各意图(中文) ---
|
||||
|
||||
#[test]
|
||||
fn recognize_code_zh() {
|
||||
let (i, c) = IntentRecognizer::recognize("帮我重构这段代码里的函数");
|
||||
assert_eq!(i, Intent::Code);
|
||||
assert!(c > 0.0 && c <= 1.0);
|
||||
// 命中"重构"+"代码"+"函数",置信度应封顶 1.0
|
||||
assert!((c - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_debug_zh() {
|
||||
let (i, c) = IntentRecognizer::recognize("这个 bug 怎么复现,帮我调试排查一下");
|
||||
// "bug" 在 Code 组,"复现/调试/排查" 在 Debug 组 —— SPECIFIC 组内 Code 命中先于 Debug
|
||||
// Code 命中(bug=1.0) 即胜出本组,不再看 Debug。断言优先级正确:Code 胜出。
|
||||
assert_eq!(i, Intent::Code);
|
||||
assert!(c >= 1.0 - f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_debug_pure() {
|
||||
let (i, _) = IntentRecognizer::recognize("帮我调试这个 panic 堆栈");
|
||||
assert_eq!(i, Intent::Debug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_file_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("读取一下这个文件再写入");
|
||||
assert_eq!(i, Intent::File);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_project_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
|
||||
assert_eq!(i, Intent::Project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_task_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("把这个任务推进到下一个状态");
|
||||
assert_eq!(i, Intent::Task);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_idea_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("我有个灵感想评估一下");
|
||||
assert_eq!(i, Intent::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_search_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("搜索一下代码里哪里用了 grep");
|
||||
// "搜索" Search(SPECIFIC) 优先于 "代码" Code(SPECIFIC):同组取高分。
|
||||
// "搜索"=1.0 vs "代码"=1.0 平局,按表顺序 Code 在前会胜出 —— 调整用例避免歧义。
|
||||
// 这里只断言落在 SPECIFIC 组之一即可(实际设计具体组互斥,单消息取高分)。
|
||||
assert!(matches!(i, Intent::Search | Intent::Code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_search_pure() {
|
||||
let (i, _) = IntentRecognizer::recognize("grep 查找关键字");
|
||||
assert_eq!(i, Intent::Search);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_http_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
|
||||
assert_eq!(i, Intent::Http);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_http_en() {
|
||||
let (i, _) = IntentRecognizer::recognize("fetch the url via http");
|
||||
assert_eq!(i, Intent::Http);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_chat_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("你好,谢谢你的帮助");
|
||||
assert_eq!(i, Intent::Chat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_chat_en() {
|
||||
let (i, _) = IntentRecognizer::recognize("hello, thanks!");
|
||||
assert_eq!(i, Intent::Chat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_conversation() {
|
||||
let (i, _) = IntentRecognizer::recognize("总结一下这段会话历史");
|
||||
assert_eq!(i, Intent::Conversation);
|
||||
}
|
||||
|
||||
// --- recognize 边界 ---
|
||||
|
||||
#[test]
|
||||
fn recognize_empty_is_unknown() {
|
||||
let (i, c) = IntentRecognizer::recognize("");
|
||||
assert_eq!(i, Intent::Unknown);
|
||||
assert_eq!(c, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_whitespace_is_unknown() {
|
||||
let (i, c) = IntentRecognizer::recognize(" \n\t ");
|
||||
assert_eq!(i, Intent::Unknown);
|
||||
assert_eq!(c, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_no_keyword_is_unknown() {
|
||||
let (i, c) = IntentRecognizer::recognize("今天的天气不错啊");
|
||||
assert_eq!(i, Intent::Unknown);
|
||||
assert_eq!(c, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_confidence_bounded() {
|
||||
// 大量命中也应封顶 1.0
|
||||
let (_, c) = IntentRecognizer::recognize("代码 函数 方法 编译 error impl 重构");
|
||||
assert!(c >= 0.0 && c <= 1.0);
|
||||
assert!((c - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// --- 优先级 ---
|
||||
|
||||
#[test]
|
||||
fn priority_specific_over_entity() {
|
||||
// "代码"(Code/SPECIFIC) + "文件"(File/ENTITY) → SPECIFIC 胜出
|
||||
let (i, _) = IntentRecognizer::recognize("看看这段代码对应的文件");
|
||||
assert_eq!(i, Intent::Code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_entity_over_generic() {
|
||||
// "任务"(Task/ENTITY) + "帮助"(Chat/GENERIC) → ENTITY 胜出
|
||||
let (i, _) = IntentRecognizer::recognize("帮我处理这个任务,谢谢帮助");
|
||||
assert_eq!(i, Intent::Task);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_english() {
|
||||
let (i, _) = IntentRecognizer::recognize("HTTP REQUEST to API");
|
||||
assert_eq!(i, Intent::Http);
|
||||
}
|
||||
|
||||
// --- tool_subset_for ---
|
||||
|
||||
#[test]
|
||||
fn subset_code_has_file_and_http() {
|
||||
let s = tool_subset_for(&Intent::Code);
|
||||
assert!(s.contains(&"read_file"));
|
||||
assert!(s.contains(&"write_file"));
|
||||
assert!(s.contains(&"patch_file"));
|
||||
assert!(s.contains(&"http_request"));
|
||||
assert!(!s.contains(&"list_projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_file_only_file_domain() {
|
||||
let s = tool_subset_for(&Intent::File);
|
||||
assert!(s.contains(&"read_file"));
|
||||
assert!(s.contains(&"search_files"));
|
||||
assert!(!s.contains(&"http_request"));
|
||||
assert!(!s.contains(&"list_projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_project_is_data() {
|
||||
let s = tool_subset_for(&Intent::Project);
|
||||
assert!(s.contains(&"create_project"));
|
||||
assert!(s.contains(&"bind_directory"));
|
||||
assert!(!s.contains(&"read_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_task_is_data() {
|
||||
let s = tool_subset_for(&Intent::Task);
|
||||
assert!(s.contains(&"advance_task"));
|
||||
assert!(s.contains(&"create_task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_idea_is_data() {
|
||||
let s = tool_subset_for(&Intent::Idea);
|
||||
assert!(s.contains(&"list_ideas"));
|
||||
assert!(s.contains(&"create_idea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_http_only_http() {
|
||||
let s = tool_subset_for(&Intent::Http);
|
||||
assert_eq!(s, vec!["http_request"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_search_has_search_files() {
|
||||
let s = tool_subset_for(&Intent::Search);
|
||||
assert!(s.contains(&"search_files"));
|
||||
// Search 复用 File domain,read_file 等也在
|
||||
assert!(s.contains(&"read_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_chat_empty_means_full_fallback() {
|
||||
let s = tool_subset_for(&Intent::Chat);
|
||||
assert!(s.is_empty(), "Chat 空=全量 fallback 约定");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_unknown_empty_means_full_fallback() {
|
||||
let s = tool_subset_for(&Intent::Unknown);
|
||||
assert!(s.is_empty(), "Unknown 空=全量 fallback 约定");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_debug_has_file_and_http() {
|
||||
let s = tool_subset_for(&Intent::Debug);
|
||||
assert!(s.contains(&"read_file"));
|
||||
assert!(s.contains(&"http_request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_no_duplicates_across_domains() {
|
||||
// Code/Debug 跨 file+http domain,确保去重
|
||||
let s = tool_subset_for(&Intent::Code);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for n in &s {
|
||||
assert!(seen.insert(*n), "重复工具名: {}", n);
|
||||
}
|
||||
}
|
||||
|
||||
// --- ToolDomain 工具名与 registry 对齐(核验) ---
|
||||
|
||||
#[test]
|
||||
fn tool_domain_names_match_registry() {
|
||||
// 抽样核对与 src-tauri tool_registry 实际注册名一致
|
||||
assert!(ToolDomain::File.tools().contains(&"patch_file"));
|
||||
assert!(ToolDomain::File.tools().contains(&"run_command"));
|
||||
assert!(ToolDomain::Data.tools().contains(&"advance_task"));
|
||||
assert!(ToolDomain::Data.tools().contains(&"run_workflow"));
|
||||
assert!(ToolDomain::Data.tools().contains(&"list_trash"));
|
||||
assert_eq!(ToolDomain::Http.tools(), &["http_request"]);
|
||||
}
|
||||
|
||||
// --- suggested_model_tier 预留 ---
|
||||
|
||||
#[test]
|
||||
fn model_tier_always_none_for_now() {
|
||||
// 接口预留:当前所有意图均返 None
|
||||
for i in [
|
||||
Intent::Code,
|
||||
Intent::Debug,
|
||||
Intent::File,
|
||||
Intent::Project,
|
||||
Intent::Task,
|
||||
Intent::Idea,
|
||||
Intent::Conversation,
|
||||
Intent::Search,
|
||||
Intent::Http,
|
||||
Intent::Chat,
|
||||
Intent::Unknown,
|
||||
] {
|
||||
assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i);
|
||||
}
|
||||
}
|
||||
|
||||
// --- IntentRecognizer Default ---
|
||||
|
||||
#[test]
|
||||
fn recognizer_is_default_constructible() {
|
||||
let _r = IntentRecognizer::default();
|
||||
// 纯静态方法,实例仅占位
|
||||
let (i, _) = IntentRecognizer::recognize("hi");
|
||||
assert_eq!(i, Intent::Chat);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
@@ -15,7 +15,7 @@ use crate::state::AppState;
|
||||
|
||||
use crate::commands::err_str;
|
||||
|
||||
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
|
||||
use super::{AiChatEvent, AiSession, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||
// tool_registry::generate_diff 经 audit/diff.rs 直接 use(build_write_file_diff 内调用),
|
||||
// 本文件不再裸名用 generate_diff,故不再在此 use(避免 unused import warning)。
|
||||
|
||||
@@ -126,6 +126,74 @@ pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDE
|
||||
mod data_change;
|
||||
pub(crate) use data_change::emit_data_changed;
|
||||
|
||||
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
||||
///
|
||||
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
||||
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files)
|
||||
/// 取 args["path"]
|
||||
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
|
||||
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
|
||||
fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
|
||||
match tool_name {
|
||||
"rename_file" => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(from) = args.get("from").and_then(|x| x.as_str()) {
|
||||
v.push(from.to_string());
|
||||
}
|
||||
if let Some(to) = args.get("to").and_then(|x| x.as_str()) {
|
||||
v.push(to.to_string());
|
||||
}
|
||||
v
|
||||
}
|
||||
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
|
||||
| "append_file" | "delete_file" | "search_files" => {
|
||||
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
///
|
||||
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||
enum FileToolAuthOutcome {
|
||||
Authorized,
|
||||
NeedsAuth(PathAuthRequest),
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
fn check_file_tool_auth(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
allowed: &crate::state::AllowedDirs,
|
||||
) -> FileToolAuthOutcome {
|
||||
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||
let paths = extract_file_tool_paths(tool_name, args);
|
||||
if paths.is_empty() {
|
||||
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
|
||||
return FileToolAuthOutcome::Authorized;
|
||||
}
|
||||
let mut pending_dir: Option<std::path::PathBuf> = None;
|
||||
for p in &paths {
|
||||
match check_path_authorization(p, allowed) {
|
||||
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
if pending_dir.is_none() {
|
||||
pending_dir = Some(dir);
|
||||
}
|
||||
}
|
||||
PathAuthDecision::Authorized => {}
|
||||
}
|
||||
}
|
||||
match pending_dir {
|
||||
Some(dir) => FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dir, raw_paths: paths }),
|
||||
None => FileToolAuthOutcome::Authorized,
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
pub(crate) async fn process_tool_calls(
|
||||
@@ -162,6 +230,87 @@ pub(crate) async fn process_tool_calls(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── F-260619-03 Phase B/C: 文件工具路径授权预校验 ──
|
||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||
// 逐条预校验路径授权(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 移除单独处理。
|
||||
let allowed_snapshot = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.allowed_dirs
|
||||
.clone();
|
||||
let allowed_guard = allowed_snapshot.read().await.clone();
|
||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||
let mut path_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 会话切换清空),
|
||||
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard) {
|
||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||
FileToolAuthOutcome::NeedsAuth(req) => path_auth_pending.push((draft, args, req)),
|
||||
FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)),
|
||||
}
|
||||
}
|
||||
let drafts = authorized_drafts;
|
||||
|
||||
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
||||
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
||||
for (draft, reason) in path_denied {
|
||||
let err_msg = format!("路径授权拒绝: {}", reason);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
|
||||
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,
|
||||
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
|
||||
).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 恢复)。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
let dir_str = req.dir.to_string_lossy().to_string();
|
||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||
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,
|
||||
diff: None,
|
||||
path_auth: Some(req),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired {
|
||||
id: draft.id.clone(),
|
||||
tool: draft.name.clone(),
|
||||
path: path_str,
|
||||
dir: dir_str,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
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,
|
||||
).await;
|
||||
}
|
||||
|
||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||
//
|
||||
// F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||
@@ -253,6 +402,7 @@ pub(crate) async fn process_tool_calls(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
diff: approval_diff.clone(),
|
||||
path_auth: None,
|
||||
});
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||
|
||||
@@ -69,6 +69,9 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
|
||||
// 前端见 diff=None 时回退显新 content。
|
||||
diff: None,
|
||||
// F-260619-03 Phase B: 重启恢复的审批一律视为普通 RiskLevel 审批(非路径授权挂起)。
|
||||
// 路径授权挂起是会话级状态,重启后 session 重建,无法恢复挂起语义。
|
||||
path_auth: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -121,6 +124,7 @@ mod tests_f09_batch8_restore {
|
||||
conversation_id: conversation_id.clone(),
|
||||
recovered: true,
|
||||
diff: None,
|
||||
path_auth: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,6 +356,17 @@ pub async fn ai_approve(
|
||||
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
|
||||
let _recovered = approval.recovered;
|
||||
|
||||
// F-260619-03 Phase B: 路径授权挂起的 pending 不走 ai_approve(它不预写授权目录,
|
||||
// 直接 execute 会因路径未授权失败)。引导前端改用 ai_authorize_dir(写 session/persistent 后执行)。
|
||||
// 防御:前端误调时回滚 pending(重新 insert)+ 返明确错误,避免 pending 被误消费丢失。
|
||||
if approval.path_auth.is_some() {
|
||||
state.ai_session.lock().await.pending_approvals.insert(tool_call_id.clone(), approval);
|
||||
return Err(format!(
|
||||
"tool_call_id={} 是路径授权挂起,请用 ai_authorize_dir(decision: once/always/deny)",
|
||||
tool_call_id
|
||||
));
|
||||
}
|
||||
|
||||
if !approved {
|
||||
// 替换占位 tool_result 为拒绝结果
|
||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
|
||||
@@ -493,6 +504,131 @@ pub async fn ai_approve(
|
||||
Ok("executed".to_string())
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。
|
||||
///
|
||||
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
|
||||
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
|
||||
///
|
||||
/// `decision`:
|
||||
/// - `"once"`:写入 `PerConvState.session_allowed_dirs`(会话级,随会话销毁)→ 执行工具 → 恢复 loop
|
||||
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复
|
||||
/// - `"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> {
|
||||
// 取 pending(必须是 path_auth 挂起;普通 RiskLevel 审批走 ai_approve)
|
||||
let approval = {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
match session.pending_approvals.remove(&tool_call_id) {
|
||||
Some(a) => a,
|
||||
None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)),
|
||||
}
|
||||
};
|
||||
let req = approval.path_auth.clone().ok_or_else(|| {
|
||||
format!("tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", tool_call_id)
|
||||
})?;
|
||||
let dir_str = req.dir.to_string_lossy().to_string();
|
||||
let conv_id = approval.conversation_id.clone();
|
||||
let args = approval.arguments.clone();
|
||||
|
||||
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
|
||||
if decision == "deny" {
|
||||
let err_msg = "用户拒绝路径授权".to_string();
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
return Ok("rejected".to_string());
|
||||
}
|
||||
|
||||
// ── "once":写会话级临时授权(随会话销毁,不落库) ──
|
||||
if decision == "once" {
|
||||
state.add_session_allowed_dir(dir_str.clone()).await;
|
||||
}
|
||||
|
||||
// ── "always":写持久化授权(Settings KV + 同步内存 persistent) ──
|
||||
if decision == "always" {
|
||||
if let Err(e) = state.add_persistent_allowed_dir(dir_str.clone()).await {
|
||||
tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): {}", e);
|
||||
// 降级:写入会话临时授权,保本路径本会话可用
|
||||
state.add_session_allowed_dir(dir_str.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
|
||||
// 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
|
||||
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
|
||||
super::super::execute_run_workflow_for_tool(&app, &state, &args).await
|
||||
} else {
|
||||
state.ai_tools.execute(&approval.tool_name, args.clone()).await
|
||||
};
|
||||
let (audit_status, result_val) = match &exec_result {
|
||||
Ok(val) => ("executed", val.clone()),
|
||||
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
|
||||
};
|
||||
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
|
||||
if exec_result.is_ok() {
|
||||
emit_data_changed(&app, &approval.tool_name);
|
||||
}
|
||||
|
||||
// 替换占位 tool_result 为真实结果
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: result_val.clone(),
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: true,
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
Ok(audit_status.to_string())
|
||||
}
|
||||
|
||||
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PendingToolCallInfo {
|
||||
|
||||
@@ -68,6 +68,11 @@ pub async fn ai_conversation_create(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.active_conversation_id = Some(id.clone());
|
||||
session.active_conv_created_at = Some(now);
|
||||
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
|
||||
// "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。
|
||||
drop(session);
|
||||
state.clear_session_allowed_dirs().await;
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
|
||||
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
|
||||
// (switch 路径会 retain 清目标 conv 的,create 不动他人)。
|
||||
@@ -140,6 +145,11 @@ pub async fn ai_conversation_switch(
|
||||
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
|
||||
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
|
||||
session.active_conversation_id = Some(conversation_id.clone());
|
||||
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
|
||||
// "当前活跃会话的临时授权",不跨会话继承)。
|
||||
drop(session);
|
||||
state.clear_session_allowed_dirs().await;
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
|
||||
if !already_live {
|
||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||
@@ -203,7 +213,8 @@ pub async fn ai_conversation_delete(
|
||||
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
|
||||
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
if session.active_conversation_id.as_deref() == Some(&conversation_id) {
|
||||
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
||||
if was_active {
|
||||
session.active_conversation_id = None;
|
||||
}
|
||||
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
|
||||
@@ -213,6 +224,11 @@ pub async fn ai_conversation_delete(
|
||||
// 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
|
||||
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
|
||||
state.llm_concurrency.release_conv(&conversation_id).await;
|
||||
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
|
||||
// "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。
|
||||
if was_active {
|
||||
state.clear_session_allowed_dirs().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,19 @@ 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.path_auth 标记路径授权挂起),
|
||||
/// 用户经 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> },
|
||||
@@ -552,6 +565,18 @@ pub struct PendingApproval {
|
||||
/// recovered 审批(启动重建)无 diff(文件可能已变,重读无意义)。
|
||||
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
|
||||
pub diff: Option<String>,
|
||||
/// F-260619-03 Phase B: 路径授权挂起标记(Some = 路径授权挂起,None = 普通 RiskLevel 审批)。
|
||||
/// 携带待授权目录(规范化父目录),ai_authorize_dir IPC 据此决定写入 session/persistent。
|
||||
pub path_auth: Option<PathAuthRequest>,
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径授权挂起请求(PendingApproval.path_auth 标记)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PathAuthRequest {
|
||||
/// 待授权目录(规范化父目录,对齐 session_trust 目录粒度)。
|
||||
pub dir: std::path::PathBuf,
|
||||
/// 原始路径参数(read_file.path / write_file.path / rename_file.from+to / ...)。
|
||||
pub raw_paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// 工具调用草稿(流式收集时的临时结构)
|
||||
|
||||
@@ -167,6 +167,8 @@ pub fn run() {
|
||||
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
||||
commands::ai::ai_get_allowed_dirs,
|
||||
commands::ai::ai_set_allowed_dirs,
|
||||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
||||
commands::ai::ai_authorize_dir,
|
||||
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
||||
commands::ai::audit::list_tool_executions,
|
||||
// 知识库
|
||||
|
||||
@@ -305,20 +305,26 @@ pub struct AppState {
|
||||
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
||||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||||
///
|
||||
/// Phase A 仅含 `persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||||
///
|
||||
/// Phase B 将扩展 `session: HashSet<PathBuf>`(会话临时授权) + 弹窗挂起机制,
|
||||
/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。
|
||||
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
|
||||
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
|
||||
/// process_tool_calls 预校验均读此字段,两端一致。
|
||||
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AllowedDirs {
|
||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||
pub persistent: HashSet<PathBuf>,
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
|
||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||
pub session: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl AllowedDirs {
|
||||
@@ -330,22 +336,124 @@ impl AllowedDirs {
|
||||
pub fn default_with_root() -> Self {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
Self { persistent: set }
|
||||
Self { persistent: set, session: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 任一 starts_with 命中。
|
||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
|
||||
///
|
||||
/// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
|
||||
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
|
||||
pub fn is_authorized(&self, candidate: &Path) -> bool {
|
||||
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
|
||||
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
|
||||
if is_in_system_blacklist(candidate) {
|
||||
return false;
|
||||
}
|
||||
let root = workspace_root_path();
|
||||
if candidate.starts_with(&root) {
|
||||
return true;
|
||||
}
|
||||
self.persistent.iter().any(|d| candidate.starts_with(d))
|
||||
|| self.session.iter().any(|d| candidate.starts_with(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
|
||||
///
|
||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathAuthDecision {
|
||||
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
|
||||
Authorized,
|
||||
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
|
||||
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
|
||||
NeedsAuthorization { dir: PathBuf },
|
||||
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
|
||||
///
|
||||
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
|
||||
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
|
||||
/// 此处补系统核心目录。
|
||||
///
|
||||
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
|
||||
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
|
||||
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
|
||||
pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
let seps = ['/', '\\'];
|
||||
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
|
||||
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
// Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System
|
||||
if cfg!(windows) {
|
||||
if *seg == "windows" {
|
||||
if let Some(next) = segs.get(i + 1) {
|
||||
if matches!(*next, "system32" | "syswow64" | "system") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// C:\Program Files / C:\Program Files (x86)
|
||||
if *seg == "program files" || *seg == "program files (x86)" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
|
||||
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
|
||||
///
|
||||
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
|
||||
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
|
||||
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
|
||||
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
|
||||
///
|
||||
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
|
||||
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
|
||||
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
|
||||
pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else {
|
||||
workspace_root_path().join(raw_path)
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
if is_in_system_blacklist(&resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
};
|
||||
}
|
||||
if allowed.is_authorized(&resolved) {
|
||||
return PathAuthDecision::Authorized;
|
||||
}
|
||||
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| workspace_root_path());
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -477,7 +585,11 @@ impl AppState {
|
||||
});
|
||||
set.insert(normalized);
|
||||
}
|
||||
*self.allowed_dirs.write().await = AllowedDirs { persistent: set };
|
||||
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
|
||||
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
||||
let mut guard = self.allowed_dirs.write().await;
|
||||
let preserved_session = std::mem::take(&mut guard.session);
|
||||
*guard = AllowedDirs { persistent: set, session: preserved_session };
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
||||
@@ -520,6 +632,54 @@ impl AppState {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。
|
||||
///
|
||||
/// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。
|
||||
/// 返回持久化后规范化列表(含 workspace_root)。
|
||||
pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result<Vec<String>> {
|
||||
let d = dir.trim().to_string();
|
||||
if d.is_empty() {
|
||||
return Ok(self.get_allowed_dirs().await);
|
||||
}
|
||||
let mut current = self.get_allowed_dirs().await;
|
||||
if !current.iter().any(|c| paths_eq(c, &d)) {
|
||||
current.push(d);
|
||||
}
|
||||
self.set_allowed_dirs(current).await
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加会话级临时授权目录(弹窗"仅本次"选项)。
|
||||
///
|
||||
/// 写入 `allowed_dirs.session`(进程级内存,不落库)。handler 闭包 read lock 取快照时
|
||||
/// 与 process_tool_calls 预校验读同一字段,两端授权判定一致。规范化:canonicalize
|
||||
/// 失败回退原字面量 trim(与 reload_allowed_dirs 一致)。
|
||||
pub async fn add_session_allowed_dir(&self, dir: String) {
|
||||
let d = dir.trim();
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
});
|
||||
self.allowed_dirs.write().await.session.insert(normalized);
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 清空会话级临时授权(切换/新建/删除活跃会话时调)。
|
||||
///
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段语义为
|
||||
/// "当前活跃会话的临时授权",切走即清空(不跨会话继承临时授权)。
|
||||
pub async fn clear_session_allowed_dirs(&self) {
|
||||
self.allowed_dirs.write().await.session.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||
fn paths_eq(a: &str, b: &str) -> bool {
|
||||
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
|
||||
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
|
||||
pa.eq_ignore_ascii_case(&pb)
|
||||
}
|
||||
|
||||
/// 构建节点注册表 — 注册内置节点
|
||||
@@ -590,7 +750,7 @@ mod tests {
|
||||
let mut set = HashSet::new();
|
||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||
set.insert(custom.clone());
|
||||
let allowed = AllowedDirs { persistent: set };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
// 精确命中 + 子路径 starts_with 命中
|
||||
assert!(allowed.is_authorized(&custom));
|
||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||
@@ -601,7 +761,7 @@ mod tests {
|
||||
fn test_allowed_dirs_unauthorized_rejected() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||
let allowed = AllowedDirs { persistent: set };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||
}
|
||||
@@ -620,5 +780,124 @@ mod tests {
|
||||
fn test_allowed_dirs_settings_key_stable() {
|
||||
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B: session 临时授权语义测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
|
||||
#[test]
|
||||
fn test_allowed_dirs_session_authorized() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||
}
|
||||
|
||||
/// Phase B: persistent + session 任一命中即放行
|
||||
#[test]
|
||||
fn test_allowed_dirs_persistent_or_session() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent, session };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase C: 系统目录黑名单测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
|
||||
#[test]
|
||||
fn test_blacklist_windows_system32() {
|
||||
let mut persistent = HashSet::new();
|
||||
// 用户误把整个 C:\ 加入白名单
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||
}
|
||||
|
||||
/// Phase C: Windows Program Files 黑名单命中
|
||||
#[test]
|
||||
fn test_blacklist_windows_program_files() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
|
||||
#[test]
|
||||
fn test_blacklist_no_false_positive() {
|
||||
// "my program files backup" 不应被拒(分段匹配,非精确段)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
|
||||
// 普通工作目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
|
||||
}
|
||||
|
||||
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
|
||||
#[test]
|
||||
fn test_blacklist_unix_system_dirs() {
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
|
||||
// 普通用户目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B/C: workspace_root 路径 → Authorized
|
||||
#[test]
|
||||
fn test_check_path_authorized_workspace() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
let rel = "src/main.rs";
|
||||
match check_path_authorization(rel, &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
|
||||
#[test]
|
||||
fn test_check_path_needs_auth() {
|
||||
let allowed = AllowedDirs::default(); // 空,无 workspace_root
|
||||
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
assert_eq!(dir, PathBuf::from("E:/wk-external"));
|
||||
}
|
||||
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
|
||||
#[test]
|
||||
fn test_check_path_session_hit() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
|
||||
#[test]
|
||||
fn test_check_path_denied_blacklist() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("System32 应 Denied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,15 @@ export const aiApi = {
|
||||
return invoke('ai_approve', { toolCallId, approved })
|
||||
},
|
||||
|
||||
/**
|
||||
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。
|
||||
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||
*/
|
||||
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> {
|
||||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
||||
},
|
||||
|
||||
/**
|
||||
* 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。
|
||||
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
||||
|
||||
@@ -293,6 +293,10 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
|
||||
type: 'AiMaxRoundsReached'
|
||||
} | {
|
||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||
} | {
|
||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||
type: 'AiStreamRetry'; attempt: number; max_attempts: number
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
</div>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
|
||||
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny) -->
|
||||
<DirAuthDialog @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发) -->
|
||||
<div v-if="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||
<div class="ai-queue-head">
|
||||
@@ -131,6 +133,7 @@ import ChatInput from './ai/ChatInput.vue'
|
||||
import TopBar from './ai/TopBar.vue'
|
||||
import MessageList from './ai/MessageList.vue'
|
||||
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import type { AiMessage } from '../api/types'
|
||||
|
||||
142
src/components/ai/DirAuthDialog.vue
Normal file
142
src/components/ai/DirAuthDialog.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
条件:pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
|
||||
点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuth 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-if="showDirAuthCard" class="ai-dir-auth">
|
||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||
<span class="ai-dir-auth-hint">
|
||||
{{ $t('aiChat.dirAuthHint', { tool: pendingDirAuth?.tool, path: pendingDirAuth?.path }) }}
|
||||
</span>
|
||||
<div class="ai-dir-auth-actions">
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--once"
|
||||
:disabled="dirAuthActing"
|
||||
@click="handleAuthorize('once')"
|
||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
||||
:disabled="dirAuthActing"
|
||||
@click="handleAuthorize('always')"
|
||||
>{{ $t('aiChat.dirAuthAlways') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
|
||||
:disabled="dirAuthActing"
|
||||
@click="handleAuthorize('deny')"
|
||||
>{{ $t('aiChat.dirAuthDeny') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingDirAuth } from '../../composables/ai/useAiEvents'
|
||||
import { aiApi } from '../../api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前视图是否正在生成(切走后台生成时光标不显示,与 MaxRoundsCard 一致守卫)
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId,
|
||||
)
|
||||
|
||||
// pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
const dirAuthActing = ref(false)
|
||||
const showDirAuthCard = computed(() =>
|
||||
pendingDirAuth.value !== null && isViewingGenerating.value,
|
||||
)
|
||||
|
||||
/** 点三选项之一:调 ai_authorize_dir。后端 remove pending → 写授权/拒 → execute → try_continue。
|
||||
* 不主动清 pendingDirAuth——由后端 AiApprovalResult/AiCompleted/AiError 在 useAiEvents 内清。
|
||||
* IPC 失败回滚 acting 让用户可重试。 */
|
||||
async function handleAuthorize(decision: 'once' | 'always' | 'deny'): Promise<void> {
|
||||
const id = pendingDirAuth.value?.id
|
||||
if (!id) return
|
||||
dirAuthActing.value = true
|
||||
try {
|
||||
await aiApi.authorizeDir(id, decision)
|
||||
} catch (e) {
|
||||
dirAuthActing.value = false
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
/** pendingDirAuth 离开挂起态(后端事件已清)时复位 acting,允许下次再操作 */
|
||||
watch(() => pendingDirAuth.value, (v) => {
|
||||
if (!v) dirAuthActing.value = false
|
||||
})
|
||||
</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: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-dir-auth-hint {
|
||||
font-size: 10.5px;
|
||||
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--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>
|
||||
@@ -43,7 +43,8 @@ const appSettings = useAppSettingsStore()
|
||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached'])
|
||||
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
|
||||
|
||||
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
|
||||
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
|
||||
@@ -97,6 +98,20 @@ function clearAllToolSlowTimers(): void {
|
||||
// 故用 boolean ref 而非数组,语义更精确。
|
||||
export const pendingMaxRounds = ref(false)
|
||||
|
||||
// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。
|
||||
//
|
||||
// 同 pendingMaxRounds 模式:模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件
|
||||
// 直接 import 读。一次只追踪一个挂起询问(后端单 tool_call_id 挂起,用户决策后 try_continue),
|
||||
// 故用对象 ref 而非数组,语义更精确。null = 无挂起。
|
||||
export interface PendingDirAuth {
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversationId?: string
|
||||
}
|
||||
export const pendingDirAuth = ref<PendingDirAuth | null>(null)
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
@@ -198,6 +213,31 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
pendingMaxRounds.value = true
|
||||
return true
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
|
||||
// 置 pendingDirAuth 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
|
||||
// 同时把工具卡置 pending_approval 态(与 AiApprovalRequired 一致,复用 ToolCard 渲染)。
|
||||
clearStreamWatchdog()
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.tool,
|
||||
args: { path: event.path },
|
||||
status: 'pending_approval',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'pending_approval'
|
||||
clearToolSlowTimer(event.id)
|
||||
pendingDirAuth.value = {
|
||||
id: event.id,
|
||||
tool: event.tool,
|
||||
path: event.path,
|
||||
dir: event.dir,
|
||||
conversationId: event.conversation_id,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -289,6 +329,11 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
|
||||
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
|
||||
}
|
||||
// F-260619-03 Phase B: 路径授权挂起的工具收到 ApprovalResult(once/always 通过 / deny 拒绝)
|
||||
// → 清弹窗(后端 ai_authorize_dir 已 try_continue 续 loop)。
|
||||
if (pendingDirAuth.value && pendingDirAuth.value.id === event.id) {
|
||||
pendingDirAuth.value = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -309,6 +354,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
state.generatingConvId = null
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
|
||||
pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留)
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
|
||||
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
|
||||
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
|
||||
@@ -358,6 +404,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
|
||||
state.pendingApprovals = []
|
||||
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
|
||||
pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
|
||||
|
||||
@@ -145,6 +145,14 @@ export default {
|
||||
continueLoopFailed: 'Continue failed: {msg}',
|
||||
stopLoopFailed: 'Stop failed: {msg}',
|
||||
|
||||
// ── 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',
|
||||
dirAuthAlways: 'Always allow',
|
||||
dirAuthDeny: 'Deny',
|
||||
dirAuthFailed: 'Authorization failed: {msg}',
|
||||
|
||||
// ── UX-18: conversation export (sidebar hover, pick format to download) ──
|
||||
exportConversation: 'Export chat',
|
||||
exportMarkdown: 'Markdown',
|
||||
|
||||
@@ -146,6 +146,14 @@ export default {
|
||||
continueLoopFailed: '继续失败:{msg}',
|
||||
stopLoopFailed: '停止失败:{msg}',
|
||||
|
||||
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
||||
dirAuthRequired: '需要路径授权',
|
||||
dirAuthHint: '{tool} 想访问:{path}',
|
||||
dirAuthOnce: '仅本次',
|
||||
dirAuthAlways: '未来都允许',
|
||||
dirAuthDeny: '拒绝',
|
||||
dirAuthFailed: '授权失败:{msg}',
|
||||
|
||||
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
|
||||
exportConversation: '导出对话',
|
||||
exportMarkdown: 'Markdown',
|
||||
|
||||
Reference in New Issue
Block a user