优化: 前端走查 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:
lxy
2026-08-02 10:43:30 +08:00
parent ef77120912
commit f1773dde4f
7 changed files with 234 additions and 136 deletions
+9
View File
@@ -466,6 +466,15 @@ export interface AiMessage {
role: 'user' | 'assistant' | 'tool' | 'system' role: 'user' | 'assistant' | 'tool' | 'system'
content: string content: string
isError?: boolean 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[] toolCalls?: AiToolCallInfo[]
/** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */ /** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */
model?: string model?: string
+2 -6
View File
@@ -548,19 +548,15 @@ async function toggleAlwaysOnTop() {
// (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。 // (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ── // ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环): // ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状) // null | undefined | 'active' → 正常消息(现状)
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N "分隔条 // 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N "分隔条
// 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块 // 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块
// 'truncated' → switchConversation 已过滤,不到这里 // 'truncated' → switchConversation 已过滤,不到这里
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。 // 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
interface AiMessageWithStatus extends AiMessage {
status?: string | null
}
// 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/ // 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/
// toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。 // toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。
// AiMessageWithStatus 保留:hasActiveMessages(清空/压缩按钮启用判定,TopBar 用)仍需 cast 读 status。
/** /**
* 是否存在"活跃消息"(可清空/可压缩) 控制清空/压缩按钮启用 * 是否存在"活跃消息"(可清空/可压缩) 控制清空/压缩按钮启用
@@ -569,7 +565,7 @@ interface AiMessageWithStatus extends AiMessage {
*/ */
const hasActiveMessages = computed(() => const hasActiveMessages = computed(() =>
store.state.messages.some(m => { store.state.messages.some(m => {
const s = (m as AiMessageWithStatus).status const s = m.status
return s !== 'archived_segment' && s !== 'compressed' return s !== 'archived_segment' && s !== 'compressed'
}), }),
) )
+3 -6
View File
@@ -510,15 +510,12 @@ async function onInstallObscura(): Promise<void> {
} }
// ── F-15 阶段2: 消息分段(归档段折叠) ── // ── F-15 阶段2: 消息分段(归档段折叠) ──
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环): // ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状) // null | undefined | 'active' → 正常消息(现状)
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条 // 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
// 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息) // 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息)
// 'truncated' → switchConversation 已过滤,不到这里 // 'truncated' → switchConversation 已过滤,不到这里
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。 // 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
interface AiMessageWithStatus extends AiMessage {
status?: string | null
}
type MessageSegment = type MessageSegment =
| { kind: 'normal'; msg: AiMessage } | { kind: 'normal'; msg: AiMessage }
@@ -531,7 +528,7 @@ const messageSegments = computed<MessageSegment[]>(() => {
let i = 0 let i = 0
while (i < msgs.length) { while (i < msgs.length) {
const m = msgs[i] const m = msgs[i]
const status = (m as AiMessageWithStatus).status const status = m.status
// F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩 // F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩
// 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。 // 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。
// 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。 // 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。
@@ -539,7 +536,7 @@ const messageSegments = computed<MessageSegment[]>(() => {
// 连续 archived_segment 合并 // 连续 archived_segment 合并
const group: AiMessage[] = [] const group: AiMessage[] = []
const key = `seg-${m.id}` 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]) group.push(msgs[i])
i++ i++
} }
+7 -3
View File
@@ -18,6 +18,7 @@ import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { findToolCall } from './aiShared' import { findToolCall } from './aiShared'
import { dispatchApprovalIPC } from './useToolApproval'
/** /**
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。 * F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
@@ -65,9 +66,12 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
const approvalConvId = state.activeConversationId || undefined const approvalConvId = state.activeConversationId || undefined
resetStreamWatchdog(approvalConvId) resetStreamWatchdog(approvalConvId)
try { try {
if (isPathKind) { // IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC(useToolApproval.ts),
// path 类:decision 缺省兜底 deny(调用方未传时安全侧拒绝,不静默放行) // 与 ApprovalPopup.runApproval 同源,消除「同语义两套逻辑」漂移
await aiApi.authorizeDir(toolCallId, decision ?? 'deny') // tc 为 null(findToolCall 未找到,时序竞态)时 isPathKind=false,走 risk 兜底 approve 分支
// (与原实现等价:!!tc && tc.kind==='path' 短路)。
if (tc) {
await dispatchApprovalIPC(tc, approved, decision)
} else { } else {
await aiApi.approve(toolCallId, approved) await aiApi.approve(toolCallId, approved)
} }
+29
View File
@@ -10,6 +10,7 @@
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 { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { aiApi } from '@/api'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
/** /**
@@ -26,6 +27,34 @@ const HIGH_RISK_TOOLS = new Set<string>([
'http_request', '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) { export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
const { t } = useI18n() const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm() const { confirmState, confirmDialog, answerConfirm } = useConfirm()
+85 -76
View File
@@ -2,13 +2,19 @@
* ToolCard ( ToolCard.vue ) * ToolCard ( ToolCard.vue )
* *
* 职责:工具显示名 + ( id /), * 职责:工具显示名 + ( id /),
* http url host project store(), composable , * http url host
* tc { toolDisplayName, displayArgValue } *
* :
* - `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 * ( + store ), ToolCard.vue
*/ */
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { useI18n } from 'vue-i18n' import { t as tGlobal } from '@/i18n/i18n-helpers'
import { import {
formatToolName, formatToolName,
shortCmd, shortCmd,
@@ -38,32 +44,83 @@ const TASK_ID_TOOL_ARG: Record<string, 'id' | 'task_id'> = {
run_workflow: 'task_id', run_workflow: 'task_id',
} }
/**
* id ( + ): delete/restore/purge ,
* projects deletedProjects,(退), undefined( fallback)
* 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(,useProjectStore ),
*/
function taskNameById(id: string): string | undefined {
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)
*/
export function toolDisplayName(tc: AiToolCallInfo): string {
const p = argString(tc.args, 'path')
switch (tc.name) {
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 ? `${tGlobal('aiTool.grepPrefix')} ${shortPath(p)}` : tGlobal('aiTool.grepFallback')
return pat ? `${head}${pat}` : head
}
case 'delete_project':
case 'restore_project':
case 'purge_project':
case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? argString(tc.args, idKey) : ''
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 ? `${tGlobal('aiTool.' + prefixKey)}${name}` : tGlobal('aiTool.' + fallbackKey)
}
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)
}
case 'http_request': {
const method = argString(tc.args, 'method') || 'GET'
const url = argString(tc.args, 'url')
const host = httpHost(url)
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 ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback')
}
default: return formatToolName(tc.name)
}
}
/** /**
* ToolCard / * ToolCard /
* @param getTc tc ( props.tc composable ,) * @param getTc tc ( props.tc composable ,)
*/ */
export function useToolCardHeader(getTc: () => AiToolCallInfo) { export function useToolCardHeader(getTc: () => AiToolCallInfo) {
const t = useI18n().t
const projectStore = useProjectStore()
/**
* id ( + ): delete/restore/purge ,
* projects deletedProjects,(退), undefined( fallback)
* project store ,; store
*/
function projectNameById(id: string): string | undefined {
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(),
*/
function taskNameById(id: string): string | undefined {
return projectStore.tasks.find(tk => tk.id === id)?.title
}
/** /**
* (AR-3): id/project_id , * (AR-3): id/project_id ,
* + id key formatArgValue * + id key formatArgValue
@@ -75,8 +132,8 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
const id = typeof arg.val === 'string' ? arg.val : '' const id = typeof arg.val === 'string' ? arg.val : ''
if (id) { if (id) {
const name = projectNameById(id) const name = projectNameById(id)
if (name) return t('aiTool.projectLabel', { name }) if (name) return tGlobal('aiTool.projectLabel', { name })
return t('aiTool.projectIdNotFound', { id }) return tGlobal('aiTool.projectIdNotFound', { id })
} }
} }
// UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题 // UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题
@@ -85,61 +142,13 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
const id = typeof arg.val === 'string' ? arg.val : '' const id = typeof arg.val === 'string' ? arg.val : ''
if (id) { if (id) {
const name = taskNameById(id) const name = taskNameById(id)
if (name) return t('aiTool.taskLabel', { name }) if (name) return tGlobal('aiTool.taskLabel', { name })
return t('aiTool.taskIdNotFound', { id }) return tGlobal('aiTool.taskIdNotFound', { id })
} }
} }
return formatArgValue(arg.val) return formatArgValue(arg.val)
} }
/**
* (); path, id ,create_*
*/
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 'grep': {
// grep 头部:搜索根 + pattern(折叠态可见搜索什么)
const pat = argString(tc.args, 'pattern')
const head = p ? `${t('aiTool.grepPrefix')} ${shortPath(p)}` : t('aiTool.grepFallback')
return pat ? `${head}${pat}` : head
}
case 'delete_project':
case 'restore_project':
case 'purge_project':
case 'update_project': {
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
const id = idKey ? argString(tc.args, idKey) : ''
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)
}
case 'create_task': return t('aiTool.createTask')
case 'create_project': return t('aiTool.createProject')
case 'run_command': {
const cmd = argString(tc.args, 'command')
return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name)
}
case 'http_request': {
const method = argString(tc.args, 'method') || 'GET'
const url = argString(tc.args, 'url')
const host = httpHost(url)
return host ? `${method} ${host}` : t('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')
}
default: return formatToolName(tc.name)
}
}
/** 审批参数键值对(头部 toolArgsEntries 透传,本 composable 仅暴露便于复用) */ /** 审批参数键值对(头部 toolArgsEntries 透传,本 composable 仅暴露便于复用) */
function argsEntries() { function argsEntries() {
return toolArgsEntries(getTc().args) return toolArgsEntries(getTc().args)
+99 -45
View File
@@ -4,7 +4,7 @@
- 无边框(decorations:false) + alwaysOnTop + skipTaskbar 系统级可见 - 无边框(decorations:false) + alwaysOnTop + skipTaskbar 系统级可见
- 紫色主题自定义样式( OS notification 默认) - 紫色主题自定义样式( OS notification 默认)
- 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件 - 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件
- 操作按钮直调 aiApi.approve/authorizeDir(主窗口 listener 收后端 emit 推新快照) - 操作按钮 dispatchApprovalIPC( tc.kind 分派 aiApi.approve/authorizeDir;主窗口 listener 收后端 emit 推新快照)
- 点击打开审批面板emit approval-popup-activate 主窗口聚焦 + togglePanel - 点击打开审批面板emit approval-popup-activate 主窗口聚焦 + togglePanel
- 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动 - 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动
- 自动定位屏幕右上角( ApprovalOverlay 视觉一致) --> - 自动定位屏幕右上角( ApprovalOverlay 视觉一致) -->
@@ -42,7 +42,7 @@
> >
<!-- 工具名 + kind 徽章 --> <!-- 工具名 + kind 徽章 -->
<div class="popup-item-header"> <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"> <span v-if="tc.kind === 'path'" class="popup-item-tag popup-item-tag--path">
{{ $t('aiChat.popupTagPath') }} {{ $t('aiChat.popupTagPath') }}
</span> </span>
@@ -56,8 +56,36 @@
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir"> <div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
📁 {{ tc.dir }} 📁 {{ tc.dir }}
</div> </div>
<!-- 操作按钮组 --> <!-- 操作按钮组:path 类显 once/always/deny 三选项( ToolCard 同款, always);
<div class="popup-item-actions"> 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 <button
class="popup-btn popup-btn--approve" class="popup-btn popup-btn--approve"
:disabled="processingId === tc.id" :disabled="processingId === tc.id"
@@ -65,7 +93,7 @@
> >
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span> <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> <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>
<button <button
class="popup-btn popup-btn--reject" class="popup-btn popup-btn--reject"
@@ -93,8 +121,8 @@
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { aiApi } from '@/api' import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader' import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup' import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
@@ -105,16 +133,6 @@ const processingId = ref<string>('')
/** 拖动态(样式反馈) */ /** 拖动态(样式反馈) */
const isDragging = ref(false) 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 _unlistenUpdate: UnlistenFn | null = null
let _positioned = false let _positioned = false
/** : close() promise /** : close() promise
@@ -123,6 +141,15 @@ let _positioned = false
let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
const CLOSE_TIMEOUT_MS = 1500 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 () => { onMounted(async () => {
// (,) // (,)
if (!_positioned) { if (!_positioned) {
@@ -164,11 +191,17 @@ onMounted(async () => {
// request-reply , emit // request-reply , emit
await emit('approval-popup-ready', {}) await emit('approval-popup-ready', {})
} catch { /* ignore */ } } catch { /* ignore */ }
// Esc (:)Tab/Enter button ,
// _onKeyDown ,onBeforeUnmount removeEventListener()
window.addEventListener('keydown', _onKeyDown)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
_unlistenUpdate?.() _unlistenUpdate?.()
_unlistenUpdate = null _unlistenUpdate = null
// Esc listener( window /)
window.removeEventListener('keydown', _onKeyDown)
if (_closeFallbackTimer) { if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer) clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null _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) { async function onApprove(tc: AiToolCallInfo) {
if (processingId.value === tc.id) return await runApproval(tc, true)
processingId.value = tc.id }
try { /** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派) */
if (tc.kind === 'path') { async function onReject(tc: AiToolCallInfo) {
await aiApi.authorizeDir(tc.id, 'once') await runApproval(tc, false, tc.kind === 'path' ? 'deny' : undefined)
} 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 = ''
}
} }
/** 拒绝:risk 类 approve(false),path 类 authorizeDir('deny') */ /** :(processingId)+ IPC + +
async function onReject(tc: AiToolCallInfo) { * IPC 分派规则(pathauthorizeDir / riskapprove)抽离至 dispatchApprovalIPC,
* useAiApproval.approveToolCall 同源,消除同语义两套逻辑漂移 */
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'always' | 'deny') {
if (processingId.value === tc.id) return if (processingId.value === tc.id) return
processingId.value = tc.id processingId.value = tc.id
try { try {
if (tc.kind === 'path') { await dispatchApprovalIPC(tc, approved, decision)
await aiApi.authorizeDir(tc.id, 'deny') // ( listener emit ,)
} else {
await aiApi.approve(tc.id, false)
}
approvals.value = approvals.value.filter(a => a.id !== tc.id) approvals.value = approvals.value.filter(a => a.id !== tc.id)
// BUG2 :,( popup-empty )
await maybeAutoClose() await maybeAutoClose()
} catch (e) { } catch (e) {
console.error('[ApprovalPopup] 拒绝失败:', e) console.error('[ApprovalPopup] 审批失败:', e)
} finally { } finally {
processingId.value = '' processingId.value = ''
} }
@@ -294,8 +323,24 @@ async function onClose() {
} }
} }
// ( data-tauri-drag-region + Tauri dataDrag ) // :JS API + data-tauri-drag-region
function onDragStart() { isDragging.value = true } // :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 } function onDragEnd() { isDragging.value = false }
</script> </script>
@@ -500,6 +545,15 @@ function onDragEnd() { isDragging.value = false }
.popup-btn--approve:hover:not(:disabled) { .popup-btn--approve:hover:not(:disabled) {
background: var(--df-accent-hover, #6558d8); 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 { .popup-btn--reject {
background: var(--df-sidebar-hover); background: var(--df-sidebar-hover);
color: var(--df-text-dim); color: var(--df-text-dim);