新增: 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:
2026-06-19 18:58:48 +08:00
parent 968c3df5ab
commit 8da6bdcec2
17 changed files with 1572 additions and 17 deletions

36
Cargo.lock generated
View File

@@ -83,6 +83,18 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "async-executor" name = "async-executor"
version = "1.14.0" version = "1.14.0"
@@ -506,6 +518,24 @@ dependencies = [
"memchr", "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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -751,6 +781,7 @@ dependencies = [
"futures", "futures",
"keyring", "keyring",
"percent-encoding", "percent-encoding",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -4744,12 +4775,17 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.13.0", "bitflags 2.13.0",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",

682
crates/df-ai/src/intent.rs Normal file
View 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.01.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.01.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_filesConversation → [];
/// Chat → []Debug → [file, http](调试常需读文件+查 APIUnknown → [](全量)。
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);
}
}

View File

@@ -6,6 +6,10 @@ pub mod anthropic_helpers;
pub mod context; pub mod context;
pub mod context_helpers; pub mod context_helpers;
pub mod coordinator; 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; pub mod model_fetch;
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化), // model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。 // crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。

View File

@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, Manager, State};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
@@ -15,7 +15,7 @@ use crate::state::AppState;
use crate::commands::err_str; 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 直接 usebuild_write_file_diff 内调用), // tool_registry::generate_diff 经 audit/diff.rs 直接 usebuild_write_file_diff 内调用),
// 本文件不再裸名用 generate_diff故不再在此 use避免 unused import warning // 本文件不再裸名用 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; mod data_change;
pub(crate) use data_change::emit_data_changed; 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_allMed/High 进审批门控 /// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成) /// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls( pub(crate) async fn process_tool_calls(
@@ -162,6 +230,87 @@ pub(crate) async fn process_tool_calls(
}) })
.collect(); .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 // 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result
// //
// F-260616-05High risk 在进审批门前先查去重缓存find_cached_high_risk_result // F-260616-05High risk 在进审批门前先查去重缓存find_cached_high_risk_result
@@ -253,6 +402,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
recovered: false, recovered: false,
diff: approval_diff.clone(), diff: approval_diff.clone(),
path_auth: None,
}); });
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER)); 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; let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;

View File

@@ -69,6 +69,9 @@ pub async fn restore_pending_approvals(state: &AppState) {
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。 // 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
// 前端见 diff=None 时回退显新 content。 // 前端见 diff=None 时回退显新 content。
diff: None, 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(), conversation_id: conversation_id.clone(),
recovered: true, recovered: true,
diff: None, diff: None,
path_auth: None,
}, },
); );
} }

View File

@@ -356,6 +356,17 @@ pub async fn ai_approve(
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。 // recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
let _recovered = approval.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 { if !approved {
// 替换占位 tool_result 为拒绝结果 // 替换占位 tool_result 为拒绝结果
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。 // 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()) 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 态用) /// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct PendingToolCallInfo { pub struct PendingToolCallInfo {

View File

@@ -68,6 +68,11 @@ pub async fn ai_conversation_create(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.active_conversation_id = Some(id.clone()); session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now); 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,与旧会话隔离)。 // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留 // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
// (switch 路径会 retain 清目标 conv 的,create 不动他人)。 // (switch 路径会 retain 清目标 conv 的,create 不动他人)。
@@ -140,6 +145,11 @@ pub async fn ai_conversation_switch(
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages // **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。 // (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
session.active_conversation_id = Some(conversation_id.clone()); 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); let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
if !already_live { if !already_live {
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 // 目标 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 存在性判据依赖此, // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。 // 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
session.per_conv.remove(&conversation_id); 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; session.active_conversation_id = None;
} }
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。 // 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 未删时不清理(下次发消息复用,限流计数连续)。 // 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题) drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
state.llm_concurrency.release_conv(&conversation_id).await; 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(()) Ok(())
} }

View File

@@ -148,6 +148,19 @@ pub enum AiChatEvent {
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。 /// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。 /// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> }, 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 标记), /// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。 /// 前端可据此刷新消息视图(归档段折叠/隐藏)。
AiContextCleared { conversation_id: Option<String> }, AiContextCleared { conversation_id: Option<String> },
@@ -552,6 +565,18 @@ pub struct PendingApproval {
/// recovered 审批(启动重建)无 diff文件可能已变重读无意义 /// recovered 审批(启动重建)无 diff文件可能已变重读无意义
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报 #[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
pub diff: Option<String>, 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>,
} }
/// 工具调用草稿(流式收集时的临时结构) /// 工具调用草稿(流式收集时的临时结构)

View File

@@ -167,6 +167,8 @@ pub fn run() {
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 // F-260619-03 Phase A: AI 工具文件访问授权目录白名单
commands::ai::ai_get_allowed_dirs, commands::ai::ai_get_allowed_dirs,
commands::ai::ai_set_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 表,敏感字段截断) // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::list_tool_executions, commands::ai::audit::list_tool_executions,
// 知识库 // 知识库

View File

@@ -305,20 +305,26 @@ pub struct AppState {
pub allowed_dirs: Arc<RwLock<AllowedDirs>>, 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` 校验时: /// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
/// - workspace_root 始终视为已授权(向后兼容,默认根) /// - workspace_root 始终视为已授权(向后兼容,默认根)
/// - 任一 persistent 目录 starts_with 命中即放行 /// - 任一 persistent 目录 starts_with 命中即放行
/// /// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
/// Phase B 将扩展 `session: HashSet<PathBuf>`(会话临时授权) + 弹窗挂起机制, /// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。 /// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
/// process_tool_calls 预校验均读此字段,两端一致。
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct AllowedDirs { pub struct AllowedDirs {
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。 /// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。 /// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
pub persistent: HashSet<PathBuf>, pub persistent: HashSet<PathBuf>,
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
pub session: HashSet<PathBuf>,
} }
impl AllowedDirs { impl AllowedDirs {
@@ -330,22 +336,124 @@ impl AllowedDirs {
pub fn default_with_root() -> Self { pub fn default_with_root() -> Self {
let mut set = HashSet::new(); let mut set = HashSet::new();
set.insert(workspace_root_path()); 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 逃逸); /// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。 /// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
pub fn is_authorized(&self, candidate: &Path) -> bool { 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(); let root = workspace_root_path();
if candidate.starts_with(&root) { if candidate.starts_with(&root) {
return true; return true;
} }
self.persistent.iter().any(|d| candidate.starts_with(d)) 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 同源)。 /// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
fn workspace_root_path() -> PathBuf { fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -477,7 +585,11 @@ impl AppState {
}); });
set.insert(normalized); 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 调用)。 /// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
@@ -520,6 +632,54 @@ impl AppState {
out.sort(); out.sort();
out 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 mut set = HashSet::new();
let custom = PathBuf::from("E:/wk-test-external-dir"); let custom = PathBuf::from("E:/wk-test-external-dir");
set.insert(custom.clone()); set.insert(custom.clone());
let allowed = AllowedDirs { persistent: set }; let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
// 精确命中 + 子路径 starts_with 命中 // 精确命中 + 子路径 starts_with 命中
assert!(allowed.is_authorized(&custom)); assert!(allowed.is_authorized(&custom));
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt"))); assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
@@ -601,7 +761,7 @@ mod tests {
fn test_allowed_dirs_unauthorized_rejected() { fn test_allowed_dirs_unauthorized_rejected() {
let mut set = HashSet::new(); let mut set = HashSet::new();
set.insert(PathBuf::from("E:/wk-test-authorized")); 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"); let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝"); assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
} }
@@ -620,5 +780,124 @@ mod tests {
fn test_allowed_dirs_settings_key_stable() { fn test_allowed_dirs_settings_key_stable() {
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs"); 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),
}
}
} }

View File

@@ -68,6 +68,15 @@ export const aiApi = {
return invoke('ai_approve', { toolCallId, approved }) 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 暂停态点继续)。 * 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn * 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn

View File

@@ -293,6 +293,10 @@ export type AiChatEvent = ({
} | { } | {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止 // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
type: 'AiMaxRoundsReached' 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」) // F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
type: 'AiStreamRetry'; attempt: number; max_attempts: number type: 'AiStreamRetry'; attempt: number; max_attempts: number

View File

@@ -58,6 +58,8 @@
</div> </div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) --> <!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" /> <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 v-if="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head"> <div class="ai-queue-head">
@@ -131,6 +133,7 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue' import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue' import MessageList from './ai/MessageList.vue'
import MaxRoundsCard from './ai/MaxRoundsCard.vue' import MaxRoundsCard from './ai/MaxRoundsCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import type { AiMessage } from '../api/types' import type { AiMessage } from '../api/types'

View 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>

View File

@@ -43,7 +43,8 @@ const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。 // B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。 // 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。 // 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)。 // B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval, // 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -97,6 +98,20 @@ function clearAllToolSlowTimers(): void {
// 故用 boolean ref 而非数组,语义更精确。 // 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false) 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 等触发刷新侧栏) */ /** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() { export function notifyConversationChanged() {
emit('ai-conversation-changed', {}) emit('ai-conversation-changed', {})
@@ -198,6 +213,31 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
pendingMaxRounds.value = true pendingMaxRounds.value = true
return 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: default:
return false return false
} }
@@ -289,6 +329,11 @@ function handleToolEvent(event: AiChatEvent): boolean {
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器 // B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '') 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 return true
} }
@@ -309,6 +354,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
state.generatingConvId = null state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡 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), // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。 // 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。 // 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
@@ -358,6 +404,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。 // 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = [] state.pendingApprovals = []
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡 pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗
localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text') localStorage.removeItem('df-ai-text')
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。

View File

@@ -145,6 +145,14 @@ export default {
continueLoopFailed: 'Continue failed: {msg}', continueLoopFailed: 'Continue failed: {msg}',
stopLoopFailed: 'Stop 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) ── // ── UX-18: conversation export (sidebar hover, pick format to download) ──
exportConversation: 'Export chat', exportConversation: 'Export chat',
exportMarkdown: 'Markdown', exportMarkdown: 'Markdown',

View File

@@ -146,6 +146,14 @@ export default {
continueLoopFailed: '继续失败:{msg}', continueLoopFailed: '继续失败:{msg}',
stopLoopFailed: '停止失败:{msg}', stopLoopFailed: '停止失败:{msg}',
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
dirAuthRequired: '需要路径授权',
dirAuthHint: '{tool} 想访问:{path}',
dirAuthOnce: '仅本次',
dirAuthAlways: '未来都允许',
dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}',
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ── // ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
exportConversation: '导出对话', exportConversation: '导出对话',
exportMarkdown: 'Markdown', exportMarkdown: 'Markdown',