优化: 前端走查 P2+UX(status union + ApprovalPopup hack消除/拖动JS API/Esc)
- status union: AiMessage 加 status 字面量联合,删 AiChat/MessageList AiMessageWithStatus 重复+cast - ApprovalPopup: 删 _placeholder hack(toolDisplayName 提纯函数) + dispatchApprovalIPC 共享分派消除三套重复 + always 按钮语义对齐 + closeWithFallback 兜底 + 拖动 JS API startDragging(避子元素覆盖) + Esc 快捷键
This commit is contained in:
@@ -466,6 +466,15 @@ export interface AiMessage {
|
||||
role: 'user' | 'assistant' | 'tool' | 'system'
|
||||
content: string
|
||||
isError?: boolean
|
||||
/**
|
||||
* 消息上下文状态(F-15 手动上下文管理)。
|
||||
* null | undefined | 'active' → 正常消息
|
||||
* 'archived_segment' → 清空上下文归档标记(前端折叠成"已归档 N 条"分隔条)
|
||||
* 'compressed' → 压缩标记(后端不再发给 LLM;前端照常展开)
|
||||
* 'truncated' → switchConversation 已过滤,前端不渲染
|
||||
* 字面量联合:改 status 取值时编译器拦截,避免散布 string cast 失去类型校验。
|
||||
*/
|
||||
status?: 'active' | 'archived_segment' | 'compressed' | 'truncated' | null
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */
|
||||
model?: string
|
||||
|
||||
@@ -548,19 +548,15 @@ async function toggleAlwaysOnTop() {
|
||||
// (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。
|
||||
|
||||
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
||||
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环):
|
||||
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
|
||||
// null | undefined | 'active' → 正常消息(现状)
|
||||
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
|
||||
// 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块
|
||||
// 'truncated' → switchConversation 已过滤,不到这里
|
||||
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
|
||||
interface AiMessageWithStatus extends AiMessage {
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
// 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/
|
||||
// toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。
|
||||
// AiMessageWithStatus 保留:hasActiveMessages(清空/压缩按钮启用判定,TopBar 用)仍需 cast 读 status。
|
||||
|
||||
/**
|
||||
* 是否存在"活跃消息"(可清空/可压缩)— 控制清空/压缩按钮启用。
|
||||
@@ -569,7 +565,7 @@ interface AiMessageWithStatus extends AiMessage {
|
||||
*/
|
||||
const hasActiveMessages = computed(() =>
|
||||
store.state.messages.some(m => {
|
||||
const s = (m as AiMessageWithStatus).status
|
||||
const s = m.status
|
||||
return s !== 'archived_segment' && s !== 'compressed'
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -510,15 +510,12 @@ async function onInstallObscura(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── F-15 阶段2: 消息分段(归档段折叠) ──
|
||||
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环):
|
||||
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
|
||||
// null | undefined | 'active' → 正常消息(现状)
|
||||
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
|
||||
// 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息)
|
||||
// 'truncated' → switchConversation 已过滤,不到这里
|
||||
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
|
||||
interface AiMessageWithStatus extends AiMessage {
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
type MessageSegment =
|
||||
| { kind: 'normal'; msg: AiMessage }
|
||||
@@ -531,7 +528,7 @@ const messageSegments = computed<MessageSegment[]>(() => {
|
||||
let i = 0
|
||||
while (i < msgs.length) {
|
||||
const m = msgs[i]
|
||||
const status = (m as AiMessageWithStatus).status
|
||||
const status = m.status
|
||||
// F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩
|
||||
// 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。
|
||||
// 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。
|
||||
@@ -539,7 +536,7 @@ const messageSegments = computed<MessageSegment[]>(() => {
|
||||
// 连续 archived_segment 合并
|
||||
const group: AiMessage[] = []
|
||||
const key = `seg-${m.id}`
|
||||
while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === 'archived_segment') {
|
||||
while (i < msgs.length && msgs[i].status === 'archived_segment') {
|
||||
group.push(msgs[i])
|
||||
i++
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { findToolCall } from './aiShared'
|
||||
import { dispatchApprovalIPC } from './useToolApproval'
|
||||
|
||||
/**
|
||||
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
|
||||
@@ -65,9 +66,12 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
const approvalConvId = state.activeConversationId || undefined
|
||||
resetStreamWatchdog(approvalConvId)
|
||||
try {
|
||||
if (isPathKind) {
|
||||
// path 类:decision 缺省兜底 deny(调用方未传时安全侧拒绝,不静默放行)。
|
||||
await aiApi.authorizeDir(toolCallId, decision ?? 'deny')
|
||||
// IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC(useToolApproval.ts),
|
||||
// 与 ApprovalPopup.runApproval 同源,消除「同语义两套逻辑」漂移。
|
||||
// tc 为 null(findToolCall 未找到,时序竞态)时 isPathKind=false,走 risk 兜底 approve 分支
|
||||
// (与原实现等价:!!tc && tc.kind==='path' 短路)。
|
||||
if (tc) {
|
||||
await dispatchApprovalIPC(tc, approved, decision)
|
||||
} else {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { aiApi } from '@/api'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,34 @@ const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'http_request',
|
||||
])
|
||||
|
||||
/**
|
||||
* 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。
|
||||
*
|
||||
* 抽离自 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
|
||||
* 状态机 + emit;ApprovalPopup 走 processingId 局部 ref;useAiApproval 走 _pendingApprovalIds 防抖)。
|
||||
* 本函数仅做「kind → IPC」一层映射,纯 IPC 调用,无 store 依赖,可在任意窗口/上下文调用。
|
||||
*
|
||||
* @returns invoke 的 Promise(调用方可 await 接力错误处理)
|
||||
*/
|
||||
export function dispatchApprovalIPC(
|
||||
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)
|
||||
}
|
||||
|
||||
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
|
||||
const { t } = useI18n()
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* ToolCard 头部显示逻辑(从 ToolCard.vue 抽离)。
|
||||
*
|
||||
* 职责:工具显示名 + 审批参数值的「语义化回显」(裸 id → 项目名/任务标题),
|
||||
* 以及 http url → host 精简。这些依赖 project store(响应式),故以 composable 形式封装,
|
||||
* 传入 tc 即得 { toolDisplayName, displayArgValue }。
|
||||
* 以及 http url → host 精简。
|
||||
*
|
||||
* 两层导出:
|
||||
* - `toolDisplayName(tc)` 模块级纯函数(i18n 走 i18n-helpers 全局 t,project store 走单例):
|
||||
* 无 setup 上下文依赖,可被 ToolCard(经 composable 包装)与 ApprovalPopup(独立 WebviewWindow,
|
||||
* 无 composable 上下文)共用。抽离自原 composable 内部函数,消除 ApprovalPopup 的 _placeholder hack。
|
||||
* - `useToolCardHeader(getTc)` composable:封装 displayArgValue/argsEntries(依赖 getTc 取当前 tc,
|
||||
* 响应式由 store 保证),返回 { toolDisplayName(转发模块级), displayArgValue, argsEntries }。
|
||||
*
|
||||
* 不含响应式状态(纯函数 + store 读),逻辑与原 ToolCard.vue 内联实现一字等价。
|
||||
*/
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { t as tGlobal } from '@/i18n/i18n-helpers'
|
||||
import {
|
||||
formatToolName,
|
||||
shortCmd,
|
||||
@@ -38,73 +44,43 @@ const TASK_ID_TOOL_ARG: Record<string, 'id' | 'task_id'> = {
|
||||
run_workflow: 'task_id',
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCard 头部/审批参数显示辅助。
|
||||
* @param getTc 取当前 tc 的函数(避免 props.tc 在 composable 调用时机脱节,由调用方透传)
|
||||
*/
|
||||
export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
const t = useI18n().t
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
/**
|
||||
* 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目,
|
||||
* 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。
|
||||
* 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证。
|
||||
* 复用 project store 已加载列表(useProjectStore 单例缓存,每次调用廉价),不新增网络请求;响应式由 store 数组保证。
|
||||
* 函数体内调 useProjectStore()(非模块级缓存)以保留原 lazy-init 时机,避免模块 import 时创建 store 改变初始化顺序。
|
||||
*/
|
||||
function projectNameById(id: string): string | undefined {
|
||||
const projectStore = useProjectStore()
|
||||
return projectStore.projects.find(p => p.id === id)?.name
|
||||
?? projectStore.deletedProjects.find(p => p.id === id)?.name
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 id 查任务标题:审批 advance_task/run_workflow 的 task_id 时,把裸 UUID 转标题。
|
||||
* 复用 project store 已加载的 state.tasks(仅当前项目范围),不新增网络请求。
|
||||
* 复用 project store 已加载的 state.tasks(仅当前项目范围,useProjectStore 单例),不新增网络请求。
|
||||
*/
|
||||
function taskNameById(id: string): string | undefined {
|
||||
return projectStore.tasks.find(tk => tk.id === id)?.title
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
|
||||
* 「项目已不存在」提示 + 原 id。非项目工具或其他 key 走通用 formatArgValue。
|
||||
*/
|
||||
function displayArgValue(arg: { key: string; val: unknown }): string {
|
||||
const tc = getTc()
|
||||
const projectArgKey = PROJECT_ID_TOOL_ARG[tc.name]
|
||||
if (projectArgKey && arg.key === projectArgKey) {
|
||||
const id = typeof arg.val === 'string' ? arg.val : ''
|
||||
if (id) {
|
||||
const name = projectNameById(id)
|
||||
if (name) return t('aiTool.projectLabel', { name })
|
||||
return t('aiTool.projectIdNotFound', { id })
|
||||
}
|
||||
}
|
||||
// UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题
|
||||
const taskArgKey = TASK_ID_TOOL_ARG[tc.name]
|
||||
if (taskArgKey && arg.key === taskArgKey) {
|
||||
const id = typeof arg.val === 'string' ? arg.val : ''
|
||||
if (id) {
|
||||
const name = taskNameById(id)
|
||||
if (name) return t('aiTool.taskLabel', { name })
|
||||
return t('aiTool.taskIdNotFound', { id })
|
||||
}
|
||||
}
|
||||
return formatArgValue(arg.val)
|
||||
return useProjectStore().tasks.find(tk => tk.id === id)?.title
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具显示名称(含目标摘要);文件类按 path,项目类按 id 解析项目名,create_* 用固定文案。
|
||||
*
|
||||
* 模块级纯函数:无 setup 依赖(i18n 走 i18n-helpers 的全局 t,project store 走单例),
|
||||
* 故可被 ToolCard(经 useToolCardHeader 包装)与 ApprovalPopup(独立 WebviewWindow,无 store/emit
|
||||
* 上下文)共用,消除 ApprovalPopup 原 `_placeholder` 占位 hack(构造空 AiToolCallInfo 喂 composable)。
|
||||
*/
|
||||
function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
export function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
const p = argString(tc.args, 'path')
|
||||
switch (tc.name) {
|
||||
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
|
||||
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
|
||||
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
|
||||
case 'read_file': return p ? `${tGlobal('aiTool.readPrefix')} ${shortPath(p)}` : tGlobal('aiTool.readFallback')
|
||||
case 'list_directory': return p ? `${tGlobal('aiTool.dirPrefix')} ${shortPath(p)}` : tGlobal('aiTool.dirFallback')
|
||||
case 'write_file': return p ? `${tGlobal('aiTool.writePrefix')} ${shortPath(p)}` : tGlobal('aiTool.writeFallback')
|
||||
case 'grep': {
|
||||
// grep 头部:搜索根 + pattern(折叠态可见搜索什么)
|
||||
const pat = argString(tc.args, 'pattern')
|
||||
const head = p ? `${t('aiTool.grepPrefix')} ${shortPath(p)}` : t('aiTool.grepFallback')
|
||||
const head = p ? `${tGlobal('aiTool.grepPrefix')} ${shortPath(p)}` : tGlobal('aiTool.grepFallback')
|
||||
return pat ? `${head} 「${pat}」` : head
|
||||
}
|
||||
case 'delete_project':
|
||||
@@ -116,10 +92,10 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
const name = id ? projectNameById(id) : ''
|
||||
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
|
||||
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
|
||||
return name ? `${t('aiTool.' + prefixKey)}「${name}」` : t('aiTool.' + fallbackKey)
|
||||
return name ? `${tGlobal('aiTool.' + prefixKey)}「${name}」` : tGlobal('aiTool.' + fallbackKey)
|
||||
}
|
||||
case 'create_task': return t('aiTool.createTask')
|
||||
case 'create_project': return t('aiTool.createProject')
|
||||
case 'create_task': return tGlobal('aiTool.createTask')
|
||||
case 'create_project': return tGlobal('aiTool.createProject')
|
||||
case 'run_command': {
|
||||
const cmd = argString(tc.args, 'command')
|
||||
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
|
||||
@@ -128,18 +104,51 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
const method = argString(tc.args, 'method') || 'GET'
|
||||
const url = argString(tc.args, 'url')
|
||||
const host = httpHost(url)
|
||||
return host ? `${method} ${host}` : t('aiTool.httpRequestFallback')
|
||||
return host ? `${method} ${host}` : tGlobal('aiTool.httpRequestFallback')
|
||||
}
|
||||
case 'fetch_url': {
|
||||
// fetch_url 头部:获取 host(对齐 http_request 精简模式,折叠态一眼知抓哪个站点)
|
||||
const url = argString(tc.args, 'url')
|
||||
const host = httpHost(url)
|
||||
return host ? `${t('aiTool.fetchUrlPrefix')} ${host}` : t('aiTool.fetchUrlFallback')
|
||||
return host ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback')
|
||||
}
|
||||
default: return formatToolName(tc.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCard 头部/审批参数显示辅助。
|
||||
* @param getTc 取当前 tc 的函数(避免 props.tc 在 composable 调用时机脱节,由调用方透传)
|
||||
*/
|
||||
export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
/**
|
||||
* 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
|
||||
* 「项目已不存在」提示 + 原 id。非项目工具或其他 key 走通用 formatArgValue。
|
||||
*/
|
||||
function displayArgValue(arg: { key: string; val: unknown }): string {
|
||||
const tc = getTc()
|
||||
const projectArgKey = PROJECT_ID_TOOL_ARG[tc.name]
|
||||
if (projectArgKey && arg.key === projectArgKey) {
|
||||
const id = typeof arg.val === 'string' ? arg.val : ''
|
||||
if (id) {
|
||||
const name = projectNameById(id)
|
||||
if (name) return tGlobal('aiTool.projectLabel', { name })
|
||||
return tGlobal('aiTool.projectIdNotFound', { id })
|
||||
}
|
||||
}
|
||||
// UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题
|
||||
const taskArgKey = TASK_ID_TOOL_ARG[tc.name]
|
||||
if (taskArgKey && arg.key === taskArgKey) {
|
||||
const id = typeof arg.val === 'string' ? arg.val : ''
|
||||
if (id) {
|
||||
const name = taskNameById(id)
|
||||
if (name) return tGlobal('aiTool.taskLabel', { name })
|
||||
return tGlobal('aiTool.taskIdNotFound', { id })
|
||||
}
|
||||
}
|
||||
return formatArgValue(arg.val)
|
||||
}
|
||||
|
||||
/** 审批参数键值对(头部 toolArgsEntries 透传,本 composable 仅暴露便于复用) */
|
||||
function argsEntries() {
|
||||
return toolArgsEntries(getTc().args)
|
||||
|
||||
+98
-44
@@ -4,7 +4,7 @@
|
||||
- 无边框(decorations:false) + alwaysOnTop + skipTaskbar → 系统级可见
|
||||
- 紫色主题自定义样式(非 OS notification 默认)
|
||||
- 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件
|
||||
- 操作按钮直调 aiApi.approve/authorizeDir(主窗口 listener 收后端 emit → 推新快照)
|
||||
- 操作按钮走 dispatchApprovalIPC(按 tc.kind 分派 aiApi.approve/authorizeDir;主窗口 listener 收后端 emit → 推新快照)
|
||||
- 点击「打开审批面板」emit approval-popup-activate → 主窗口聚焦 + togglePanel
|
||||
- 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动
|
||||
- 自动定位屏幕右上角(同 ApprovalOverlay 视觉一致) -->
|
||||
@@ -42,7 +42,7 @@
|
||||
>
|
||||
<!-- 工具名 + kind 徽章 -->
|
||||
<div class="popup-item-header">
|
||||
<span class="popup-item-name">{{ displayName(tc) }}</span>
|
||||
<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>
|
||||
@@ -56,8 +56,36 @@
|
||||
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
|
||||
📁 {{ tc.dir }}
|
||||
</div>
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="popup-item-actions">
|
||||
<!-- 操作按钮组:path 类显 once/always/deny 三选项(与 ToolCard 同款,补 always);
|
||||
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 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') }}
|
||||
</button>
|
||||
<button
|
||||
class="popup-btn popup-btn--always"
|
||||
:disabled="processingId === tc.id"
|
||||
@click.stop="onApproveAlways(tc)"
|
||||
>
|
||||
<svg 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') }}
|
||||
</button>
|
||||
<button
|
||||
class="popup-btn popup-btn--reject"
|
||||
:disabled="processingId === tc.id"
|
||||
@click.stop="onReject(tc)"
|
||||
>
|
||||
<svg 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') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="popup-item-actions">
|
||||
<button
|
||||
class="popup-btn popup-btn--approve"
|
||||
:disabled="processingId === tc.id"
|
||||
@@ -65,7 +93,7 @@
|
||||
>
|
||||
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
||||
<svg 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>
|
||||
{{ tc.kind === 'path' ? $t('aiChat.dirAuthOnce') : $t('aiTool.approve') }}
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button
|
||||
class="popup-btn popup-btn--reject"
|
||||
@@ -93,8 +121,8 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { aiApi } from '@/api'
|
||||
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
||||
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
|
||||
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -105,16 +133,6 @@ const processingId = ref<string>('')
|
||||
/** 拖动态(样式反馈) */
|
||||
const isDragging = ref(false)
|
||||
|
||||
// 工具显示名复用 ToolCard 同款逻辑(语义化回显:写入/读取/$ cmd/GET host 等)。
|
||||
// useToolCardHeader 的 getTc 入参仅供 displayArgValue/argsEntries 使用,toolDisplayName
|
||||
// 直接接受 tc 参数(getTc 不被 toolDisplayName 调用),故传一个无副作用占位 getter 即可。
|
||||
// 占位 getter 返回一个空 name 的对象,确保 displayArgValue 等即便误调也不会抛错。
|
||||
const _placeholder = { name: '' } as AiToolCallInfo
|
||||
const { toolDisplayName } = useToolCardHeader(() => _placeholder)
|
||||
function displayName(tc: AiToolCallInfo): string {
|
||||
return toolDisplayName(tc)
|
||||
}
|
||||
|
||||
let _unlistenUpdate: UnlistenFn | null = null
|
||||
let _positioned = false
|
||||
/** 关窗兜底:防止 close() promise 卡住或被拒导致「正在关闭…」永久停留。
|
||||
@@ -123,6 +141,15 @@ let _positioned = false
|
||||
let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CLOSE_TIMEOUT_MS = 1500
|
||||
|
||||
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
|
||||
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。 */
|
||||
function _onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
void onClose()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 首屏定位到屏幕右上角(只定位一次,后续保留用户拖动后位置)
|
||||
if (!_positioned) {
|
||||
@@ -164,11 +191,17 @@ onMounted(async () => {
|
||||
// 用 request-reply 太重,直接 emit 一个轻量事件让主窗口重推快照
|
||||
await emit('approval-popup-ready', {})
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Esc 快捷键关浮窗(键盘可达性:无需鼠标即可关)。Tab/Enter 原生 button 已支持,不另加。
|
||||
// _onKeyDown 是模块级引用,onBeforeUnmount 据此 removeEventListener(保证移除的是同一引用)
|
||||
window.addEventListener('keydown', _onKeyDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
_unlistenUpdate?.()
|
||||
_unlistenUpdate = null
|
||||
// 清 Esc listener(防组件卸载后仍监听 window 致内存泄漏/重复触发)
|
||||
window.removeEventListener('keydown', _onKeyDown)
|
||||
if (_closeFallbackTimer) {
|
||||
clearTimeout(_closeFallbackTimer)
|
||||
_closeFallbackTimer = null
|
||||
@@ -214,41 +247,37 @@ async function closeWithFallback(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批准:risk 类 approve(true),path 类 authorizeDir('once') */
|
||||
/** path 类:本次会话授权(decision='once') */
|
||||
async function onApproveOnce(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, 'once')
|
||||
}
|
||||
/** path 类:写持久白名单(decision='always') */
|
||||
async function onApproveAlways(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, 'always')
|
||||
}
|
||||
/** risk 类:批准(approved=true) */
|
||||
async function onApprove(tc: AiToolCallInfo) {
|
||||
if (processingId.value === tc.id) return
|
||||
processingId.value = tc.id
|
||||
try {
|
||||
if (tc.kind === 'path') {
|
||||
await aiApi.authorizeDir(tc.id, 'once')
|
||||
} else {
|
||||
await aiApi.approve(tc.id, true)
|
||||
}
|
||||
// 乐观移除(主窗口 listener 收后端 emit 后会推新快照覆盖,此乐观仅缩短视觉延迟)
|
||||
approvals.value = approvals.value.filter(a => a.id !== tc.id)
|
||||
// BUG2 修复:乐观移除后若列表空,立即关浮窗(避免空列表停留在 popup-empty 永久转圈态)
|
||||
await maybeAutoClose()
|
||||
} catch (e) {
|
||||
console.error('[ApprovalPopup] 批准失败:', e)
|
||||
} finally {
|
||||
processingId.value = ''
|
||||
await runApproval(tc, true)
|
||||
}
|
||||
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派) */
|
||||
async function onReject(tc: AiToolCallInfo) {
|
||||
await runApproval(tc, false, tc.kind === 'path' ? 'deny' : undefined)
|
||||
}
|
||||
|
||||
/** 拒绝:risk 类 approve(false),path 类 authorizeDir('deny') */
|
||||
async function onReject(tc: AiToolCallInfo) {
|
||||
/** 审批执行统一壳:防抖(processingId)+ 调共享 IPC 分派 + 乐观移除 + 空列表自动关浮窗。
|
||||
* IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC,
|
||||
* 与 useAiApproval.approveToolCall 同源,消除「同语义两套逻辑」漂移。 */
|
||||
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'always' | 'deny') {
|
||||
if (processingId.value === tc.id) return
|
||||
processingId.value = tc.id
|
||||
try {
|
||||
if (tc.kind === 'path') {
|
||||
await aiApi.authorizeDir(tc.id, 'deny')
|
||||
} else {
|
||||
await aiApi.approve(tc.id, false)
|
||||
}
|
||||
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)
|
||||
console.error('[ApprovalPopup] 审批失败:', e)
|
||||
} finally {
|
||||
processingId.value = ''
|
||||
}
|
||||
@@ -294,8 +323,24 @@ async function onClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 拖动样式反馈(实际拖动由 data-tauri-drag-region + Tauri dataDrag 守卫处理)
|
||||
function onDragStart() { isDragging.value = true }
|
||||
// 拖动: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>
|
||||
|
||||
@@ -500,6 +545,15 @@ function onDragEnd() { isDragging.value = false }
|
||||
.popup-btn--approve:hover:not(:disabled) {
|
||||
background: var(--df-accent-hover, #6558d8);
|
||||
}
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user