优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key
This commit is contained in:
+4
-4
@@ -387,7 +387,7 @@ graph TD
|
|||||||
- [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`)
|
- [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`)
|
||||||
- [x] ✅ **P1-c** 列表状态持久化 — **2026-08-09 核验销账**:usePersistedRef 已接入 Tasks/Projects/Knowledge/Ideas/AuditLog;ProjectDetail Tab 手写 localStorage 按 projectId 隔离(动态 key,usePersistedRef 静态 key 不适用)保留
|
- [x] ✅ **P1-c** 列表状态持久化 — **2026-08-09 核验销账**:usePersistedRef 已接入 Tasks/Projects/Knowledge/Ideas/AuditLog;ProjectDetail Tab 手写 localStorage 按 projectId 隔离(动态 key,usePersistedRef 静态 key 不适用)保留
|
||||||
- [x] ✅ **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total — **2026-08-09 核验销账**:list_tool_executions(record.rs:182)支持 status/risk_level/tool_keyword WHERE + count_by_query 独立 COUNT 真实 total + limit/offset 分页;前端 AuditLog.vue buildQuery 传后端 + usePersistedRef 持久化
|
- [x] ✅ **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total — **2026-08-09 核验销账**:list_tool_executions(record.rs:182)支持 status/risk_level/tool_keyword WHERE + count_by_query 独立 COUNT 真实 total + limit/offset 分页;前端 AuditLog.vue buildQuery 传后端 + usePersistedRef 持久化
|
||||||
- [~] **P1-e** 审批体系一致 — **部分(2026-08-09 核验)**:挂起计时✅(ApprovalPopup :191 MM:SS)/ 授权粒度统一✅(ToolCard/ApprovalPopup/DirAuthDialog 同 i18n)/ deny 二次确认✅(本批补 DirAuthDialog)/ **失败 toast 🟡待设计**(ApprovalPopup runApproval 乐观移除且 catch 仅 console.error;useAiApproval 失败仅气泡无 toast,需设计 toast 接入)
|
- [x] ✅ **P1-e** 审批体系一致 — **全部落地(2026-08-09)**:挂起计时✅(ApprovalPopup :191 MM:SS)/ 授权粒度统一✅(ToolCard/ApprovalPopup/DirAuthDialog 同 i18n)/ deny 二次确认✅(DirAuthDialog 补)/ **失败 toast ✅ 本批落地**(useAiApproval catch emit('ai-toast') + AiChat listen;ApprovalPopup 窗口内自包含 useToast)
|
||||||
- [~] **P1-f** cmd 闪烁 N² — **部分(2026-08-09 核验)**:前端展开缓存已做(FileExplorer loadedChildren 去重);**后端每次展开新目录仍整仓 git status 扫描(module.rs:654)待架构级缓存,登记待设计**
|
- [~] **P1-f** cmd 闪烁 N² — **部分(2026-08-09 核验)**:前端展开缓存已做(FileExplorer loadedChildren 去重);**后端每次展开新目录仍整仓 git status 扫描(module.rs:654)待架构级缓存,登记待设计**
|
||||||
- [x] ✅ **P1-g** store.tasks.filter 污染 — **2026-08-09 核验+补全销账**:ProjectDetail 独立 taskApi.list 已修;ChatInput @项目 enrichment 独立拉取(projectTasksCache)已修 + @任务联想本批改独立全量缓存 taskSuggestionCache(隔离 Tasks 页筛选子集)+ mentionTotal 对齐
|
- [x] ✅ **P1-g** store.tasks.filter 污染 — **2026-08-09 核验+补全销账**:ProjectDetail 独立 taskApi.list 已修;ChatInput @项目 enrichment 独立拉取(projectTasksCache)已修 + @任务联想本批改独立全量缓存 taskSuggestionCache(隔离 Tasks 页筛选子集)+ mentionTotal 对齐
|
||||||
|
|
||||||
@@ -406,8 +406,8 @@ graph TD
|
|||||||
- [x] **AC-1** ✅ 已落地(2026-08-09 核验):双层拦截——调用级 `cache.rs:251 find_cached_readonly_result`(同 tool_name+canonical_args 已 completed 即返缓存+警告头,第 2 次即命中)+ 轮级 `is_repetitive_exploration`(helpers.rs:304-332,单签名≥3/唯一率<40%)→ `check_stall_breaker`(警示→硬熔断 emit AiHelpRequired)
|
- [x] **AC-1** ✅ 已落地(2026-08-09 核验):双层拦截——调用级 `cache.rs:251 find_cached_readonly_result`(同 tool_name+canonical_args 已 completed 即返缓存+警告头,第 2 次即命中)+ 轮级 `is_repetitive_exploration`(helpers.rs:304-332,单签名≥3/唯一率<40%)→ `check_stall_breaker`(警示→硬熔断 emit AiHelpRequired)
|
||||||
- [x] **AC-2** ✅ 已落地(2026-08-09 核验,提交 fe780c0):`MAX_TOOL_CALLS_PER_ROUND=8`(agentic/mod.rs:245)+ 目录列举去重(readonly_cache_args_key 只取 path,返目录专用警告)+ `detect_listing_bypass`(run_command 含列举命令且引用已列目录→警告,保守式不硬拒)
|
- [x] **AC-2** ✅ 已落地(2026-08-09 核验,提交 fe780c0):`MAX_TOOL_CALLS_PER_ROUND=8`(agentic/mod.rs:245)+ 目录列举去重(readonly_cache_args_key 只取 path,返目录专用警告)+ `detect_listing_bypass`(run_command 含列举命令且引用已列目录→警告,保守式不硬拒)
|
||||||
- [x] **AC-3** ✅ 已落地(2026-08-09 核验):prompt.rs:304/323 项目/任务清单已含 id + 注明语(projects_listed_note "引用用 id 非名称");**兜底机制超要求**:entity_resolve.rs name→id 自动解析(RESOLVE_MAP 12 工具 + UUID 跳过 + 0 命中/重名返可行动错误 + 9 单测,audit/mod.rs process_tool_calls 单点漏斗)
|
- [x] **AC-3** ✅ 已落地(2026-08-09 核验):prompt.rs:304/323 项目/任务清单已含 id + 注明语(projects_listed_note "引用用 id 非名称");**兜底机制超要求**:entity_resolve.rs name→id 自动解析(RESOLVE_MAP 12 工具 + UUID 跳过 + 0 命中/重名返可行动错误 + 9 单测,audit/mod.rs process_tool_calls 单点漏斗)
|
||||||
- [~] **AC-4** 🟡 部分(2026-08-09 核验):read_file 结果级提示已落地(tools/file.rs:185-197,支持语言集内追加"改用 read_symbol")+ 三态本体(code_intel.rs:130)+ 缓存兜底;**缺口**:无按模型分级、无符号级命中检测、无采用率回测(prompt 引导对弱模型仍偏弱,待评估)
|
- [~] **AC-4** 🟡 部分(2026-08-09 核验):read_file **符号级命中**提示已补强(tools/file.rs:179-196 用 code_intel contains_definition 检测内容含定义节点才提示,替代旧按扩展名)+ 三态本体(code_intel.rs:130)+ 缓存兜底 + 单测 4;**缺口**:无按模型分级、无采用率回测(待评估)
|
||||||
- [~] **AC-5** 🟡 部分(2026-08-09 核验):画像复盘已做(docs/AI工具失败画像复盘-2026-08-08.md)+ 2/5 自愈机制落地(advance_task legal_targets 回灌 task_state_machine.rs:121 / patch_file 相近锚点 file.rs:552);**缺口**:无运行时按工具失败率统计机制(read_file 相近文件候选、search_files 空结果引导为复盘建议未落地,待评估)
|
- [~] **AC-5** 🟡 部分(2026-08-09 核验):画像复盘已做(docs/AI工具失败画像复盘-2026-08-08.md)+ 4/5 自愈机制落地(advance_task legal_targets / patch_file 相近锚点 + **本批补 read_file/read_symbol NotFound 相近文件名候选 + search_files total=0 引导**);**缺口**:无运行时按工具失败率统计机制(待评估)
|
||||||
|
|
||||||
### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记)
|
### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记)
|
||||||
|
|
||||||
@@ -462,7 +462,7 @@ graph TD
|
|||||||
- [x] **CSW-P1-1** — ✅ 已修:`useAiEvents.ts:101-102` 非当前会话 `AiCompleted` 仍 emit `ai-drain-queue`(带 conversationId,未被 isCurrent 吞);`useAiSend.ts:271-306` drainQueue 用目标 convId
|
- [x] **CSW-P1-1** — ✅ 已修:`useAiEvents.ts:101-102` 非当前会话 `AiCompleted` 仍 emit `ai-drain-queue`(带 conversationId,未被 isCurrent 吞);`useAiSend.ts:271-306` drainQueue 用目标 convId
|
||||||
- [x] **CSW-P1-2** — ✅ 已修:`conversation.rs:363-368` 非 live 分支先判 `mem_has_unsaved`(len>persisted_msg_count || needs_full_rewrite),有未落库内存则跳过 restore,DB 快照不再覆盖内存
|
- [x] **CSW-P1-2** — ✅ 已修:`conversation.rs:363-368` 非 live 分支先判 `mem_has_unsaved`(len>persisted_msg_count || needs_full_rewrite),有未落库内存则跳过 restore,DB 快照不再覆盖内存
|
||||||
- [x] **CSW-P1-3** — ✅ 已修:`conversation.rs:335/415-417` `already_live` 分支 pending 完全不动(不 finalize/不 retain/不从 DB 恢复),首屏用 per_conv 内存消息
|
- [x] **CSW-P1-3** — ✅ 已修:`conversation.rs:335/415-417` `already_live` 分支 pending 完全不动(不 finalize/不 retain/不从 DB 恢复),首屏用 per_conv 内存消息
|
||||||
- [ ] **CSW-P1-4** — 🔴 **未修,需设计**:切到生成中 conv 首响应气泡丢失(`useAiConversations.ts:333` 仍整体替换 messages 无 round0 占位补回;后端 already_live 分支 :336-352 只返回 user 消息,assistant 占位要轮末才入;MessageList 要求末条 assistant 才渲染流式 + aiShared.ts flushCurrentText 遇 user break)。需设计 round0 assistant 占位气泡补回机制,前后端协同
|
- [x] **CSW-P1-4** — ✅ **已修(2026-08-09)**:切到生成中 conv 首响应气泡丢失——前端 switchConversation(useAiConversations.ts:336-355)在 parse 成功、active 置位后,若 `targetGen && 末条是 user` 补前端占位 assistant 气泡(`round0-pending-` 判重),承接 currentText 供 MessageList isLastAi 流式渲染 + flushCurrentText 回填。纯前端,不需后端协同
|
||||||
- [x] **CSW-P1-5** — ✅ 已修:`conversation.rs:613-616` 删除先登记 `deleted_convs` 再删库,save 入口 :259-265 命中直接 return + :416-426 INSERT 前二次校验,封死 Ok(None) 复活窗口
|
- [x] **CSW-P1-5** — ✅ 已修:`conversation.rs:613-616` 删除先登记 `deleted_convs` 再删库,save 入口 :259-265 命中直接 return + :416-426 INSERT 前二次校验,封死 Ok(None) 复活窗口
|
||||||
- [x] **CSW-P1-6** — ✅ 已修:`useAiConversations.ts:255-259` `if (!force && id === state.activeConversationId) return` 同 id 短路,不清文本不重拉
|
- [x] **CSW-P1-6** — ✅ 已修:`useAiConversations.ts:255-259` `if (!force && id === state.activeConversationId) return` 同 id 短路,不清文本不重拉
|
||||||
- [x] **CSW-P1-7** — ✅ 已修:`useAiConversations.ts:269/303-305` switchingConvs 集合缓冲切换往返窗口;`useAiEvents.ts:110-115` 非当前命中 switchingConvs 的 delta 累积到 per-conv 流式态而非 drop
|
- [x] **CSW-P1-7** — ✅ 已修:`useAiConversations.ts:269/303-305` switchingConvs 集合缓冲切换往返窗口;`useAiEvents.ts:110-115` 非当前命中 switchingConvs 的 delta 累积到 per-conv 流式态而非 drop
|
||||||
|
|||||||
@@ -92,6 +92,56 @@ pub fn is_supported_ext(ext: &str) -> bool {
|
|||||||
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
|
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断 source 内容是否真的含可提取的函数/类/结构体定义(AC-4 read_file 符号级命中提示)。
|
||||||
|
///
|
||||||
|
/// read_file 读取成功后据此决定是否追加「改用 read_symbol」提示:仅当内容里真的存在
|
||||||
|
/// DEFINITION_KINDS 定义节点才提示——比旧按扩展名判断更准,无定义的文件不瞎提示。
|
||||||
|
/// 与 read_symbol 同语法树逻辑(grammar_for + DEFINITION_KINDS + Vue 切 <script> 段借 ts),
|
||||||
|
/// 但不提取符号只探测存在性,开销低。**复用现有函数,不新写正则**。
|
||||||
|
/// 无 grammar / 解析失败 / Vue 无 script / 无定义节点 → false(不提示)。
|
||||||
|
pub(crate) fn contains_definition(source: &str, ext: &str) -> bool {
|
||||||
|
let ext_lower = ext.to_lowercase();
|
||||||
|
let (parse_source, parse_ext): (String, &str) = if ext_lower == "vue" {
|
||||||
|
// Vue 逻辑在 <script> 段,切段借 ts(与 read_symbol 的 extract_vue_script 同源)
|
||||||
|
match extract_vue_script(source) {
|
||||||
|
Some((script, _)) => (script, "ts"),
|
||||||
|
None => return false, // 纯 template/style 无可提取符号
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(source.to_string(), ext_lower.as_str())
|
||||||
|
};
|
||||||
|
|
||||||
|
let grammar = match grammar_for(parse_ext) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return false, // 不支持的语言不提示
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parser = Parser::new();
|
||||||
|
if parser.set_language(&grammar).is_err() {
|
||||||
|
return false; // grammar 加载失败(ABI 不兼容)不提示
|
||||||
|
}
|
||||||
|
let tree = match parser.parse(&parse_source, None) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return false, // 解析失败不提示
|
||||||
|
};
|
||||||
|
|
||||||
|
tree_has_definition(&tree.root_node())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归遍历语法树,判断是否含任一 DEFINITION_KINDS 定义节点(contains_definition 的遍历内核)。
|
||||||
|
fn tree_has_definition(node: &Node<'_>) -> bool {
|
||||||
|
if DEFINITION_KINDS.contains(&node.kind()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut cursor = node.walk();
|
||||||
|
for child in node.children(&mut cursor) {
|
||||||
|
if tree_has_definition(&child) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
|
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
|
||||||
///
|
///
|
||||||
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
|
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
|
||||||
@@ -633,4 +683,52 @@ fn outer() {
|
|||||||
let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
|
let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
|
||||||
assert!(!calls.is_empty(), "应提取出内部调用");
|
assert!(!calls.is_empty(), "应提取出内部调用");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// contains_definition — AC-4 read_file 符号级命中检测
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contains_definition_hits_definitions() {
|
||||||
|
// 含 fn/struct 定义 → true(应提示改用 read_symbol)
|
||||||
|
assert!(contains_definition(RUST_FIXTURE, "rs"));
|
||||||
|
assert!(contains_definition("pub fn main() {}\n", "rs"));
|
||||||
|
assert!(contains_definition("struct Config { name: String }\n", "rs"));
|
||||||
|
// TS/JS
|
||||||
|
assert!(contains_definition("export function greet() { return 1; }", "ts"));
|
||||||
|
assert!(contains_definition("const handler = () => {};", "js"));
|
||||||
|
// Go / Java / Python
|
||||||
|
assert!(contains_definition("package main\nfunc main() {}\n", "go"));
|
||||||
|
assert!(contains_definition("class Foo { void bar() {} }", "java"));
|
||||||
|
assert!(contains_definition("def foo():\n pass\n", "py"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contains_definition_plain_text_false() {
|
||||||
|
// 无定义节点的纯文本/纯注释 → false(符号级命中,不瞎提示)
|
||||||
|
assert!(!contains_definition("just some text\nwith no definitions\n", "rs"));
|
||||||
|
assert!(!contains_definition("// 只有注释\n/* 多行注释 */\n", "rs"));
|
||||||
|
// 字符串字面量里有 fn 字样但不是定义
|
||||||
|
assert!(!contains_definition("let msg = \"fn main() {}\";\nconsole.log(msg);", "js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contains_definition_unsupported_ext_or_parse_fail() {
|
||||||
|
// Ruby 无 grammar → false
|
||||||
|
assert!(!contains_definition("def foo()\n puts 'hi'\nend\n", "rb"));
|
||||||
|
// 无扩展名 → false
|
||||||
|
assert!(!contains_definition("any content", ""));
|
||||||
|
// 大小写不敏感("RS" 等价 "rs")
|
||||||
|
assert!(contains_definition("pub fn main() {}\n", "RS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_contains_definition_vue() {
|
||||||
|
// Vue 含 <script> 函数 → true
|
||||||
|
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {}\n</script>\n";
|
||||||
|
assert!(contains_definition(vue, "vue"));
|
||||||
|
// 无 <script> 段(纯 template/style)→ false
|
||||||
|
let no_script = "<template><div>hi</div></template>\n<style></style>";
|
||||||
|
assert!(!contains_definition(no_script, "vue"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,7 @@ pub fn register(
|
|||||||
let mut file = File::open(path).await
|
let mut file = File::open(path).await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
anyhow::anyhow!(
|
file_not_found_error(path)
|
||||||
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
|
||||||
path
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||||
}
|
}
|
||||||
@@ -182,14 +179,13 @@ pub fn register(
|
|||||||
consumed,
|
consumed,
|
||||||
))
|
))
|
||||||
} else { None };
|
} else { None };
|
||||||
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol
|
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且**符号级命中**检测通过
|
||||||
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件
|
// (内容里真的含可提取的函数/类/结构体定义)才追加 read_symbol 引导——比旧按扩展名
|
||||||
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言
|
// 判断更准,无定义的文件不瞎提示。用全量 content(非分页 result)检测,符号可能在
|
||||||
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
|
// offset 范围外。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
|
||||||
|
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
let content_field = if offset_used.is_none()
|
let content_field = if offset_used.is_none()
|
||||||
&& crate::commands::ai::code_intel::is_supported_ext(
|
&& crate::commands::ai::code_intel::contains_definition(&content, &ext)
|
||||||
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
|
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
|
||||||
} else {
|
} else {
|
||||||
@@ -227,7 +223,13 @@ pub fn register(
|
|||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
let mut file = File::open(path).await
|
let mut file = File::open(path).await
|
||||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
.map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
file_not_found_error(path)
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
let metadata = file.metadata().await
|
let metadata = file.metadata().await
|
||||||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||||||
if metadata.len() > 1_048_576 {
|
if metadata.len() > 1_048_576 {
|
||||||
@@ -1098,7 +1100,15 @@ pub fn register(
|
|||||||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
|
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
|
||||||
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
|
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
|
||||||
let has_more = (offset + page_results.len()) < total as usize;
|
let has_more = (offset + page_results.len()) < total as usize;
|
||||||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
|
let mut out = serde_json::json!({
|
||||||
|
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
|
||||||
|
});
|
||||||
|
// AC-5:total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
|
||||||
|
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
|
||||||
|
if total == 0 {
|
||||||
|
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1380,6 +1390,83 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
|
|||||||
scored.into_iter().map(|(_, s)| s).collect()
|
scored.into_iter().map(|(_, s)| s).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选(AC-5)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
|
||||||
|
///
|
||||||
|
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
|
||||||
|
/// 治 LLM 拼错文件名/少写扩展名后盲目重试(AC-5)。低频错误路径(文件不存在),同步 std::fs::read_dir
|
||||||
|
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
|
||||||
|
fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> {
|
||||||
|
if needle.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// 预计算 needle 字符多重集计数(各文件名评分复用,同 similar_line_fragments 的探针计数)
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut needle_counts: HashMap<char, u32> = HashMap::new();
|
||||||
|
for c in needle.chars() {
|
||||||
|
*needle_counts.entry(c).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
let needle_len = needle.chars().count() as u32;
|
||||||
|
|
||||||
|
let mut scored: Vec<(u32, String)> = Vec::new();
|
||||||
|
let entries = match std::fs::read_dir(dir) {
|
||||||
|
Ok(iter) => iter,
|
||||||
|
Err(_) => return Vec::new(), // 父目录不可读/非目录 → 无候选,退回原提示
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let name_len = name.chars().count() as u32;
|
||||||
|
if name == needle || name_len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 公共字符计数(needle 计数约束下扫描文件名)
|
||||||
|
let mut counts = needle_counts.clone();
|
||||||
|
let mut common = 0u32;
|
||||||
|
for c in name.chars() {
|
||||||
|
if let Some(cnt) = counts.get_mut(&c) {
|
||||||
|
if *cnt > 0 {
|
||||||
|
*cnt -= 1;
|
||||||
|
common += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dice = 2*common / (needle_len + name_len),乘 100 转百分比整数(同 similar_line_fragments)
|
||||||
|
let dice = (common * 200) / (needle_len + name_len).max(1);
|
||||||
|
if dice >= 40 {
|
||||||
|
scored.push((dice, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
scored.truncate(max);
|
||||||
|
scored.into_iter().map(|(_, s)| s).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选(AC-5)。
|
||||||
|
///
|
||||||
|
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
|
||||||
|
fn file_not_found_error(path: &str) -> anyhow::Error {
|
||||||
|
let mut msg = format!(
|
||||||
|
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) {
|
||||||
|
let needle = file_name.to_string_lossy().into_owned();
|
||||||
|
let candidates = similar_file_names(parent, &needle, 3);
|
||||||
|
if !candidates.is_empty() {
|
||||||
|
msg.push_str(&format!(
|
||||||
|
"。相近文件候选:\n{}\n(可核对实际文件名)",
|
||||||
|
candidates.join("\n")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::anyhow!(msg)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1571,6 +1658,53 @@ mod tests {
|
|||||||
"多行 old_text 应命中首行所在行,实际: {hits:?}"
|
"多行 old_text 应命中首行所在行,实际: {hits:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similar_file_names_finds_close_name() {
|
||||||
|
// 临时目录放真实文件名,needle 少写一个字符(uilts.rs)应命中 utils.rs
|
||||||
|
let dir = std::env::temp_dir().join(format!("df-similar-files-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
for name in ["main.rs", "mod.rs", "utils.rs", "README.md"] {
|
||||||
|
std::fs::write(dir.join(name), "").unwrap();
|
||||||
|
}
|
||||||
|
let hits = similar_file_names(&dir, "uilts.rs", 3);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
assert!(
|
||||||
|
hits.iter().any(|h| h == "utils.rs"),
|
||||||
|
"相近文件名应命中 utils.rs,实际: {hits:?}"
|
||||||
|
);
|
||||||
|
// max 截断
|
||||||
|
assert!(hits.len() <= 3, "候选数应不超过 max,实际: {}", hits.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similar_file_names_empty_when_no_relation() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("df-similar-empty-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("readme.md"), "").unwrap();
|
||||||
|
let hits = similar_file_names(&dir, "config.yaml", 3);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
assert!(hits.is_empty(), "无关文件名不应给候选,实际: {hits:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_not_found_error_appends_candidates() {
|
||||||
|
// 少写一个字符的拼写 → 错误文案应含 target.rs 候选 + 保留 list_directory 引导
|
||||||
|
let dir = std::env::temp_dir().join(format!("df-notfound-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("target.rs"), "fn main() {}\n").unwrap();
|
||||||
|
let missing = dir.join("targett.rs");
|
||||||
|
let msg = file_not_found_error(&missing.to_string_lossy()).to_string();
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
assert!(msg.contains("路径不存在"), "应保留基础提示,实际: {msg}");
|
||||||
|
assert!(msg.contains("list_directory"), "应保留 list_directory 引导,实际: {msg}");
|
||||||
|
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
|
||||||
|
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ const { toast, showToast } = useToast()
|
|||||||
let _unlistenToolSlow: (() => void) | null = null
|
let _unlistenToolSlow: (() => void) | null = null
|
||||||
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
|
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
|
||||||
let _unlistenAutoApproved: (() => void) | null = null
|
let _unlistenAutoApproved: (() => void) | null = null
|
||||||
|
// P1-e-d: 审批失败 toast unlistener(useAiApproval 审批 IPC 未送达经事件总线 emit,同 _unlistenToolSlow 模式)
|
||||||
|
let _unlistenAiToast: (() => void) | null = null
|
||||||
|
|
||||||
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
|
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
|
||||||
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
|
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
|
||||||
@@ -466,6 +468,7 @@ onBeforeUnmount(() => {
|
|||||||
store.stopContextListener()
|
store.stopContextListener()
|
||||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||||
|
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
|
||||||
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -503,6 +506,11 @@ onMounted(async () => {
|
|||||||
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
|
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
|
||||||
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
|
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
|
||||||
})
|
})
|
||||||
|
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
|
||||||
|
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
|
||||||
|
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
|
||||||
|
showToast(e.payload.msg, e.payload.type)
|
||||||
|
})
|
||||||
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
|
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
|
||||||
await initContextEventListeners()
|
await initContextEventListeners()
|
||||||
// 恢复生成中态(刷新后/分离窗口接管):
|
// 恢复生成中态(刷新后/分离窗口接管):
|
||||||
|
|||||||
@@ -91,8 +91,13 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
|
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
|
||||||
try { return JSON.parse(props.dagJson) }
|
try {
|
||||||
catch { return null }
|
const parsed = JSON.parse(props.dagJson)
|
||||||
|
// 结构校验:dag_json 可能为 {} / 缺 nodes / nodes 非数组(执行记录无 DAG 或异常),解析成功但
|
||||||
|
// 缺字段会致下方 for..of 抛 "d.nodes is not iterable"。非预期结构 → null 显示空态而非崩页。
|
||||||
|
if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
|
||||||
|
return parsed as { nodes: DagNode[]; edges: EdgeDef[] }
|
||||||
|
} catch { return null }
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodeTypeLabel: Record<string, string> = {
|
const nodeTypeLabel: Record<string, string> = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
|
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { emit } from '@tauri-apps/api/event'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
@@ -99,9 +100,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||||
console.error('[AI] 审批操作未送达后端:', e)
|
console.error('[AI] 审批操作未送达后端:', e)
|
||||||
|
// P1-e-d:审批失败给用户可见反馈——composable 无组件上下文,经 Tauri 事件总线
|
||||||
|
// emit('ai-toast'),由 AiChat.vue listen 弹本地 toast(对齐 ai-tool-slow-toast 先例)。
|
||||||
|
// 文案复用 ai.approvalNotDelivered(与下方 tc.result 同源)。
|
||||||
|
const errMsg = e instanceof Error ? e.message : String(e)
|
||||||
|
void emit('ai-toast', { msg: t('ai.approvalNotDelivered', { error: errMsg }), type: 'error' })
|
||||||
if (tc) {
|
if (tc) {
|
||||||
tc.status = 'completed'
|
tc.status = 'completed'
|
||||||
tc.result = t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) })
|
tc.result = t('ai.approvalNotDelivered', { error: errMsg })
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { persistUiState } from './useAiPanel'
|
|||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
|
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types'
|
||||||
|
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
@@ -333,6 +333,28 @@ export async function switchConversation(id: string, force = false) {
|
|||||||
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
||||||
// parse 成功才置 active(保证 active 与 messages 一致)
|
// parse 成功才置 active(保证 active 与 messages 一致)
|
||||||
state.activeConversationId = id
|
state.activeConversationId = id
|
||||||
|
// CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
|
||||||
|
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
|
||||||
|
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
|
||||||
|
// 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。
|
||||||
|
// targetGen(非终止三态 generating/stopping/compressed)在上文已算,直接复用;
|
||||||
|
// round0-pending- 前缀防御判重(force 重刷同会话时 parseConvMessages 已整体重建,不叠加)。
|
||||||
|
// 字段满足 AiMessage 必填(id/role/content/timestamp),仅 id 因 branded MessageId 逐字段 cast。
|
||||||
|
const lastMsg = state.messages[state.messages.length - 1]
|
||||||
|
if (
|
||||||
|
targetGen
|
||||||
|
&& lastMsg
|
||||||
|
&& lastMsg.role === 'user'
|
||||||
|
&& !state.messages.some((m) => m.role === 'assistant' && m.id.startsWith('round0-pending-'))
|
||||||
|
) {
|
||||||
|
state.messages.push({
|
||||||
|
id: `round0-pending-${id}` as MessageId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
||||||
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
|
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export default {
|
|||||||
inputHint: 'Enter to send · Shift+Enter for newline',
|
inputHint: 'Enter to send · Shift+Enter for newline',
|
||||||
|
|
||||||
// ── F-260614-05 Phase 2b: image input (paste / drag-drop) ──
|
// ── F-260614-05 Phase 2b: image input (paste / drag-drop) ──
|
||||||
|
pickImage: 'Pick image',
|
||||||
|
pickImageTitle: 'Pick image (or paste / drag-drop)',
|
||||||
|
imageFilter: 'Image files',
|
||||||
removeImage: 'Remove image',
|
removeImage: 'Remove image',
|
||||||
imagePreviewAlt: 'Pending image',
|
imagePreviewAlt: 'Pending image',
|
||||||
imageTooLarge: 'Image too large (max 10MB), skipped',
|
imageTooLarge: 'Image too large (max 10MB), skipped',
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ export default {
|
|||||||
|
|
||||||
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
|
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
|
||||||
// 粘贴/拖拽图片到输入框,发送时构造多模态 ContentPart[] 渲染到用户气泡
|
// 粘贴/拖拽图片到输入框,发送时构造多模态 ContentPart[] 渲染到用户气泡
|
||||||
|
pickImage: '选择图片',
|
||||||
|
pickImageTitle: '选择图片(可粘贴/拖拽)',
|
||||||
|
imageFilter: '图片文件',
|
||||||
removeImage: '移除图片',
|
removeImage: '移除图片',
|
||||||
imagePreviewAlt: '待发送图片',
|
imagePreviewAlt: '待发送图片',
|
||||||
imageTooLarge: '图片过大(上限 10MB),已跳过',
|
imageTooLarge: '图片过大(上限 10MB),已跳过',
|
||||||
|
|||||||
@@ -129,6 +129,11 @@
|
|||||||
{{ $t('aiChat.popupOpenPanel') }}
|
{{ $t('aiChat.popupOpenPanel') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- P1-e-d:审批失败 toast(窗口内自包含 useToast;独立 WebviewWindow 无根 toast,自管渲染) -->
|
||||||
|
<Transition name="ai-toast">
|
||||||
|
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- M5:path 类 deny 二次确认(拒绝不可逆,与 ToolCard 同款;独立窗口本地 ConfirmDialog,
|
<!-- M5:path 类 deny 二次确认(拒绝不可逆,与 ToolCard 同款;独立窗口本地 ConfirmDialog,
|
||||||
不复用主窗口 useConfirm reactive——跨 WebviewWindow JS 上下文隔离) -->
|
不复用主窗口 useConfirm reactive——跨 WebviewWindow JS 上下文隔离) -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -149,6 +154,7 @@ import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
|||||||
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
|
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
|
||||||
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -157,6 +163,9 @@ const { t } = useI18n()
|
|||||||
const approvals = ref<AiToolCallInfo[]>([])
|
const approvals = ref<AiToolCallInfo[]>([])
|
||||||
/** 操作中 id(防抖 + 按钮加载态) */
|
/** 操作中 id(防抖 + 按钮加载态) */
|
||||||
const processingId = ref<string>('')
|
const processingId = ref<string>('')
|
||||||
|
// P1-e-d: 审批失败 toast——独立 WebviewWindow 无共享组件树,窗口内自包含 useToast
|
||||||
|
// (不走跨窗口 emit 中转,失败反馈一定显示在用户当前关注的浮窗内)
|
||||||
|
const { toast, showToast } = useToast()
|
||||||
/** 拖动态(样式反馈) */
|
/** 拖动态(样式反馈) */
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
@@ -436,6 +445,8 @@ async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'on
|
|||||||
await maybeAutoClose()
|
await maybeAutoClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ApprovalPopup] 审批失败:', e)
|
console.error('[ApprovalPopup] 审批失败:', e)
|
||||||
|
// P1-e-d:IPC 未送达给用户可见反馈(独立窗口自包含 toast;文案复用 ai.approvalNotDelivered)
|
||||||
|
showToast(t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) }), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
processingId.value = ''
|
processingId.value = ''
|
||||||
}
|
}
|
||||||
@@ -802,4 +813,28 @@ function onDragEnd() { isDragging.value = false }
|
|||||||
@keyframes df-spin {
|
@keyframes df-spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P1-e-d:审批失败 toast(自包含 useToast;独立窗口无根 toast 自管渲染。fixed 相对本窗口视口,
|
||||||
|
top 让出 header,不遮挡审批按钮区。样式对齐 AiChat .ai-toast 同款,scoped 防冲突) */
|
||||||
|
.ai-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 44px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
max-width: 80%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||||
|
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||||
|
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||||
|
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||||
|
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||||
|
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user