清理: 授权目录白名单退役与审批面板收敛
- 授权目录白名单机制退役为默认全放行(2026-10-25 拍板): 删 AllowedDirsPanel/DirAuthDialog/ApprovalPopup 三件+设置页目录选择 UI+i18n 键 - 后端 path_auth bypass 恒开收敛,allowed_dirs 机制保留代码路径 - 新增 ScriptSafetyPanel(脚本安全面板替代审批弹窗) - restore.rs 测试 import 断线修复(类型已迁 commands/ai/mod.rs)
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||||
@@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo;
|
|||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
|
|
||||||
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
|
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
|
||||||
|
use super::approval_expires_at_millis;
|
||||||
use super::super::event_bus::emit_ai_chat_event;
|
use super::super::event_bus::emit_ai_chat_event;
|
||||||
use super::diff::build_write_file_diff;
|
use super::diff::build_write_file_diff;
|
||||||
use super::reason::build_approval_reason;
|
use super::reason::build_approval_reason;
|
||||||
@@ -205,6 +206,12 @@ pub(super) async fn insert_pending_approval(
|
|||||||
}
|
}
|
||||||
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||||
|
// 审批超时收敛单一真值=后端(arm_approval_timeout_timers + cleanup 惰性兜底,前端本地
|
||||||
|
// 自动拒绝定时器已退役 2026-10-25):到期时刻随事件透传,前端倒计时只读不算。
|
||||||
|
let expires_at_millis = approval_expires_at_millis(
|
||||||
|
app_handle.state::<crate::state::AppState>().inner(),
|
||||||
|
std::time::SystemTime::now(),
|
||||||
|
);
|
||||||
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
||||||
let ev = AiChatEvent::AiApprovalRequired {
|
let ev = AiChatEvent::AiApprovalRequired {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
@@ -213,6 +220,7 @@ pub(super) async fn insert_pending_approval(
|
|||||||
reason,
|
reason,
|
||||||
diff: approval_diff,
|
diff: approval_diff,
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
expires_at_millis,
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
emit_ai_chat_event(&app_handle, ev);
|
||||||
// P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。
|
// P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
||||||
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
||||||
|
|
||||||
use super::PathAuthRequest;
|
use crate::commands::ai::PathAuthRequest;
|
||||||
|
|
||||||
/// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。
|
/// 「信任所有目录」总开关 KV key(app_settings)。
|
||||||
/// 开启后白名单外路径不再挂起授权弹窗,黑名单拦截也一并放行(产品拍板:体验优先,
|
/// 【已退役】授权目录白名单机制退役为默认全放行(2026-10-25 用户拍板),存量 KV 一律忽略,
|
||||||
/// 用户知情选择,后期再收敛)。前端设置页读写同一 key。
|
/// audit 层 bypass 恒开不再读本键。壳保留作恢复点,前端设置页管理入口已撤。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass";
|
pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass";
|
||||||
|
|
||||||
/// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数,
|
/// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_multi_conv_restore {
|
mod tests_multi_conv_restore {
|
||||||
use super::super::{ApprovalKind, PendingApproval};
|
use crate::commands::ai::{ApprovalKind, PendingApproval};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::commands::ai::AiSession;
|
use crate::commands::ai::AiSession;
|
||||||
|
|
||||||
@@ -173,14 +173,7 @@ mod tests_multi_conv_restore {
|
|||||||
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||||
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
||||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
|
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
|
||||||
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
|
// path_auth 退役(2026-10-25):ApprovalKind::Path 变体已删,恢复侧构造恒 kind=Risk。
|
||||||
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
|
|
||||||
// 对齐真实 restore_pending_approvals 的构造(实现 L78 一律 kind=Risk,无 Path 分支)。
|
|
||||||
// 语义:首次 restore 无历史(Path 审批是会话级状态不恢复,恢复侧构造时本就不产出 Path)。
|
|
||||||
let path_restored = session.pending_approvals.values()
|
|
||||||
.filter(|a| matches!(a.kind, ApprovalKind::Path(_)))
|
|
||||||
.count();
|
|
||||||
assert_eq!(path_restored, 0, "构造期恒等式:恢复侧构造全部 kind=Risk,无 Path(首次 restore 无历史)");
|
|
||||||
let conv_a_pending: Vec<_> = session
|
let conv_a_pending: Vec<_> = session
|
||||||
.pending_approvals
|
.pending_approvals
|
||||||
.values()
|
.values()
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl AllowedDirs {
|
|||||||
///
|
///
|
||||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
/// - `NeedsAuthorization`:路径未授权但非黑名单 → 挂起弹窗(已随 path_auth 退役)
|
||||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum PathAuthDecision {
|
pub enum PathAuthDecision {
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ const appSettings = useAppSettingsStore()
|
|||||||
// 持久化到 localStorage(轻量、同步、读早于 appSettings.loadAll 完成,避免闪烁)。
|
// 持久化到 localStorage(轻量、同步、读早于 appSettings.loadAll 完成,避免闪烁)。
|
||||||
// 各列表页(Tasks/Ideas/...)的筛选恢复由各自 agent 读写各自 localStorage key;
|
// 各列表页(Tasks/Ideas/...)的筛选恢复由各自 agent 读写各自 localStorage key;
|
||||||
// 此处只负责「上次所在页面 + query」级别的恢复(如 /tasks?status=done)。
|
// 此处只负责「上次所在页面 + query」级别的恢复(如 /tasks?status=done)。
|
||||||
// 分离窗口(ai-detached/file-explorer-detached/approval-popup)不参与:它们有专属
|
// 分离窗口(ai-detached/file-explorer-detached)不参与:它们有专属
|
||||||
// 入口路由,且会被 watch 误覆盖主窗口记录。
|
// 入口路由,且会被 watch 误覆盖主窗口记录。
|
||||||
const ROUTE_STORAGE_KEY = 'df-last-route'
|
const ROUTE_STORAGE_KEY = 'df-last-route'
|
||||||
// 这些路径不持久化(过渡/分离/弹窗/根跳转):避免分离窗口覆盖、避免 '/' redirect 噪音
|
// 这些路径不持久化(过渡/分离/弹窗/根跳转):避免分离窗口覆盖、避免 '/' redirect 噪音
|
||||||
const NON_PERSIST_PATHS = new Set(['/', '/ai-detached', '/file-explorer-detached', '/approval-popup'])
|
const NON_PERSIST_PATHS = new Set(['/', '/ai-detached', '/file-explorer-detached'])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -236,12 +236,14 @@ function onLogLevelChange() {
|
|||||||
flashSaved('logLevel')
|
flashSaved('logLevel')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。
|
|
||||||
function onCustomPromptChange() {
|
function onCustomPromptChange() {
|
||||||
appSettings.set('custom_prompt', customPrompt.value)
|
appSettings.set('custom_prompt', customPrompt.value)
|
||||||
rowCustomPrompt.value?.markSaved()
|
rowCustomPrompt.value?.markSaved()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 审批超时写 KV(df-approval-timeout),后端启动时 reload_approval_timeout 读入
|
||||||
|
// approval_timeout_minutes —— 超时收敛单一真值=后端定时任务(前端本地 auto-reject 已退役),
|
||||||
|
// 到期时刻经 AiApprovalRequired.expires_at_millis 透传,前端只读不算。
|
||||||
function onApprovalTimeoutChange() {
|
function onApprovalTimeoutChange() {
|
||||||
appSettings.set('df-approval-timeout', settings.approvalTimeout)
|
appSettings.set('df-approval-timeout', settings.approvalTimeout)
|
||||||
flashSaved('approvalTimeout')
|
flashSaved('approvalTimeout')
|
||||||
@@ -273,7 +275,7 @@ function onApprovalTimeoutChange() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
/* 端点输入走等宽字体(URL 逐字符核对更易读),对齐 AllowedDirsPanel 路径输入风格 */
|
/* 端点输入走等宽字体(URL 逐字符核对更易读) */
|
||||||
.upload-base-input {
|
.upload-base-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- ═══ F-260619-03 Phase A: AI 工具授权目录 ═══ -->
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>{{ $t('settings.panelAllowedDirs') }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="allowed-dirs">
|
|
||||||
<!-- 说明 -->
|
|
||||||
<p class="allowed-dirs-desc">{{ $t('settings.descAllowedDirs') }}</p>
|
|
||||||
|
|
||||||
<!-- 「信任所有目录」总开关:开启后白名单外路径不再逐次弹授权(后端黑名单拦截同步放行)。
|
|
||||||
走 appSettings KV(df-ai-path-auth-bypass),后端 process_tool_calls 每轮读取,改后即时生效。
|
|
||||||
开启方向二次确认(全放敏感目录须确认+风险自担):checked 单向绑定 + @change 裁决,
|
|
||||||
确认前不写 ref。若 v-model 直写,弹窗期间 useSetting 的 debounced 落库已把 true 推给后端,
|
|
||||||
取消也收不回这段已放行窗口。 -->
|
|
||||||
<SettingRow :label="$t('settings.labelPathAuthBypass')" :desc="$t('settings.descPathAuthBypass')">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" :checked="pathAuthBypass" @change="onPathAuthBypassChange" />
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<!-- 列表 -->
|
|
||||||
<div v-if="loading" class="empty-hint">{{ $t('settings.allowedDirsLoading') }}</div>
|
|
||||||
<template v-else>
|
|
||||||
<div v-for="(dir, idx) in dirs" :key="dir + idx" class="dir-row">
|
|
||||||
<span class="dir-path" :title="dir">{{ dir }}</span>
|
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="saving" @click="removeDir(idx)">
|
|
||||||
{{ $t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="dirs.length === 0" class="empty-hint">{{ $t('settings.allowedDirsEmpty') }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 新增输入 -->
|
|
||||||
<div class="add-row">
|
|
||||||
<input
|
|
||||||
v-model="newDir"
|
|
||||||
class="setting-input dir-input"
|
|
||||||
:placeholder="$t('settings.allowedDirsInputPlaceholder')"
|
|
||||||
@keyup.enter="onAddClick"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-primary btn-sm" :disabled="saving" @click="onAddClick">
|
|
||||||
{{ newDir.trim() ? $t('settings.allowedDirsAdd') : $t('settings.allowedDirsSelectDir') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作 -->
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="reload">
|
|
||||||
{{ $t('settings.allowedDirsReload') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ═══ 命令执行安全:ScriptNode 白/黑名单(前端仅 UI + 持久化,后端当前从环境变量读) ═══ -->
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>{{ $t('settings.panelScriptSafety') }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="script-safety">
|
|
||||||
<p class="script-safety-desc">{{ $t('settings.descScriptSafety') }}</p>
|
|
||||||
|
|
||||||
<SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')">
|
|
||||||
<input
|
|
||||||
v-model="scriptWhitelist"
|
|
||||||
class="setting-input script-safety-input"
|
|
||||||
:placeholder="$t('settings.placeholderScriptWhitelist')"
|
|
||||||
@blur="onScriptWhitelistCommit"
|
|
||||||
@keyup.enter="onScriptWhitelistCommit"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')">
|
|
||||||
<input
|
|
||||||
v-model="scriptBlacklist"
|
|
||||||
class="setting-input script-safety-input"
|
|
||||||
:placeholder="$t('settings.placeholderScriptBlacklist')"
|
|
||||||
@blur="onScriptBlacklistCommit"
|
|
||||||
@keyup.enter="onScriptBlacklistCommit"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
|
|
||||||
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行;
|
|
||||||
按钮行/过渡消费 global.css .df-confirm-actions + df-confirm,2026-10-18 整洁波并轨) -->
|
|
||||||
<Transition name="df-confirm">
|
|
||||||
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
|
|
||||||
<div class="confirm-box">
|
|
||||||
<div class="confirm-msg">{{ confirmState.msg }}</div>
|
|
||||||
<div class="df-confirm-actions">
|
|
||||||
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
|
|
||||||
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
|
||||||
import { aiApi, settingsApi } from '@/api'
|
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
|
||||||
import SettingRow from './SettingRow.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const appSettings = useAppSettingsStore()
|
|
||||||
// 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
|
|
||||||
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
|
||||||
|
|
||||||
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
|
|
||||||
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
|
|
||||||
// 避免暂存+刷新丢失的认知错配)。dirs 始终由后端 setAllowedDirs 返回值回填(canonicalize +
|
|
||||||
// strip_verbatim 规范化后的真实路径),保证前后端读写一致。workspace_root 由后端 get 返回时
|
|
||||||
// 过滤(内部隐式授权,不进用户管理列表),前端原样展示不再猜测 root 形态。
|
|
||||||
const dirs = ref<string[]>([])
|
|
||||||
const newDir = ref('')
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
// 「信任所有目录」总开关(useSetting 双向绑 KV,写走 debounced 落库,后端每轮工具执行前读)
|
|
||||||
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
|
|
||||||
|
|
||||||
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) },
|
|
||||||
// confirmDialog 返回 Promise<boolean>(true=确认),弹层 UI 在本组件模板尾部自渲染。
|
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
||||||
|
|
||||||
// 开启方向(false→true)二次确认:确认才落 true,取消仅回弹。关闭方向不拦截直接落。
|
|
||||||
async function onPathAuthBypassChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
if (!input.checked) {
|
|
||||||
pathAuthBypass.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.checked = false
|
|
||||||
const msg = t('settings.pathAuthBypassConfirmTitle') + '\n\n' + t('settings.pathAuthBypassConfirmBody')
|
|
||||||
if (await confirmDialog(msg, t('common.confirm'))) {
|
|
||||||
pathAuthBypass.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
dirs.value = await aiApi.getAllowedDirs()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载授权目录失败:', e)
|
|
||||||
emit('toast', t('settings.allowedDirsLoadFail'), 'error')
|
|
||||||
dirs.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单按钮双态:newDir 空 → 选目录(Tauri dialog::open 填入 newDir);有值 → 提交白名单
|
|
||||||
async function onAddClick() {
|
|
||||||
if (!newDir.value.trim()) {
|
|
||||||
await pickDir()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await addDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickDir() {
|
|
||||||
try {
|
|
||||||
const selected = await open({ directory: true, multiple: false })
|
|
||||||
if (!selected || Array.isArray(selected)) return
|
|
||||||
newDir.value = selected as string
|
|
||||||
// 选完即加(消除选目录后需二次点"添加"的摩擦)
|
|
||||||
await addDir()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('选择目录失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加:本地大小写不敏感去重预检(减少无效 IPC)+ 即时持久化。
|
|
||||||
// dirs 由 setAllowedDirs 返回值回填,保证与后端规范化形态一致(读写不再漂移)。
|
|
||||||
async function addDir() {
|
|
||||||
const d = newDir.value.trim()
|
|
||||||
if (!d) return
|
|
||||||
if (dirs.value.some(x => x.toLowerCase() === d.toLowerCase())) {
|
|
||||||
emit('toast', t('settings.allowedDirsDup'), 'warning')
|
|
||||||
newDir.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const next = [...dirs.value, d]
|
|
||||||
dirs.value = await aiApi.setAllowedDirs(next)
|
|
||||||
newDir.value = ''
|
|
||||||
} catch (e) {
|
|
||||||
console.error('添加授权目录失败:', e)
|
|
||||||
emit('toast', t('settings.allowedDirsAddFail'), 'error')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除:即时持久化,dirs 由后端返回值回填(与添加对称,删除也立即生效)。
|
|
||||||
// SA-28: 删除即持久化且无二次确认(误点即丢一条授权),补确认弹层——复用本组件已有的
|
|
||||||
// useConfirm/内联弹层(与「信任所有目录」开关同套)。文案受「不新增 i18n 键」约束,
|
|
||||||
// 无专用键,以 common.delete 拼路径;理想应有 allowedDirsRemoveConfirm 专用键(记 note)。
|
|
||||||
async function removeDir(idx: number) {
|
|
||||||
const dir = dirs.value[idx]
|
|
||||||
if (!await confirmDialog(`${t('common.delete')}「${dir}」?`, t('common.delete'))) return
|
|
||||||
const next = dirs.value.filter((_, i) => i !== idx)
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
dirs.value = await aiApi.setAllowedDirs(next)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('删除授权目录失败:', e)
|
|
||||||
emit('toast', t('settings.allowedDirsRemoveFail'), 'error')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reload() {
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ScriptNode 命令执行安全 — 白/黑名单 UI
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// 前端持久化到 appSettings KV 表(df-script-whitelist / df-script-blacklist)。
|
|
||||||
// 后端 ScriptNode 通过 set_script_safety IPC 注入运行时白/黑名单(即时生效)。
|
|
||||||
// 本组件同时持久化到前端 KV(localStorage)和后端(app_settings 表+ OnceLock 全局态)。
|
|
||||||
// ============================================================
|
|
||||||
const scriptWhitelist = ref(appSettings.get<string>('df-script-whitelist', ''))
|
|
||||||
const scriptBlacklist = ref(appSettings.get<string>('df-script-blacklist', ''))
|
|
||||||
|
|
||||||
async function syncScriptSafety() {
|
|
||||||
try {
|
|
||||||
await settingsApi.set('df-script-whitelist', scriptWhitelist.value.trim())
|
|
||||||
await settingsApi.set('df-script-blacklist', scriptBlacklist.value.trim())
|
|
||||||
// 走新的 set_script_safety IPC 同时持久化 + 注入运行时(一次调用完成两件事)
|
|
||||||
await invoke('set_script_safety', {
|
|
||||||
whitelist: scriptWhitelist.value.trim(),
|
|
||||||
blacklist: scriptBlacklist.value.trim(),
|
|
||||||
})
|
|
||||||
emit('toast', t('settings.savedHint'), 'info')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[SCRIPT-SAFETY] 同步后端失败:', e)
|
|
||||||
emit('toast', t('settings.toastSaveFail', { msg: String(e) }), 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScriptWhitelistCommit() {
|
|
||||||
appSettings.set('df-script-whitelist', scriptWhitelist.value.trim())
|
|
||||||
syncScriptSafety()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScriptBlacklistCommit() {
|
|
||||||
appSettings.set('df-script-blacklist', scriptBlacklist.value.trim())
|
|
||||||
syncScriptSafety()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
|
|
||||||
已迁移到 settings.css 全局。
|
|
||||||
本 scoped 仅保留 AllowedDirsPanel 特有样式:allowed-dirs/dir-row/dir-path/
|
|
||||||
add-row/dir-input/actions。 */
|
|
||||||
.allowed-dirs { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.allowed-dirs-desc {
|
|
||||||
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
|
|
||||||
}
|
|
||||||
.dir-row {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--df-bg);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
}
|
|
||||||
.dir-path {
|
|
||||||
font-size: 13px; color: var(--df-text);
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
flex: 1; margin-right: 12px;
|
|
||||||
}
|
|
||||||
.add-row { display: flex; gap: 8px; margin-top: 4px; }
|
|
||||||
.dir-input { flex: 1; min-width: 0; }
|
|
||||||
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
||||||
|
|
||||||
/* ===== ScriptNode 命令执行安全面板 ===== */
|
|
||||||
.script-safety { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.script-safety-desc {
|
|
||||||
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
|
|
||||||
}
|
|
||||||
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
|
|
||||||
.script-safety-input {
|
|
||||||
width: 280px;
|
|
||||||
max-width: 100%;
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
}
|
|
||||||
.script-safety-note {
|
|
||||||
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 「信任所有目录」二次确认弹层(同 AdvancedSection) */
|
|
||||||
.confirm-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1100;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.confirm-box {
|
|
||||||
background: var(--df-bg-card);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius-lg);
|
|
||||||
padding: 20px;
|
|
||||||
min-width: 280px;
|
|
||||||
max-width: 420px;
|
|
||||||
box-shadow: var(--df-shadow-md);
|
|
||||||
}
|
|
||||||
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
|
|
||||||
</style>
|
|
||||||
@@ -83,7 +83,7 @@ interface FeishuConfig {
|
|||||||
whitelist: string
|
whitelist: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接共享 toast 通道(Settings.vue showToast),与 AllowedDirsPanel 同范式
|
// 接共享 toast 通道(Settings.vue showToast),与 ScriptSafetyPanel 同范式
|
||||||
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══ 命令执行安全:ScriptNode 白/黑名单(前端仅 UI + 持久化,后端当前从环境变量读) ═══
|
||||||
|
(原与 AI 工具授权目录同挂 AllowedDirsPanel;授权目录白名单机制退役为默认全放行
|
||||||
|
(2026-10-25 用户拍板),本组件仅保留脚本安全面板,原机制见 git 历史) -->
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>{{ $t('settings.panelScriptSafety') }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="script-safety">
|
||||||
|
<p class="script-safety-desc">{{ $t('settings.descScriptSafety') }}</p>
|
||||||
|
|
||||||
|
<SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')">
|
||||||
|
<input
|
||||||
|
v-model="scriptWhitelist"
|
||||||
|
class="setting-input script-safety-input"
|
||||||
|
:placeholder="$t('settings.placeholderScriptWhitelist')"
|
||||||
|
@blur="onScriptWhitelistCommit"
|
||||||
|
@keyup.enter="onScriptWhitelistCommit"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')">
|
||||||
|
<input
|
||||||
|
v-model="scriptBlacklist"
|
||||||
|
class="setting-input script-safety-input"
|
||||||
|
:placeholder="$t('settings.placeholderScriptBlacklist')"
|
||||||
|
@blur="onScriptBlacklistCommit"
|
||||||
|
@keyup.enter="onScriptBlacklistCommit"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { settingsApi } from '@/api'
|
||||||
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
import SettingRow from './SettingRow.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appSettings = useAppSettingsStore()
|
||||||
|
// 接共享 toast 通道(Settings.vue showToast),与 FeishuPanel 同范式
|
||||||
|
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ScriptNode 命令执行安全 — 白/黑名单 UI
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 前端持久化到 appSettings KV 表(df-script-whitelist / df-script-blacklist)。
|
||||||
|
// 后端 ScriptNode 通过 set_script_safety IPC 注入运行时白/黑名单(即时生效)。
|
||||||
|
// 本组件同时持久化到前端 KV(localStorage)和后端(app_settings 表+ OnceLock 全局态)。
|
||||||
|
// ============================================================
|
||||||
|
const scriptWhitelist = ref(appSettings.get<string>('df-script-whitelist', ''))
|
||||||
|
const scriptBlacklist = ref(appSettings.get<string>('df-script-blacklist', ''))
|
||||||
|
|
||||||
|
async function syncScriptSafety() {
|
||||||
|
try {
|
||||||
|
await settingsApi.set('df-script-whitelist', scriptWhitelist.value.trim())
|
||||||
|
await settingsApi.set('df-script-blacklist', scriptBlacklist.value.trim())
|
||||||
|
// 走新的 set_script_safety IPC 同时持久化 + 注入运行时(一次调用完成两件事)
|
||||||
|
await invoke('set_script_safety', {
|
||||||
|
whitelist: scriptWhitelist.value.trim(),
|
||||||
|
blacklist: scriptBlacklist.value.trim(),
|
||||||
|
})
|
||||||
|
emit('toast', t('settings.savedHint'), 'info')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SCRIPT-SAFETY] 同步后端失败:', e)
|
||||||
|
emit('toast', t('settings.toastSaveFail', { msg: String(e) }), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScriptWhitelistCommit() {
|
||||||
|
appSettings.set('df-script-whitelist', scriptWhitelist.value.trim())
|
||||||
|
syncScriptSafety()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScriptBlacklistCommit() {
|
||||||
|
appSettings.set('df-script-blacklist', scriptBlacklist.value.trim())
|
||||||
|
syncScriptSafety()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* panel/panel-header/btn/setting-input 系列已迁移到 settings.css 全局。
|
||||||
|
本 scoped 仅保留脚本安全面板特有样式。 */
|
||||||
|
.script-safety { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.script-safety-desc {
|
||||||
|
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
|
||||||
|
.script-safety-input {
|
||||||
|
width: 280px;
|
||||||
|
max-width: 100%;
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
}
|
||||||
|
.script-safety-note {
|
||||||
|
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
// ai-model AI 模型(provider 列表 + 负载均衡池 + 模型拉取)
|
// ai-model AI 模型(provider 列表 + 负载均衡池 + 模型拉取)
|
||||||
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
|
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
|
||||||
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
|
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
|
||||||
// security 安全(allowed_dirs)
|
// security 安全(命令执行脚本白/黑名单)
|
||||||
// integrations 集成(飞书渠道凭证)
|
// integrations 集成(飞书渠道凭证)
|
||||||
// advanced 高级(autoExecuteMode/logLevel + 连接)
|
// advanced 高级(autoExecuteMode/logLevel + 连接)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export interface SettingIndexEntry {
|
|||||||
* vector_enabled/embedding_provider/embedding_model)
|
* vector_enabled/embedding_provider/embedding_model)
|
||||||
* performance 性能(global-concurrency/per-conv-concurrency/
|
* performance 性能(global-concurrency/per-conv-concurrency/
|
||||||
* max-iterations/max-retries)
|
* max-iterations/max-retries)
|
||||||
* security 安全(allowed_dirs)
|
* security 安全(脚本白/黑名单)
|
||||||
* integrations 集成(飞书渠道凭证)
|
* integrations 集成(飞书渠道凭证)
|
||||||
* advanced 高级(autoExecuteMode/logLevel + 连接)
|
* advanced 高级(autoExecuteMode/logLevel + 连接)
|
||||||
* about 关于(版本号 / 更新检查)
|
* about 关于(版本号 / 更新检查)
|
||||||
@@ -83,7 +83,6 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
|||||||
{ category: 'performance', key: 'agentMaxRetries', labelKey: 'settings.labelAgentMaxRetries', descKey: 'settings.descAgentMaxRetries' },
|
{ category: 'performance', key: 'agentMaxRetries', labelKey: 'settings.labelAgentMaxRetries', descKey: 'settings.descAgentMaxRetries' },
|
||||||
|
|
||||||
// ===== 安全 =====
|
// ===== 安全 =====
|
||||||
{ category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' },
|
|
||||||
{ category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' },
|
{ category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' },
|
||||||
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
|
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
|
||||||
|
|
||||||
|
|||||||
@@ -33,29 +33,19 @@ const _pendingApprovalIds = new Set<string>()
|
|||||||
* 不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
* 不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
||||||
*
|
*
|
||||||
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
|
* 普通 RiskLevel 审批:调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
|
||||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。
|
* (原按 tc.kind 分派 authorizeDir 的 path 双轨已随 path_auth 退役,2026-10-25。)
|
||||||
* once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。
|
|
||||||
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
|
|
||||||
* approved=true→放行执行,approved=false→拒绝。
|
|
||||||
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
|
|
||||||
* **注意**:决策分派由 tc.kind 决定,与调用方是否传 decision 入参无关——decision 仅 path 类消费,
|
|
||||||
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
|
|
||||||
*
|
*
|
||||||
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
|
* @param approved approve=true 放行执行 / reject=false 拒绝。
|
||||||
* @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
|
||||||
*/
|
*/
|
||||||
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||||
// 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
// 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||||
if (_pendingApprovalIds.has(toolCallId)) return
|
if (_pendingApprovalIds.has(toolCallId)) return
|
||||||
_pendingApprovalIds.add(toolCallId)
|
_pendingApprovalIds.add(toolCallId)
|
||||||
try {
|
try {
|
||||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||||
const tc = findToolCall(toolCallId)
|
const tc = findToolCall(toolCallId)
|
||||||
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny);
|
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired 已 clear,
|
||||||
// 其余(risk/缺省)走 approve(approve/reject)。
|
|
||||||
const isPathKind = !!tc && tc.kind === 'path'
|
|
||||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
|
|
||||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
|
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
|
||||||
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||||
// 避免新增独立计时器与状态分支)
|
// 避免新增独立计时器与状态分支)
|
||||||
@@ -67,12 +57,10 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
const approvalConvId = state.activeConversationId || undefined
|
const approvalConvId = state.activeConversationId || undefined
|
||||||
resetStreamWatchdog(approvalConvId)
|
resetStreamWatchdog(approvalConvId)
|
||||||
try {
|
try {
|
||||||
// IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC(useToolApproval.ts),
|
// IPC 分派抽离至 dispatchApprovalIPC(useToolApproval.ts)统一收口。tc 为 null(findToolCall
|
||||||
// 与 ApprovalPopup.runApproval 同源,消除「同语义两套逻辑」漂移。
|
// 未找到,时序竞态)时直接按 id 兜底。
|
||||||
// tc 为 null(findToolCall 未找到,时序竞态)时 isPathKind=false,走 risk 兜底 approve 分支
|
|
||||||
// (与原实现等价:!!tc && tc.kind==='path' 短路)。
|
|
||||||
if (tc) {
|
if (tc) {
|
||||||
await dispatchApprovalIPC(tc, approved, decision)
|
await dispatchApprovalIPC(tc, approved)
|
||||||
} else {
|
} else {
|
||||||
await aiApi.approve(toolCallId, approved)
|
await aiApi.approve(toolCallId, approved)
|
||||||
}
|
}
|
||||||
@@ -82,7 +70,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
|
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
|
||||||
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
|
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
|
||||||
if (tc) {
|
if (tc) {
|
||||||
const isDeny = isPathKind ? (decision === 'deny') : !approved
|
const isDeny = !approved
|
||||||
if (tc.status !== 'completed') {
|
if (tc.status !== 'completed') {
|
||||||
tc.status = isDeny ? 'rejected' : 'running'
|
tc.status = isDeny ? 'rejected' : 'running'
|
||||||
}
|
}
|
||||||
@@ -119,14 +107,8 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
|
* 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall。
|
||||||
* 非目录聚合——path 类每条独立决策,不合并同目录)。
|
* decision='approve' → approved=true;decision='reject' → approved=false。
|
||||||
*
|
|
||||||
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
|
|
||||||
* 批量操作语义:
|
|
||||||
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
|
|
||||||
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
|
|
||||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
|
||||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||||
*/
|
*/
|
||||||
const _batchProcessing = ref(false)
|
const _batchProcessing = ref(false)
|
||||||
@@ -139,13 +121,7 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
|||||||
let failCount = 0
|
let failCount = 0
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
try {
|
try {
|
||||||
if (p.kind === 'path') {
|
await approveToolCall(p.id, decision === 'approve')
|
||||||
// path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
|
|
||||||
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
|
|
||||||
} else {
|
|
||||||
// risk 类:approve/reject boolean
|
|
||||||
await approveToolCall(p.id, decision === 'approve')
|
|
||||||
}
|
|
||||||
successCount++
|
successCount++
|
||||||
} catch {
|
} catch {
|
||||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
//! 系统级审批提示浮窗(独立 WebviewWindow,应用失焦/最小化时可见)。
|
|
||||||
//!
|
|
||||||
//! 设计(4 性平衡):
|
|
||||||
//! - **机制**:主窗口(App.vue)是 pendingApprovals 唯一真相源(已有 useAiEvents 维护),
|
|
||||||
//! 浮窗是「哑渲染器」—— 主窗口通过 Tauri event 推快照给浮窗,浮窗不重复监听 AI 事件
|
|
||||||
//! (避免双 listener 竞态/状态漂移)。浮窗操作按钮直调 aiApi IPC,后端 emit → 主窗口
|
|
||||||
//! listener 更新 store → 主窗口推新快照 → 浮窗刷新,闭环。
|
|
||||||
//! - **系统级**:独立 WebviewWindow,alwaysOnTop + decorations:false + skipTaskbar,
|
|
||||||
//! 无边框自定义紫色主题(非 OS notification 默认样式)。
|
|
||||||
//! - **可靠兜底**:浮窗创建失败/不可用 → 静默降级(不影响现有 ApprovalOverlay 应用内浮层)。
|
|
||||||
//! 浮窗操作 IPC 失败 → 浮窗内 toast,不阻塞主窗口审批流。
|
|
||||||
//! - **健壮边界**:pendingApprovals 清空 → 自动关浮窗;浮窗被用户手动关 → 主窗口 unlisten + 允许下次重弹。
|
|
||||||
//!
|
|
||||||
//! 事件协议(主窗口 ↔ 浮窗):
|
|
||||||
//! - 主窗口 → 浮窗:`approval-popup-update` { approvals: AiToolCallInfo[] }
|
|
||||||
//! - 浮窗 → 主窗口:`approval-popup-activate`(聚焦主窗口 + 打开 AI 面板)
|
|
||||||
//! - 浮窗 → 主窗口:`approval-popup-closed`(用户关浮窗,主窗口清引用)
|
|
||||||
//!
|
|
||||||
//! 调用方:App.vue 主窗口 onMounted watch pendingApprovals → openOrUpdate/clear。
|
|
||||||
|
|
||||||
import { watch, type WatchSource } from 'vue'
|
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
|
||||||
|
|
||||||
/** 浮窗 label(全局唯一,与 ai-detached / fe-detached 区分) */
|
|
||||||
const POPUP_LABEL = 'approval-popup'
|
|
||||||
/** 浮窗路由 hash(URL 同 AiDetached 模式,走 hash 路由) */
|
|
||||||
const POPUP_URL_HASH = '#/approval-popup'
|
|
||||||
/** 浮窗与主窗口通信的事件名 */
|
|
||||||
const EVT_UPDATE = 'approval-popup-update'
|
|
||||||
const EVT_ACTIVATE = 'approval-popup-activate'
|
|
||||||
const EVT_CLOSED = 'approval-popup-closed'
|
|
||||||
|
|
||||||
/** 模块级:当前浮窗引用 + 是否用户手动关闭(防 watch 重复弹) */
|
|
||||||
type PopupWin = { close: () => Promise<void>; destroy: () => Promise<void> }
|
|
||||||
let _popupWin: PopupWin | null = null
|
|
||||||
let _userClosed = false
|
|
||||||
let _unlistenActivate: (() => void) | null = null
|
|
||||||
let _unlistenClosed: (() => void) | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动系统级审批浮窗管理。
|
|
||||||
*
|
|
||||||
* @param source 取 pendingApprovals 的响应式源(通常是 () => store.state.pendingApprovals)
|
|
||||||
* @param onActivate 浮窗「打开审批面板」按钮回调(主窗口聚焦 + togglePanel)
|
|
||||||
* @returns cleanup 函数(onUnmounted 调用,关浮窗 + unlisten)
|
|
||||||
*/
|
|
||||||
export function startApprovalPopup(
|
|
||||||
source: WatchSource<AiToolCallInfo[]>,
|
|
||||||
onActivate: () => void,
|
|
||||||
): () => void {
|
|
||||||
// 监听浮窗 → 主窗口的 activate 事件(浮窗点「打开审批面板」)
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const { listen } = await import('@tauri-apps/api/event')
|
|
||||||
_unlistenActivate = await listen(EVT_ACTIVATE, () => {
|
|
||||||
onActivate()
|
|
||||||
})
|
|
||||||
_unlistenClosed = await listen(EVT_CLOSED, () => {
|
|
||||||
// 浮窗被用户/系统关闭:清引用 + 标记用户关(下次有新审批才重弹)
|
|
||||||
_popupWin = null
|
|
||||||
_userClosed = true
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[ApprovalPopup] 主窗口事件监听失败:', e)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
// watch pendingApprovals → 弹/更新/关浮窗
|
|
||||||
const stop = watch(source, async (approvals) => {
|
|
||||||
// 无 pending:浮窗存在则关,不存在则不弹
|
|
||||||
if (!approvals || approvals.length === 0) {
|
|
||||||
_userClosed = false // 清空时复位,下次有新审批允许弹
|
|
||||||
await closePopup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 有 pending + 用户已手动关浮窗:不再自动弹(尊重用户决定,直到 pending 清空再复位)
|
|
||||||
if (_userClosed) return
|
|
||||||
await openOrUpdate(approvals)
|
|
||||||
}, { deep: true, immediate: true })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop()
|
|
||||||
_unlistenActivate?.()
|
|
||||||
_unlistenClosed?.()
|
|
||||||
_unlistenActivate = null
|
|
||||||
_unlistenClosed = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建或更新浮窗(已存在则仅推新快照) */
|
|
||||||
async function openOrUpdate(approvals: AiToolCallInfo[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
|
||||||
const existing = await WebviewWindow.getByLabel(POPUP_LABEL)
|
|
||||||
if (existing) {
|
|
||||||
// 已存在:仅推新快照(浮窗监听 EVT_UPDATE 渲染)
|
|
||||||
_popupWin = existing
|
|
||||||
await existing.emit(EVT_UPDATE, { approvals })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 不存在:创建独立无边框浮窗
|
|
||||||
const url = window.location.origin + window.location.pathname + POPUP_URL_HASH
|
|
||||||
const win = new WebviewWindow(POPUP_LABEL, {
|
|
||||||
url,
|
|
||||||
title: 'DevFlow 审批待办',
|
|
||||||
width: 380,
|
|
||||||
height: 480,
|
|
||||||
minWidth: 320,
|
|
||||||
minHeight: 200,
|
|
||||||
resizable: true,
|
|
||||||
decorations: false, // 无系统边框/标题栏(自定义紫色主题)
|
|
||||||
alwaysOnTop: true, // 系统级:浮于所有桌面窗口上面
|
|
||||||
skipTaskbar: true, // 不占任务栏(避免与主窗口双任务栏项)
|
|
||||||
focus: false, // 不抢焦点(不打断用户当前操作,仅作可见提示)
|
|
||||||
transparent: false,
|
|
||||||
center: false, // 位置由浮窗自己定位(右上角,见 ApprovalPopup.vue)
|
|
||||||
})
|
|
||||||
_popupWin = win
|
|
||||||
win.once('tauri://created', async () => {
|
|
||||||
// 创建成功后立即推首批数据(浮窗 mount 后开始 listen,需保证时序:
|
|
||||||
// 浮窗内 onMounted listen → 主窗口 emit;但创建是异步,浮窗可能尚未 listen。
|
|
||||||
// 兜底:浮窗 mount 时主动 emit 'request-update' 拉数据,主窗口响应推完整快照。
|
|
||||||
// 此处也立即推一次(若浮窗已 listen 则直接收,未 listen 则由 request-update 兜底)
|
|
||||||
try {
|
|
||||||
await win.emit(EVT_UPDATE, { approvals })
|
|
||||||
} catch { /* 浮窗未就绪,等 request-update 兜底 */ }
|
|
||||||
})
|
|
||||||
win.once('tauri://error', (e) => {
|
|
||||||
console.error('[ApprovalPopup] 浮窗创建失败:', e)
|
|
||||||
_popupWin = null
|
|
||||||
})
|
|
||||||
win.once('tauri://destroyed', () => {
|
|
||||||
_popupWin = null
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
// 静默降级:浮窗不可用不影响现有 ApprovalOverlay 应用内浮层
|
|
||||||
console.error('[ApprovalPopup] openOrUpdate 失败,降级到应用内浮层:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** close() 超时降级 destroy() 的阈值(对齐浮窗侧 closeWithFallback 的 CLOSE_TIMEOUT_MS) */
|
|
||||||
const CLOSE_TIMEOUT_MS = 1500
|
|
||||||
|
|
||||||
/** 关闭浮窗(若存在)。
|
|
||||||
* 根因:Tauri 2.x 的 close() 走 closeRequested(非强制),偶发原生窗口未及时销毁,而主窗口侧
|
|
||||||
* 此前裸 await close() 无兜底 → 浮窗空态 hint「正在关闭…」永久残留。
|
|
||||||
* 对齐浮窗侧 closeWithFallback 语义:取引用后立即置空 _popupWin(防重复关/防关闭期间拿到旧引用),
|
|
||||||
* close 失败或超时未落定 → destroy() 强制销毁。 */
|
|
||||||
async function closePopup(): Promise<void> {
|
|
||||||
const win = _popupWin
|
|
||||||
_popupWin = null
|
|
||||||
if (!win) return
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
console.warn('[ApprovalPopup] close() 超时,降级 destroy()')
|
|
||||||
win.destroy().catch((e: unknown) => {
|
|
||||||
console.error('[ApprovalPopup] destroy() 兜底也失败:', e)
|
|
||||||
})
|
|
||||||
}, CLOSE_TIMEOUT_MS)
|
|
||||||
try {
|
|
||||||
await win.close()
|
|
||||||
clearTimeout(timer)
|
|
||||||
} catch (e) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
|
|
||||||
try {
|
|
||||||
await win.destroy()
|
|
||||||
} catch (e2) {
|
|
||||||
console.error('[ApprovalPopup] destroy() 兜底也失败:', e2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApprovalPopupEvents = {
|
|
||||||
EVT_UPDATE,
|
|
||||||
EVT_ACTIVATE,
|
|
||||||
EVT_CLOSED,
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
|
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
|
||||||
*
|
*
|
||||||
* 职责:审批按钮 loading 态、审批挂起时长显示、
|
* 职责:审批按钮 loading 态、审批挂起时长显示、审批到期倒计时、
|
||||||
* 审批超时兜底 toast、approve/reject/path_auth 三选项处理、
|
* 审批超时兜底 toast、approve/reject 处理、status 变化自动复位 + pending 计时。
|
||||||
* status 变化自动复位 + pending 计时。
|
*
|
||||||
|
* High 风险二次确认已退役(2026-10-25 批C):审批按钮本身即确认,再加确认层纯增摩擦;
|
||||||
|
* 拒绝侧无破坏性,同样不加确认。
|
||||||
*
|
*
|
||||||
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
|
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
|
||||||
*/
|
*/
|
||||||
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import i18n from '@/i18n'
|
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { getApprovalRemainingMs } from './aiShared'
|
import { getApprovalRemainingMs } from './aiShared'
|
||||||
import { formatWaitTime, waitLevelOf, type WaitLevel } from '@/utils/time'
|
import { formatWaitTime, waitLevelOf, type WaitLevel } from '@/utils/time'
|
||||||
@@ -25,25 +25,22 @@ import type { AiToolCallInfo } from '@/api/types'
|
|||||||
const APPROVE_LOADING_TIMEOUT_MS = 30_000
|
const APPROVE_LOADING_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High 风险工具「兜底」名单——仅在后端工具元数据不可用时生效
|
* 前端 HIGH_RISK_TOOLS_FALLBACK 兜底名单已退役(2026-10-25 批C):
|
||||||
* (启动早期尚未拉到 ai_list_tools / e2e 浏览器垫片无此 IPC)。
|
* 手写名单与后端注册表双写漂移(手写 7 项 vs 后端 High 17 项),兜底反制造假徽章/假高危判定。
|
||||||
* 权威来源是后端工具注册表 risk_level:启动后经 syncHighRiskTools 整体覆盖,
|
* 未拉到 registry 元数据时按 low 处理(改动最小选型):审批按钮/挂起流程不受影响
|
||||||
* 不再手工对齐 tools/*.rs 分散注册(原双写已漂移:手写 7 项,后端 High 实际 17 项)。
|
* (是否挂起由后端 risk_level 决定,前端名单只影响徽章着色),少显徽章优于乱标。
|
||||||
|
* 权威来源是后端工具注册表 risk_level,启动后经 syncHighRiskTools 覆盖。
|
||||||
*/
|
*/
|
||||||
const HIGH_RISK_TOOLS_FALLBACK = new Set<string>([
|
|
||||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
|
||||||
'http_request',
|
|
||||||
])
|
|
||||||
|
|
||||||
/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */
|
/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(按 low 处理,不显徽章) */
|
||||||
let registryHighRisk: Set<string> | null = null
|
let registryHighRisk: Set<string> | null = null
|
||||||
|
|
||||||
/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(无兜底,安全侧降级不显徽章) */
|
/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(按 low 处理,不显徽章) */
|
||||||
let registryMediumRisk: Set<string> | null = null
|
let registryMediumRisk: Set<string> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。
|
* 用后端 ai_list_tools 元数据构建 High/Medium 风险名单(权威源,整体覆盖)。
|
||||||
* 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。
|
* 空结果不覆盖:注册表为空/数据异常时保留上次名单,避免瞬时抖动清空。
|
||||||
* 供启动初始化调用(useAiPanel.loadToolRisks),幂等可重复调。
|
* 供启动初始化调用(useAiPanel.loadToolRisks),幂等可重复调。
|
||||||
*/
|
*/
|
||||||
export function syncHighRiskTools(tools: { name: string; risk: string }[]): void {
|
export function syncHighRiskTools(tools: { name: string; risk: string }[]): void {
|
||||||
@@ -53,74 +50,36 @@ export function syncHighRiskTools(tools: { name: string; risk: string }[]): void
|
|||||||
if (med.length > 0) registryMediumRisk = new Set(med)
|
if (med.length > 0) registryMediumRisk = new Set(med)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单。
|
|
||||||
* V4 导出:系统浮窗 ApprovalPopup 的批准路径与 ToolCard 同门槛复用。 */
|
|
||||||
export function isHighRiskTool(name: string): boolean {
|
|
||||||
return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具风险等级判定(high/medium/low):审批卡着色条+徽章数据源。
|
* 工具风险等级判定(high/medium/low):审批卡着色条+徽章数据源。
|
||||||
* high 优先注册表投影、未拉到时退兜底名单(与 isHighRiskTool 同口径);
|
* 仅注册表投影;未拉到元数据时一律按 low(前端名单已无兜底,少显徽章优于乱标)。
|
||||||
* medium 仅注册表投影,无兜底——未拉到元数据时不误标中危(安全侧降级:少显徽章优于乱标)。
|
|
||||||
*/
|
*/
|
||||||
export function toolRiskLevel(name: string): 'high' | 'medium' | 'low' {
|
export function toolRiskLevel(name: string): 'high' | 'medium' | 'low' {
|
||||||
if ((registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)) return 'high'
|
if (registryHighRisk?.has(name)) return 'high'
|
||||||
if (registryMediumRisk?.has(name)) return 'medium'
|
if (registryMediumRisk?.has(name)) return 'medium'
|
||||||
return 'low'
|
return 'low'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。
|
* 审批 IPC 分派(共享):普通 RiskLevel 审批调 aiApi.approve。
|
||||||
*
|
* 原按 tc.kind 分派 authorizeDir 的双轨随 path_auth 退役(2026-10-25)收敛为单轨。
|
||||||
* 抽离自 useAiApproval.approveToolCall 与 ApprovalPopup.onApprove/onReject 两处重复实现,
|
|
||||||
* 供两调用方共用同一分派规则,消除「同语义两套逻辑」漂移风险。
|
|
||||||
*
|
|
||||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision)。decision 必传
|
|
||||||
* ('once'|'session'|'always'|'deny'),由调用方按按钮语义给出;缺省兜底 'deny'(安全侧拒绝)。
|
|
||||||
* - 其余(risk/缺省,普通 RiskLevel 审批):调 aiApi.approve(toolCallId, approved)。
|
|
||||||
* approved=true 放行,false 拒绝。
|
|
||||||
*
|
*
|
||||||
* 不含状态机(loading/防抖/超时兜底)——那些是调用方各自的责任(ToolCard 走 useToolApproval
|
* 不含状态机(loading/防抖/超时兜底)——那些是调用方各自的责任(ToolCard 走 useToolApproval
|
||||||
* 状态机 + emit;ApprovalPopup 走 processingId 局部 ref;useAiApproval 走 _pendingApprovalIds 防抖)。
|
* 状态机 + emit;useAiApproval 走 _pendingApprovalIds 防抖)。
|
||||||
* 本函数仅做「kind → IPC」一层映射,纯 IPC 调用,无 store 依赖,可在任意窗口/上下文调用。
|
|
||||||
*
|
*
|
||||||
* @returns invoke 的 Promise(调用方可 await 接力错误处理)
|
* @returns invoke 的 Promise(调用方可 await 接力错误处理)
|
||||||
*/
|
*/
|
||||||
export function dispatchApprovalIPC(
|
export function dispatchApprovalIPC(tc: AiToolCallInfo, approved: boolean): Promise<string> {
|
||||||
tc: AiToolCallInfo,
|
|
||||||
approved: boolean,
|
|
||||||
decision?: 'once' | 'session' | 'always' | 'deny',
|
|
||||||
): Promise<string> {
|
|
||||||
if (tc.kind === 'path') {
|
|
||||||
return aiApi.authorizeDir(tc.id, decision ?? 'deny')
|
|
||||||
}
|
|
||||||
return aiApi.approve(tc.id, approved)
|
return aiApi.approve(tc.id, approved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** V4:High 风险工具二次确认文案(模块级,供 ApprovalPopup 在无组件上下文复用;
|
|
||||||
* i18n 全局实例取 t,与 useI18n 同源 locale) */
|
|
||||||
export function highRiskConfirmText(name: string): string {
|
|
||||||
// i18n.global 整体访问触发 vue-i18n 泛型递归 TS2589,先断言窄化再取 t
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const g = i18n.global as any
|
|
||||||
const gt = g.t as (key: string) => string
|
|
||||||
if (name === 'run_command') return gt('aiTool.confirmHighExec')
|
|
||||||
if (name === 'http_request') return gt('aiTool.confirmHighHttp')
|
|
||||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
|
||||||
return gt('aiTool.confirmHighDelete')
|
|
||||||
}
|
|
||||||
return gt('aiTool.confirmHighGeneric')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** useToolApproval emit 签名 — 补全类型替代 any */
|
/** useToolApproval emit 签名 — 补全类型替代 any */
|
||||||
interface ToolApprovalEmit {
|
interface ToolApprovalEmit {
|
||||||
(event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void
|
(event: 'approve', payload: { id: string; approved: boolean }): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit) {
|
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
||||||
|
|
||||||
// ── 审批 loading 态 ──
|
// ── 审批 loading 态 ──
|
||||||
const approving = ref(false)
|
const approving = ref(false)
|
||||||
@@ -149,7 +108,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
waitSecs.value = 0
|
waitSecs.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatWaitTime/waitLevelOf 收敛至 @/utils/time(原与 ApprovalPopup 逐字双写)
|
// formatWaitTime/waitLevelOf 收敛至 @/utils/time
|
||||||
|
|
||||||
const waitLevel = computed<WaitLevel>(() => waitLevelOf(waitSecs.value))
|
const waitLevel = computed<WaitLevel>(() => waitLevelOf(waitSecs.value))
|
||||||
|
|
||||||
@@ -178,45 +137,26 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** High 风险工具的二次确认文案(按操作类型;V4 提升为模块级导出,浮窗复用) */
|
|
||||||
function highRiskConfirmMsg(name: string): string {
|
|
||||||
return highRiskConfirmText(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批准按钮处理(risk 类专用)。
|
* 批准按钮处理(risk 类专用)。
|
||||||
* High 风险工具 → 先二次确认(useConfirm 弹层)。
|
* 审批按钮本身即确认——High 风险二次确认已退役(2026-10-25 批C),点击直接分派。
|
||||||
* loading + 超时兜底。approving 提前到 confirmDialog 前,防二次确认期间双击重入。
|
* loading + 超时兜底。
|
||||||
*/
|
*/
|
||||||
async function onApprove() {
|
async function onApprove() {
|
||||||
if (approving.value) return // 防重入
|
if (approving.value) return // 防重入
|
||||||
// 先锁防重入(含二次确认 await 期间)
|
|
||||||
approving.value = true
|
approving.value = true
|
||||||
try {
|
approvingTimer = setTimeout(() => {
|
||||||
// High 风险 → 先二次确认
|
approving.value = false
|
||||||
if (isHighRiskTool(tc.value.name)) {
|
approvingTimer = null
|
||||||
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
|
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
||||||
if (!ok) {
|
showApproveTimeoutToast()
|
||||||
approving.value = false // 取消二次确认 → 解锁
|
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||||
return
|
emit('approve', { id: tc.value.id, approved: true })
|
||||||
}
|
|
||||||
}
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
emit('approve', { id: tc.value.id, approved: true })
|
|
||||||
} catch (e) {
|
|
||||||
approving.value = false // 异常解锁
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拒绝按钮处理(risk 类专用)。
|
* 拒绝按钮处理(risk 类专用)。
|
||||||
* loading + 超时兜底复用 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。
|
* loading + 超时兜底复用 机制。拒绝无破坏性,不加确认(无害,加确认反增摩擦)。
|
||||||
*/
|
*/
|
||||||
async function onReject() {
|
async function onReject() {
|
||||||
if (approving.value) return // 防重入
|
if (approving.value) return // 防重入
|
||||||
@@ -230,86 +170,6 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
emit('approve', { id: tc.value.id, approved: false })
|
emit('approve', { id: tc.value.id, approved: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。
|
|
||||||
* 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。
|
|
||||||
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。
|
|
||||||
*/
|
|
||||||
async function onAuthorizeOnce() {
|
|
||||||
if (approving.value) return
|
|
||||||
approving.value = true
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
emit('approve', { id: tc.value.id, approved: false, decision: 'once' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M4:path 类「当前会话授权」处理(decision='session')。
|
|
||||||
* 对齐 DirAuthDialog 四按钮(once/session/always/deny)。session 写会话临时授权层,切会话清;
|
|
||||||
* IPC authorizeDir 后端 'session' 全支持(api/ai.ts authorizeDir 签名 + 后端 ai_authorize_dir)。
|
|
||||||
* 同 onAuthorizeOnce 同款 loading + 超时兜底;无 High 风险二次确认(同 once/always 语义,显式白名单写入)。
|
|
||||||
*/
|
|
||||||
async function onAuthorizeSession() {
|
|
||||||
if (approving.value) return
|
|
||||||
approving.value = true
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
emit('approve', { id: tc.value.id, approved: false, decision: 'session' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* path_auth 审批链阶段3b:path 类「写持久白名单」处理。
|
|
||||||
* 同 onAuthorizeOnce,decision='always'。
|
|
||||||
*/
|
|
||||||
async function onAuthorizeAlways() {
|
|
||||||
if (approving.value) return
|
|
||||||
approving.value = true
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
emit('approve', { id: tc.value.id, approved: false, decision: 'always' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* path_auth 审批链阶段3b:path 类「拒绝」处理。
|
|
||||||
* 拒绝前二次确认(useConfirm 弹层)。拒绝不可逆——工具返 Err、loop 续跑不再重试该工具,
|
|
||||||
* 与放行(once/session/always 均可后续再授权)不对称,误点代价高;补确认与 approve(高风险)对称。
|
|
||||||
* risk 类拒绝(onReject)不补确认:risk reject 是常规审批否决,语义明确且可重新触发;仅 path 类加。
|
|
||||||
* approving 提前到 confirmDialog 前,防二次确认期间双击重入。
|
|
||||||
*/
|
|
||||||
async function onDeny() {
|
|
||||||
if (approving.value) return
|
|
||||||
approving.value = true
|
|
||||||
try {
|
|
||||||
const ok = await confirmDialog(t('aiChat.dirAuthDenyConfirm'))
|
|
||||||
if (!ok) {
|
|
||||||
approving.value = false // 取消二次确认 → 解锁
|
|
||||||
return
|
|
||||||
}
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
emit('approve', { id: tc.value.id, approved: false, decision: 'deny' })
|
|
||||||
} catch (e) {
|
|
||||||
approving.value = false // 异常解锁
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。
|
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。
|
||||||
watch(() => tc.value.status, (s) => {
|
watch(() => tc.value.status, (s) => {
|
||||||
if (s !== 'pending_approval') {
|
if (s !== 'pending_approval') {
|
||||||
@@ -337,12 +197,5 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
expireLevel,
|
expireLevel,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
onAuthorizeOnce,
|
|
||||||
onAuthorizeSession,
|
|
||||||
onAuthorizeAlways,
|
|
||||||
onDeny,
|
|
||||||
// 供模板 ConfirmDialog 绑定
|
|
||||||
confirmState,
|
|
||||||
answerConfirm,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-19
@@ -236,25 +236,7 @@ export default {
|
|||||||
labelAgentMaxRetries: 'Stream failure retries',
|
labelAgentMaxRetries: 'Stream failure retries',
|
||||||
descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned',
|
descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned',
|
||||||
|
|
||||||
// ===== F-260619-03 Phase A: AI tool allowed dirs panel =====
|
// AI tool allowed-dirs whitelist retired to allow-all-by-default (user decision 2026-10-25); related i18n keys removed
|
||||||
panelAllowedDirs: '📁 AI Tool Allowed Dirs',
|
|
||||||
descAllowedDirs: 'AI file tools (read/write/list/patch/search) can only access the following authorized directories. The project root (devflow itself) is always implicitly authorized and need not be added. Access to directories not in the list will be rejected.',
|
|
||||||
allowedDirsLoading: 'Loading…',
|
|
||||||
allowedDirsEmpty: 'No extra authorized directories (only project root is authorized by default)',
|
|
||||||
allowedDirsInputPlaceholder: 'Enter absolute path, e.g. E:/wk-lab/u-abc',
|
|
||||||
allowedDirsSelectDir: 'Select Dir',
|
|
||||||
allowedDirsAdd: 'Add',
|
|
||||||
allowedDirsSave: 'Save',
|
|
||||||
allowedDirsReload: 'Reload',
|
|
||||||
allowedDirsLoadFail: 'Failed to load authorized directories',
|
|
||||||
allowedDirsDup: 'This directory is already in the authorized list',
|
|
||||||
allowedDirsAddFail: 'Failed to add authorized directory',
|
|
||||||
allowedDirsRemoveFail: 'Failed to remove authorized directory',
|
|
||||||
// "Trust all directories" master switch (Settings - Security - AI Tool Allowed Dirs panel)
|
|
||||||
labelPathAuthBypass: 'Trust all directories',
|
|
||||||
descPathAuthBypass: 'When enabled, AI can directly access any directory, including sensitive system and credential folders, without per-access prompts. Only enable in trusted environments; exposing sensitive directories risks credential leakage.',
|
|
||||||
pathAuthBypassConfirmTitle: 'Enable "Trust all directories"?',
|
|
||||||
pathAuthBypassConfirmBody: 'Once enabled, AI tools will no longer ask for per-access authorization and can directly reach any path, including sensitive directories such as .ssh and .aws. Sensitive credentials may end up in the conversation context; proceed at your own risk. Are you sure you want to enable it?',
|
|
||||||
|
|
||||||
// Anthropic prompt caching (Settings - AI Model; KV df-ai-anthropic-cache, default on, effective immediately)
|
// Anthropic prompt caching (Settings - AI Model; KV df-ai-anthropic-cache, default on, effective immediately)
|
||||||
panelAnthropicCache: 'AI Prompt Caching',
|
panelAnthropicCache: 'AI Prompt Caching',
|
||||||
|
|||||||
@@ -235,25 +235,7 @@ export default {
|
|||||||
labelAgentMaxRetries: '流式失败重试次数',
|
labelAgentMaxRetries: '流式失败重试次数',
|
||||||
descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
|
descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
|
||||||
|
|
||||||
// ===== F-260619-03 Phase A: AI 工具授权目录面板 =====
|
// AI 工具授权目录白名单机制已退役为默认全放行(2026-10-25 用户拍板),相关 i18n 键同批删除
|
||||||
panelAllowedDirs: '📁 AI 工具授权目录',
|
|
||||||
descAllowedDirs: 'AI 文件工具(读/写/列/改/搜等)仅可访问以下授权目录。项目根目录(devflow 自身)始终隐式授权,无需在此添加。未在列表内的目录访问将被拒绝。',
|
|
||||||
allowedDirsLoading: '加载中…',
|
|
||||||
allowedDirsEmpty: '暂无额外授权目录(仅项目根目录默认授权)',
|
|
||||||
allowedDirsInputPlaceholder: '输入授权目录绝对路径,如 E:/wk-lab/u-abc',
|
|
||||||
allowedDirsSelectDir: '选择目录',
|
|
||||||
allowedDirsAdd: '添加',
|
|
||||||
allowedDirsSave: '保存',
|
|
||||||
allowedDirsReload: '重载',
|
|
||||||
allowedDirsLoadFail: '加载授权目录失败',
|
|
||||||
allowedDirsDup: '该目录已在授权列表中',
|
|
||||||
allowedDirsAddFail: '添加授权目录失败',
|
|
||||||
allowedDirsRemoveFail: '删除授权目录失败',
|
|
||||||
// 「信任所有目录」总开关(设置-安全-AI 工具授权目录面板)
|
|
||||||
labelPathAuthBypass: '信任所有目录',
|
|
||||||
descPathAuthBypass: '开启后 AI 可直接访问任意目录(含系统与凭据等敏感目录),不再逐次询问。仅建议在受信任环境开启,放开敏感目录有凭据泄露风险。',
|
|
||||||
pathAuthBypassConfirmTitle: '开启「信任所有目录」?',
|
|
||||||
pathAuthBypassConfirmBody: '开启后 AI 工具将无需逐次授权,直接访问包括 .ssh、.aws 等敏感目录在内的任意路径,敏感凭据可能进入对话上下文,风险自担。确定开启?',
|
|
||||||
|
|
||||||
// Anthropic prompt caching(设置-AI 模型;KV df-ai-anthropic-cache,默认开,改后即时生效)
|
// Anthropic prompt caching(设置-AI 模型;KV df-ai-anthropic-cache,默认开,改后即时生效)
|
||||||
panelAnthropicCache: 'AI Prompt 缓存',
|
panelAnthropicCache: 'AI Prompt 缓存',
|
||||||
|
|||||||
@@ -1,820 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- ═══ 系统级审批提示浮窗(独立 WebviewWindow 内容)═══
|
|
||||||
设计:
|
|
||||||
- 无边框(decorations:false) + alwaysOnTop + skipTaskbar → 系统级可见
|
|
||||||
- 紫色主题自定义样式(非 OS notification 默认)
|
|
||||||
- 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件
|
|
||||||
- 操作按钮走 dispatchApprovalIPC(按 tc.kind 分派 aiApi.approve/authorizeDir;主窗口 listener 收后端 emit → 推新快照)
|
|
||||||
- 点击「打开审批面板」emit approval-popup-activate → 主窗口聚焦 + togglePanel
|
|
||||||
- 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动
|
|
||||||
- 自动定位屏幕右上角(同 ApprovalOverlay 视觉一致) -->
|
|
||||||
<div class="popup-shell" :data-dragging="isDragging">
|
|
||||||
<!-- 顶部拖动条(双击不最大化,因 decorations:false) -->
|
|
||||||
<div class="popup-header" data-tauri-drag-region @mousedown="onDragStart" @mouseup="onDragEnd">
|
|
||||||
<div class="popup-header-icon">
|
|
||||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M9 11l3 3L22 4" />
|
|
||||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
||||||
</svg>
|
|
||||||
<span v-if="approvals.length > 1" class="popup-header-badge">{{ approvals.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div>
|
|
||||||
<button class="popup-header-close" :title="$t('common.close')" :aria-label="$t('common.close')" @click.stop="onClose">
|
|
||||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 待审批列表 -->
|
|
||||||
<div class="popup-body">
|
|
||||||
<div v-if="approvals.length === 0" class="popup-empty">
|
|
||||||
<!-- BUG2 修复:空态不再永久转圈(原 popup-empty-spinner 致「处理完仍转圈」误判卡死)。
|
|
||||||
空态正常会被 maybeAutoClose 即时关窗;此 hint 仅作防御性兜底(close 失败时给可见反馈)。 -->
|
|
||||||
<span class="popup-empty-hint">{{ $t('aiChat.popupEmptyHint') }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="tc in approvals"
|
|
||||||
:key="tc.id"
|
|
||||||
class="popup-item"
|
|
||||||
:class="'popup-item--' + (tc.kind || 'risk')"
|
|
||||||
>
|
|
||||||
<!-- 工具名 + kind 徽章 -->
|
|
||||||
<div class="popup-item-header">
|
|
||||||
<span class="popup-item-name">{{ toolDisplayName(tc) }}</span>
|
|
||||||
<span v-if="tc.kind === 'path'" class="popup-item-tag popup-item-tag--path">
|
|
||||||
{{ $t('aiChat.popupTagPath') }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="popup-item-tag popup-item-tag--risk">
|
|
||||||
{{ $t('aiChat.popupTagRisk') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) -->
|
|
||||||
<div v-if="tc.reason" class="popup-item-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
|
|
||||||
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
|
|
||||||
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
|
|
||||||
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
|
|
||||||
<div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelOf(waitSecsFor(tc.id))">
|
|
||||||
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }}
|
|
||||||
</div>
|
|
||||||
<!-- path 类:目录提示 -->
|
|
||||||
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
|
|
||||||
<span aria-hidden="true">📁</span> {{ tc.dir }}
|
|
||||||
</div>
|
|
||||||
<!-- 操作按钮组:path 类显 once/session/always/deny 四选项(与 ToolCard/DirAuthDialog 同款);
|
|
||||||
risk 类显 approve/reject 两选项。IPC 分派统一走 dispatchApprovalIPC。 -->
|
|
||||||
<div v-if="tc.kind === 'path'" class="popup-item-actions popup-item-actions--path">
|
|
||||||
<button
|
|
||||||
class="popup-btn popup-btn--approve"
|
|
||||||
:disabled="processingId === tc.id"
|
|
||||||
@click.stop="onApproveOnce(tc)"
|
|
||||||
>
|
|
||||||
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
|
||||||
<svg aria-hidden="true" v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
{{ $t('aiChat.dirAuthOnce') }}<span class="popup-btn-key">1</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="popup-btn popup-btn--session"
|
|
||||||
:disabled="processingId === tc.id"
|
|
||||||
@click.stop="onApproveSession(tc)"
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
{{ $t('aiChat.dirAuthSession') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="popup-btn popup-btn--always"
|
|
||||||
:disabled="processingId === tc.id"
|
|
||||||
@click.stop="onApproveAlways(tc)"
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
{{ $t('aiChat.dirAuthAlways') }}<span class="popup-btn-key">2</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="popup-btn popup-btn--reject"
|
|
||||||
:disabled="processingId === tc.id"
|
|
||||||
@click.stop="onReject(tc)"
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
{{ $t('aiChat.dirAuthDeny') }}<span class="popup-btn-key">3</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="popup-item-actions">
|
|
||||||
<button
|
|
||||||
class="popup-btn popup-btn--approve"
|
|
||||||
:disabled="processingId === tc.id"
|
|
||||||
@click.stop="onApprove(tc)"
|
|
||||||
>
|
|
||||||
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
|
||||||
<svg aria-hidden="true" v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
{{ $t('aiTool.approve') }}<span class="popup-btn-key">1</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="popup-btn popup-btn--reject"
|
|
||||||
:disabled="processingId === tc.id"
|
|
||||||
@click.stop="onReject(tc)"
|
|
||||||
>
|
|
||||||
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
{{ $t('aiTool.reject') }}<span class="popup-btn-key">3</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部:打开审批面板(聚焦主窗口) -->
|
|
||||||
<button class="popup-footer" @click="onActivate">
|
|
||||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" />
|
|
||||||
</svg>
|
|
||||||
{{ $t('aiChat.popupOpenPanel') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- P1-e-d:审批失败 toast(窗口内自包含 useToast;独立 WebviewWindow 无根 toast,自管渲染);
|
|
||||||
样式消费全局 .df-toast 族,error 映射 danger,定位经 scoped 覆写让出 header -->
|
|
||||||
<Transition name="df-toast">
|
|
||||||
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- M5:path 类 deny 二次确认(拒绝不可逆,与 ToolCard 同款;独立窗口本地 ConfirmDialog,
|
|
||||||
不复用主窗口 useConfirm reactive——跨 WebviewWindow JS 上下文隔离) -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:visible="confirmState.visible"
|
|
||||||
:msg="confirmState.msg"
|
|
||||||
:danger-label="$t('common.confirm')"
|
|
||||||
@result="answerConfirm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
|
||||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
|
||||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
|
||||||
import { dispatchApprovalIPC, isHighRiskTool, highRiskConfirmText } from '@/composables/ai/useToolApproval'
|
|
||||||
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
|
||||||
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
||||||
import { useToast } from '@/composables/useToast'
|
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
/** 主窗口推送的快照(唯一真相源,本组件不监听 AI 事件) */
|
|
||||||
const approvals = ref<AiToolCallInfo[]>([])
|
|
||||||
/** 操作中 id(防抖 + 按钮加载态) */
|
|
||||||
const processingId = ref<string>('')
|
|
||||||
// P1-e-d: 审批失败 toast——独立 WebviewWindow 无共享组件树,窗口内自包含 useToast
|
|
||||||
// (不走跨窗口 emit 中转,失败反馈一定显示在用户当前关注的浮窗内)
|
|
||||||
const { toast, showToast } = useToast()
|
|
||||||
/** 拖动态(样式反馈) */
|
|
||||||
const isDragging = ref(false)
|
|
||||||
|
|
||||||
// ── M5/V4:浮窗本地二次确认(独立窗口本地 confirm;与主窗口 JS 上下文隔离,不复用 useConfirm)──
|
|
||||||
// M5:path 类 deny(拒绝不可逆——工具返 Err、loop 续跑不再重试)。
|
|
||||||
// V4:High 风险工具 risk 批准前置确认(对齐 ToolCard 安全语义:注意力分散场景数字键「1」
|
|
||||||
// 单键放行 run_command/delete_file 太危险)。action 记录「待确认动作」供 answerConfirm 分派。
|
|
||||||
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null; action: 'deny' | 'approve' }>({
|
|
||||||
visible: false,
|
|
||||||
msg: '',
|
|
||||||
tc: null,
|
|
||||||
action: 'deny',
|
|
||||||
})
|
|
||||||
function answerConfirm(ok: boolean) {
|
|
||||||
const tc = confirmState.tc
|
|
||||||
const action = confirmState.action
|
|
||||||
confirmState.visible = false
|
|
||||||
confirmState.tc = null
|
|
||||||
if (ok && tc) {
|
|
||||||
if (action === 'approve') void runApproval(tc, true)
|
|
||||||
else void runApproval(tc, false, 'deny')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)──
|
|
||||||
// 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖,
|
|
||||||
// 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻)
|
|
||||||
// 每秒刷新。格式化(formatWaitTime)与分级阈值(waitLevelOf,120s long / 300s urgent)
|
|
||||||
// 收敛至 @/utils/time,与 ToolCard 同源。
|
|
||||||
/** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */
|
|
||||||
const arrivalTimes = reactive(new Map<string, number>())
|
|
||||||
/** tc.id → 已等待秒数(每秒 tick 更新)。 */
|
|
||||||
const waitSecsMap = reactive(new Map<string, number>())
|
|
||||||
let _waitTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
/** 取某条审批已等待秒数(无记录返回 0) */
|
|
||||||
function waitSecsFor(id: string): number {
|
|
||||||
return waitSecsMap.get(id) ?? 0
|
|
||||||
}
|
|
||||||
/** 每秒刷新所有挂起审批的 waitSecs(单 timer 驱动多 tc,避免每条一个 interval) */
|
|
||||||
function _tickWaitSecs() {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [id, t0] of arrivalTimes.entries()) {
|
|
||||||
waitSecsMap.set(id, Math.floor((now - t0) / 1000))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步到达时间到当前 approvals 快照。
|
|
||||||
* - 新出现的 id(popup 首次见到该审批)→ 记 Date.now() 起算。
|
|
||||||
* - 已不在快照的 id(被处理 / 后端清掉)→ 清 arrival + waitSecs,防 Map 无界增长 + 防幽灵计时。
|
|
||||||
* - 已记录的 id 保留原时刻(快照重推不影响计时,审批真实挂起时长不被重置)。
|
|
||||||
*/
|
|
||||||
function _syncArrivalTimes(list: AiToolCallInfo[]) {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const tc of list) {
|
|
||||||
seen.add(tc.id)
|
|
||||||
if (!arrivalTimes.has(tc.id)) {
|
|
||||||
arrivalTimes.set(tc.id, Date.now())
|
|
||||||
waitSecsMap.set(tc.id, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of Array.from(arrivalTimes.keys())) {
|
|
||||||
if (!seen.has(id)) {
|
|
||||||
arrivalTimes.delete(id)
|
|
||||||
waitSecsMap.delete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _unlistenUpdate: UnlistenFn | null = null
|
|
||||||
let _positioned = false
|
|
||||||
/** close() 后窗口仍未销毁则强制 destroy() 的阈值(closeWithFallback 使用) */
|
|
||||||
const CLOSE_TIMEOUT_MS = 1500
|
|
||||||
|
|
||||||
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
|
|
||||||
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。
|
|
||||||
*
|
|
||||||
* 7c:数字键快捷审批(对列表顶部第一条生效,多审批场景用户应逐条点或用方向键导航)。
|
|
||||||
* - path 类:1=once / 2=always / 3=deny(deny 仍走二次确认,与按钮同款不可逆守卫)
|
|
||||||
* - risk 类:1=approve / 3=reject(2 仅 path 类有 always,risk 类忽略 '2')
|
|
||||||
* 守卫:
|
|
||||||
* - 确认对话框打开时(confirmState.visible)禁用快捷键,避免与确认按钮的 Enter/Esc 冲突
|
|
||||||
* - 处理中(processingId)时忽略,防重复触发
|
|
||||||
* - 输入元素聚焦时不拦截(input/textarea/contenteditable 场景,本浮窗暂无 input 但防御)
|
|
||||||
* - Ctrl/Meta/Alt 组合不拦截(避免劫持浏览器/系统快捷键) */
|
|
||||||
function _onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
void onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 7c:数字键快捷审批
|
|
||||||
if (e.key === '1' || e.key === '2' || e.key === '3') {
|
|
||||||
// 组合键(Ctrl+1 等)不拦截;确认对话框打开时不拦截;处理中不拦截
|
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return
|
|
||||||
if (confirmState.visible) return
|
|
||||||
if (processingId.value) return
|
|
||||||
// 输入元素聚焦时不拦截(防御:本浮窗无 input,但 ConfirmDialog 未来或有)
|
|
||||||
const tgt = e.target as HTMLElement | null
|
|
||||||
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return
|
|
||||||
const first = approvals.value[0]
|
|
||||||
if (!first) return
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.key === '1') {
|
|
||||||
// 1 = once(path) / approve(risk)
|
|
||||||
if (first.kind === 'path') void onApproveOnce(first)
|
|
||||||
else void onApprove(first)
|
|
||||||
} else if (e.key === '2') {
|
|
||||||
// 2 = always(仅 path 类;risk 类无 always,忽略)
|
|
||||||
if (first.kind === 'path') void onApproveAlways(first)
|
|
||||||
} else {
|
|
||||||
// 3 = deny(path) / reject(risk)
|
|
||||||
void onReject(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// 首屏定位到屏幕右上角(只定位一次,后续保留用户拖动后位置)
|
|
||||||
if (!_positioned) {
|
|
||||||
_positioned = true
|
|
||||||
try {
|
|
||||||
const { currentMonitor } = await import('@tauri-apps/api/window')
|
|
||||||
const { LogicalPosition } = await import('@tauri-apps/api/dpi')
|
|
||||||
const mon = await currentMonitor()
|
|
||||||
if (mon) {
|
|
||||||
const win = getCurrentWebviewWindow()
|
|
||||||
const w = 380
|
|
||||||
// 逻辑坐标(mon.size 是物理像素,scaleFactor 转逻辑);仅用 X 定位右侧,Y 用固定顶部 16px
|
|
||||||
const scale = mon.scaleFactor || 1
|
|
||||||
const screenW = mon.size.width / scale
|
|
||||||
const x = screenW - w - 16
|
|
||||||
const y = 16 + (mon.position.y / scale)
|
|
||||||
await win.setPosition(new LogicalPosition(x, y))
|
|
||||||
}
|
|
||||||
} catch { /* 定位失败不影响功能 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听主窗口推送的 pending 快照
|
|
||||||
try {
|
|
||||||
_unlistenUpdate = await listen<{ approvals: AiToolCallInfo[] }>(
|
|
||||||
ApprovalPopupEvents.EVT_UPDATE,
|
|
||||||
(e) => {
|
|
||||||
const next = e.payload?.approvals || []
|
|
||||||
approvals.value = next
|
|
||||||
// H1:同步到达时间(新 id 记时;消失 id 清时 + 清秒数,避免 Map 无界增长)
|
|
||||||
_syncArrivalTimes(next)
|
|
||||||
// BUG2 修复:主窗口推空快照(所有 pending 已清)→ 立即关浮窗,避免停留在空态转圈
|
|
||||||
void maybeAutoClose()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[ApprovalPopup] 监听 update 事件失败:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主动拉一次数据(主窗口可能在浮窗 listen 之前 emit,本次拉取兜底)
|
|
||||||
try {
|
|
||||||
const { emit } = await import('@tauri-apps/api/event')
|
|
||||||
// 用 request-reply 太重,直接 emit 一个轻量事件让主窗口重推快照
|
|
||||||
await emit('approval-popup-ready', {})
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Esc 快捷键关浮窗(键盘可达性:无需鼠标即可关)。Tab/Enter 原生 button 已支持,不另加。
|
|
||||||
// _onKeyDown 是模块级引用,onBeforeUnmount 据此 removeEventListener(保证移除的是同一引用)
|
|
||||||
window.addEventListener('keydown', _onKeyDown)
|
|
||||||
|
|
||||||
// H1:启动挂起时长计时(每秒刷新 waitSecsMap)。单 timer 驱动所有 tc,
|
|
||||||
// onBeforeUnmount 清理。立即 tick 一次让首屏(若已有 approvals)马上显示。
|
|
||||||
_tickWaitSecs()
|
|
||||||
if (_waitTimer) clearInterval(_waitTimer)
|
|
||||||
_waitTimer = setInterval(_tickWaitSecs, 1000)
|
|
||||||
|
|
||||||
// 弹出即空态自检:创建竞态下浮窗可能收到已过期的空快照(主窗口 openOrUpdate 推快照时
|
|
||||||
// 列表恰好已清空),此后不会再有任何 EVT_UPDATE → 无人触发关闭,空态「正在关闭…」永久残留。
|
|
||||||
// mount 2s 后仍为空则主动走 maybeAutoClose 兜底关窗(正常有数据时此自检无操作)。
|
|
||||||
setTimeout(() => {
|
|
||||||
if (approvals.value.length === 0) void maybeAutoClose()
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
_unlistenUpdate?.()
|
|
||||||
_unlistenUpdate = null
|
|
||||||
// 清 Esc listener(防组件卸载后仍监听 window 致内存泄漏/重复触发)
|
|
||||||
window.removeEventListener('keydown', _onKeyDown)
|
|
||||||
// H1:清挂起计时器(防卸载后仍 tick 致空 Map 操作/内存泄漏)
|
|
||||||
if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null }
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 关窗统一实现:close 先行(优雅),超时/失败兜底 destroy()(强制销毁)。
|
|
||||||
* 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(),而 Tauri 2.x 的
|
|
||||||
* close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发),
|
|
||||||
* Vue 组件保持挂载 → 浮窗内空态 hint「正在关闭…」永久可见,UI 卡死。
|
|
||||||
* 判定依据:close() 只是派发关闭消息即 resolve,即使关闭被原生层 prevent_close 拦截
|
|
||||||
* (窗口仅隐藏未销毁),promise 照样成功 → 不能以 resolve 作为「窗口已关」依据,
|
|
||||||
* 以 once('tauri://destroyed') 置位 destroyed 标志为准,超时触发时若尚未销毁才 destroy。
|
|
||||||
* - 兜底1:close() 失败 → 未销毁则立即 destroy() 强制销毁
|
|
||||||
* - 兜底2:close() resolve 后 1.5s 仍未销毁 → destroy() 强制销毁(超时定时器)
|
|
||||||
* - 兜底3:destroy() 也失败 → 控制台报错(不再静默吞掉,便于排查)
|
|
||||||
* 兜底定时器不取消:并发调用(onClose 与 maybeAutoClose)各自持有 timer,
|
|
||||||
* destroy 幂等无害,以 destroyed 事件兜住重复强制销毁。 */
|
|
||||||
async function closeWithFallback(): Promise<void> {
|
|
||||||
const win = getCurrentWebviewWindow()
|
|
||||||
// 以 destroyed 事件为「窗口真正销毁」的判定依据(close() resolve≠销毁,见上)
|
|
||||||
let destroyed = false
|
|
||||||
void win.once('tauri://destroyed', () => { destroyed = true })
|
|
||||||
// 超时兜底:close 后窗口迟迟未销毁则强制 destroy
|
|
||||||
setTimeout(() => {
|
|
||||||
if (destroyed) return
|
|
||||||
console.warn('[ApprovalPopup] close() 后窗口未销毁,降级 destroy()')
|
|
||||||
win.destroy().catch((e: unknown) => {
|
|
||||||
console.error('[ApprovalPopup] destroy() 兜底也失败:', e)
|
|
||||||
})
|
|
||||||
}, CLOSE_TIMEOUT_MS)
|
|
||||||
try {
|
|
||||||
await win.close()
|
|
||||||
// close() resolve 不取消兜底定时器:被 prevent_close 时 promise 照样成功而窗口未必销毁,
|
|
||||||
// 是否强制 destroy 交给 destroyed 标志与超时判定(见上)
|
|
||||||
} catch (e) {
|
|
||||||
// close 失败:未销毁则降级 destroy()
|
|
||||||
if (destroyed) return
|
|
||||||
console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
|
|
||||||
try {
|
|
||||||
await win.destroy()
|
|
||||||
} catch (e2) {
|
|
||||||
console.error('[ApprovalPopup] destroy() 兜底也失败:', e2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** path 类:本次会话授权(decision='once') */
|
|
||||||
async function onApproveOnce(tc: AiToolCallInfo) {
|
|
||||||
await runApproval(tc, false, 'once')
|
|
||||||
}
|
|
||||||
/** M4:path 类:当前会话授权(decision='session',对齐 ToolCard/DirAuthDialog 四按钮) */
|
|
||||||
async function onApproveSession(tc: AiToolCallInfo) {
|
|
||||||
await runApproval(tc, false, 'session')
|
|
||||||
}
|
|
||||||
/** path 类:写持久白名单(decision='always') */
|
|
||||||
async function onApproveAlways(tc: AiToolCallInfo) {
|
|
||||||
await runApproval(tc, false, 'always')
|
|
||||||
}
|
|
||||||
/** risk 类:批准(approved=true)。V4:High 风险工具先二次确认(按钮与数字键 1 同走本入口,
|
|
||||||
* 确认框打开时数字键守卫生效,不绕确认;拒绝仍按 M5 原逻辑)。 */
|
|
||||||
async function onApprove(tc: AiToolCallInfo) {
|
|
||||||
if (isHighRiskTool(tc.name)) {
|
|
||||||
confirmState.msg = highRiskConfirmText(tc.name)
|
|
||||||
confirmState.tc = tc
|
|
||||||
confirmState.action = 'approve'
|
|
||||||
confirmState.visible = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await runApproval(tc, true)
|
|
||||||
}
|
|
||||||
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
|
||||||
* M5:path 类拒绝前二次确认(拒绝不可逆);risk reject 直接执行。 */
|
|
||||||
async function onReject(tc: AiToolCallInfo) {
|
|
||||||
if (tc.kind === 'path') {
|
|
||||||
confirmState.msg = t('aiChat.dirAuthDenyConfirm')
|
|
||||||
confirmState.tc = tc
|
|
||||||
confirmState.visible = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await runApproval(tc, false, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 审批执行统一壳:防抖(processingId)+ 调共享 IPC 分派 + 乐观移除 + 空列表自动关浮窗。
|
|
||||||
* IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC,
|
|
||||||
* 与 useAiApproval.approveToolCall 同源,消除「同语义两套逻辑」漂移。 */
|
|
||||||
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
|
||||||
if (processingId.value === tc.id) return
|
|
||||||
processingId.value = tc.id
|
|
||||||
try {
|
|
||||||
await dispatchApprovalIPC(tc, approved, decision)
|
|
||||||
// 乐观移除(主窗口 listener 收后端 emit 后会推新快照覆盖,此乐观仅缩短视觉延迟)
|
|
||||||
approvals.value = approvals.value.filter(a => a.id !== tc.id)
|
|
||||||
// BUG2 修复:乐观移除后若列表空,立即关浮窗(避免空列表停留 popup-empty 永久转圈态)
|
|
||||||
await maybeAutoClose()
|
|
||||||
} catch (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 {
|
|
||||||
processingId.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列表清空时自动关浮窗。
|
|
||||||
* 场景:用户处理完最后一条审批 / 主窗口推空快照(EVT_UPDATE approvals:[])。
|
|
||||||
* 修复 BUG2:原实现空列表渲染 popup-empty + 永久 popup-empty-spinner,用户看到「列表空 + 一直转」
|
|
||||||
* 误以为卡死。空 = 完成,应即时关浮窗(不等待主窗口 watch closePopup 的跨窗口往返)。 */
|
|
||||||
async function maybeAutoClose() {
|
|
||||||
if (approvals.value.length > 0) return
|
|
||||||
// 先关窗(确保用户立刻看到反馈),再 emit 清主窗口引用(顺序与 onClose 一致)
|
|
||||||
// 关窗走 closeWithFallback 兜底(close 超时/失败 → destroy 强制销毁),防「正在关闭…」永久
|
|
||||||
await closeWithFallback()
|
|
||||||
try {
|
|
||||||
const { emit } = await import('@tauri-apps/api/event')
|
|
||||||
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 点击「打开审批面板」:emit 主窗口聚焦 + togglePanel */
|
|
||||||
async function onActivate() {
|
|
||||||
try {
|
|
||||||
const { emit } = await import('@tauri-apps/api/event')
|
|
||||||
await emit(ApprovalPopupEvents.EVT_ACTIVATE, {})
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 关闭浮窗(用户主动关 → emit closed 让主窗口清引用,允许下次有新审批再弹)
|
|
||||||
* 修复 BUG3:原顺序 emit→close,若 emit 跨窗口分发阻塞/异常(close 永不执行)→ 关闭按钮「无效」。
|
|
||||||
* 改为先 close(即时反馈,core:window:allow-close 已授权),再 emit 清主窗口引用;
|
|
||||||
* 即便 emit 失败,主窗口 win.once('tauri://destroyed') 也会清 _popupWin(双保险)。 */
|
|
||||||
async function onClose() {
|
|
||||||
// 关窗走 closeWithFallback 兜底(close 超时/失败 → destroy 强制销毁)
|
|
||||||
await closeWithFallback()
|
|
||||||
try {
|
|
||||||
const { emit } = await import('@tauri-apps/api/event')
|
|
||||||
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖动:JS API 主动驱动 + data-tauri-drag-region 双保险。
|
|
||||||
// 根因:Tauri 2.x data-tauri-drag-region 默认不冒泡到子元素(svg/span/title/close 按钮"吃掉"
|
|
||||||
// 鼠标事件),用户拖到子元素上拖不动(只有 header 空白缝隙可拖)。JS API startDragging() 绕过此限制——
|
|
||||||
// 主动 mousedown 调用,不依赖属性冒泡;data-tauri-drag-region 保留作 fallback(若 JS API 失败仍可拖)。
|
|
||||||
// button 区域不拖(closest('button') 跳过),保证审批按钮点击优先(mousedown 冒泡到 header 时不触发拖动)。
|
|
||||||
async function onDragStart(e: MouseEvent) {
|
|
||||||
// 仅左键触发拖动(右键/中键忽略,防误触);button 元素的 mousedown 不拖(让点击优先)
|
|
||||||
if (e.button !== 0) return
|
|
||||||
if ((e.target as HTMLElement)?.closest('button')) return
|
|
||||||
isDragging.value = true
|
|
||||||
try {
|
|
||||||
const win = getCurrentWebviewWindow()
|
|
||||||
await win.startDragging()
|
|
||||||
} catch (err) {
|
|
||||||
// JS API 失败时降级:data-tauri-drag-region 属性仍在 header 上,原生守卫接管
|
|
||||||
console.warn('[ApprovalPopup] startDragging() 失败,降级到 data-tauri-drag-region:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onDragEnd() { isDragging.value = false }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 浮窗根:无边框 + 紫色主题阴影 */
|
|
||||||
.popup-shell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: var(--df-bg-card);
|
|
||||||
border: 0.5px solid var(--df-accent);
|
|
||||||
border-left: 3px solid var(--df-accent);
|
|
||||||
border-radius: var(--df-radius-md);
|
|
||||||
box-shadow: var(--df-shadow-md), 0 0 0 1px var(--df-accent-soft);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.popup-shell[data-dragging="true"] {
|
|
||||||
box-shadow: var(--df-shadow-md), 0 0 0 2px var(--df-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部拖动条 */
|
|
||||||
.popup-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: linear-gradient(135deg, var(--df-accent-bg), transparent);
|
|
||||||
border-bottom: 0.5px solid var(--df-border);
|
|
||||||
cursor: move;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.popup-header-icon {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.popup-header-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -3px;
|
|
||||||
right: -3px;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: var(--df-radius-md);
|
|
||||||
background: var(--df-danger);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 16px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1.5px solid var(--df-bg-card);
|
|
||||||
}
|
|
||||||
.popup-header-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.popup-header-close {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.popup-header-close:hover {
|
|
||||||
background: var(--df-sidebar-hover);
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表区(可滚动) */
|
|
||||||
.popup-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-empty {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 80px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.popup-empty-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单条审批项 */
|
|
||||||
.popup-item {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: var(--df-sidebar-hover);
|
|
||||||
border-left: 2px solid var(--df-accent);
|
|
||||||
transition: background 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.popup-item--path { border-left-color: var(--df-accent); }
|
|
||||||
.popup-item--risk { border-left-color: var(--df-warning); }
|
|
||||||
.popup-item:hover {
|
|
||||||
background: var(--df-sidebar-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-item-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.popup-item-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.popup-item-tag {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--df-radius-md);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
.popup-item-tag--path {
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
.popup-item-tag--risk {
|
|
||||||
background: var(--df-warning-bg);
|
|
||||||
color: var(--df-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-item-reason {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
.popup-item-dir {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* H1:审批挂起时长提示(对齐 ToolCard .ai-tool-wait-time)。
|
|
||||||
>2min 橙色脉冲 / >5min 红色更急脉冲;pulseGlow 复用 styles/global.css 全局 keyframes。
|
|
||||||
用户主要从浮窗审批,后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝前给可见预警。 */
|
|
||||||
.popup-item-wait {
|
|
||||||
padding: 2px 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: var(--df-bg-card);
|
|
||||||
}
|
|
||||||
.popup-item-wait.is-long {
|
|
||||||
color: var(--df-warning);
|
|
||||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.popup-item-wait.is-urgent {
|
|
||||||
color: var(--df-danger);
|
|
||||||
animation: pulseGlow 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.popup-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.popup-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
/* 7c:数字键快捷键标记(1/2/3 对应 once/approve、always、deny/reject)。
|
|
||||||
小尺寸单字符徽章,提示键盘可达性;继承父按钮对比色但降透明度作辅助信息层。 */
|
|
||||||
.popup-btn-key {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
padding: 0 3px;
|
|
||||||
margin-left: 1px;
|
|
||||||
border-radius: var(--df-radius-xs);
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
background: rgba(0, 0, 0, 0.18);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.popup-btn--approve {
|
|
||||||
background: var(--df-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.popup-btn--approve:hover:not(:disabled) {
|
|
||||||
background: var(--df-accent-hover);
|
|
||||||
}
|
|
||||||
/* M4:session 按钮(弱填充态,介于 approve 强填充与 always 描边之间,视觉层级 once>session>always) */
|
|
||||||
.popup-btn--session {
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
.popup-btn--session:hover:not(:disabled) {
|
|
||||||
background: color-mix(in srgb, var(--df-accent) 30%, transparent);
|
|
||||||
}
|
|
||||||
.popup-btn--always {
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
border: 0.5px solid var(--df-accent);
|
|
||||||
}
|
|
||||||
.popup-btn--always:hover:not(:disabled) {
|
|
||||||
background: var(--df-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.popup-btn--reject {
|
|
||||||
background: var(--df-sidebar-hover);
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.popup-btn--reject:hover:not(:disabled) {
|
|
||||||
background: var(--df-danger-bg);
|
|
||||||
color: var(--df-danger);
|
|
||||||
}
|
|
||||||
.popup-btn-spinner {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border: 1.5px solid currentColor;
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: df-spin 0.6s linear infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部:打开审批面板 */
|
|
||||||
.popup-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px;
|
|
||||||
border: none;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.popup-footer:hover {
|
|
||||||
background: var(--df-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* P1-e-d:审批失败 toast:消费 global.css .df-toast 四色族+过渡(2026-10-18 整洁波并轨),
|
|
||||||
仅覆写浮窗上下文定位——top 让出拖动条 header,窄窗(380px)限宽防长错误文案溢出窗口 */
|
|
||||||
.df-toast { top: 44px; max-width: 80%; overflow-wrap: anywhere; }
|
|
||||||
</style>
|
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
@toast="showToast"
|
@toast="showToast"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 安全:allowed_dirs -->
|
<!-- 安全:命令执行脚本白/黑名单(授权目录白名单已退役为默认全放行,2026-10-25) -->
|
||||||
<AllowedDirsPanel
|
<ScriptSafetyPanel
|
||||||
v-else-if="activeCategory === 'security'"
|
v-else-if="activeCategory === 'security'"
|
||||||
@toast="showToast"
|
@toast="showToast"
|
||||||
/>
|
/>
|
||||||
@@ -110,7 +110,7 @@ import AppearanceSection from '@/components/settings/AppearanceSection.vue'
|
|||||||
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
||||||
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
||||||
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
||||||
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
import ScriptSafetyPanel from '@/components/settings/ScriptSafetyPanel.vue'
|
||||||
import FeishuPanel from '@/components/settings/FeishuPanel.vue'
|
import FeishuPanel from '@/components/settings/FeishuPanel.vue'
|
||||||
import UclawPanel from '@/components/settings/UclawPanel.vue'
|
import UclawPanel from '@/components/settings/UclawPanel.vue'
|
||||||
import UnattendedPanel from '@/components/settings/UnattendedPanel.vue'
|
import UnattendedPanel from '@/components/settings/UnattendedPanel.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user