优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)

- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分
- 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls)
  - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转
  - 多条/轮提取,system_prompt 仅注入 active 目标
  - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见)
- 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护
- 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见
- 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite
- 分离窗口默认置顶+置顶状态持久化(刷新后保持)
- header 菜单聚合:清空/压缩上下文收入 ... popout
- LLM 文字重叠防御:delta 重复检测+丢弃
- 置顶图标:星→大头针 pushpin
- 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
This commit is contained in:
lxy
2026-07-03 13:07:03 +08:00
parent aa0701a2ea
commit 0dfaeb0f5b
26 changed files with 929 additions and 753 deletions
+24 -2
View File
@@ -12,6 +12,7 @@
//! 模块级私有:
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
import { ref } from 'vue'
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
@@ -73,10 +74,13 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
// IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。
// 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达,
// 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。
// approve/authorize 通过 → running(后端正在执行工具);deny/reject → rejected(后端已记审计)。
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
if (tc) {
const isDeny = isPathKind ? (decision === 'deny') : !approved
tc.status = isDeny ? 'rejected' : 'running'
if (tc.status !== 'completed') {
tc.status = isDeny ? 'rejected' : 'running'
}
if (isDeny) {
tc.result = t('ai.aiTool.rejectedHint')
}
@@ -102,6 +106,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
}
}
/**
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
* 非目录聚合——path 类每条独立决策,不合并同目录)。
@@ -113,9 +118,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
*/
const _batchProcessing = ref(false)
async function batchApprove(decision: 'approve' | 'reject') {
if (_batchProcessing.value) return // 去重:批量进行中禁重复点击
_batchProcessing.value = true
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
const items = [...state.pendingApprovals]
let successCount = 0
let failCount = 0
for (const p of items) {
try {
if (p.kind === 'path') {
@@ -125,11 +135,23 @@ async function batchApprove(decision: 'approve' | 'reject') {
// risk 类:approve/reject boolean
await approveToolCall(p.id, decision === 'approve')
}
successCount++
} catch {
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
failCount++
// 继续处理剩余项
}
}
_batchProcessing.value = false
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
if (failCount > 0) {
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
}
}
/** 批量处理中(供按钮禁用 + spinner 展示) */
export function isBatchProcessing(): boolean {
return _batchProcessing.value
}
export function useAiApproval() {
+24 -1
View File
@@ -42,6 +42,9 @@ let _unlistenApprovalClear: (() => void) | null = null
// (两回调写同一 state.currentText → 流式文字双倍)
let _startPromise: Promise<void> | null = null
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
let _lastDelta = ''
const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
@@ -205,6 +208,7 @@ export function flushCurrentText() {
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
// currentText='' 对已清空值幂等,无副作用。
state.currentText = ''
_lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测
}
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
@@ -274,9 +278,28 @@ function removePendingDirAuth(id: string): void {
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiTextDelta':
case 'AiTextDelta': {
// BUG-260624-01 / UX-260617-12 消息重叠防御:
// 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。
// 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、
// startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。
// 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文
// 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。
// 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。
// ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。
// 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。
// 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。
if (event.delta && _lastDelta === event.delta && event.delta.length > 1) {
// df-ai-trace-delta 开关控制(默认开,生产可关)
if (appSettings.get('df-ai-trace-delta', true)) {
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
}
return true
}
_lastDelta = event.delta
state.currentText += event.delta
return true
}
case 'AiAgentRound': {
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
+51 -24
View File
@@ -82,6 +82,7 @@ async function detachPanel(convId?: string) {
minHeight: 400,
center: true,
decorations: true,
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
})
win.once('tauri://created', () => {
state.panelOpen = false
@@ -258,10 +259,22 @@ const AI_DOCK_WIDTH = 600
const AI_DOCK_GAP = 4
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
let _isSyncing = false
// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发,
// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。
// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。
let _syncTimer: ReturnType<typeof setTimeout> | null = null
const SYNC_DEBOUNCE_MS = 150
/** 同步 AI 窗口位置到主窗口右侧保留 GAP 间隙
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)
*
* 设计原则(本次优化重点):
* 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变,
* 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位
* (往左偏移或缩宽),不动主窗口。
* 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。
* 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。
* 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。
*/
async function syncToMain() {
if (_isSyncing) return
_isSyncing = true
@@ -276,28 +289,32 @@ async function syncToMain() {
const monitor = await currentMonitor()
const screenWidth = monitor?.size.width ?? 0
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
if (await mainWin.isMaximized()) {
await mainWin.unmaximize()
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
}
const pos = await mainWin.outerPosition()
const size = await mainWin.innerSize()
const maximized = await mainWin.isMaximized()
let pos = await mainWin.outerPosition()
let size = await mainWin.innerSize()
// 计算目标位置(默认贴主窗口右侧 + GAP)
let targetX = pos.x + size.width + AI_DOCK_GAP
let targetY = pos.y
let targetHeight = size.height
let targetWidth = AI_DOCK_WIDTH
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
if (newMainWidth >= 400) {
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
pos = await mainWin.outerPosition()
size = await mainWin.innerSize()
if (maximized) {
// 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。
// 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。
if (screenWidth > 0) {
targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP
}
targetHeight = size.height > targetHeight ? targetHeight : size.height
} else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) {
// 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。
// 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。
// 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。
targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP)
}
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
await aiWin.setPosition(new PhysicalPosition(targetX, targetY))
await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight))
} catch (e) {
console.error('[AI] syncToMain 失败:', e)
} finally {
@@ -305,18 +322,28 @@ async function syncToMain() {
}
}
/** 启动主窗口 move/resize 跟随(吸附时调用) */
/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */
function scheduleSync() {
if (_syncTimer) clearTimeout(_syncTimer)
_syncTimer = setTimeout(() => {
_syncTimer = null
void syncToMain()
}, SYNC_DEBOUNCE_MS)
}
/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */
async function startFollowMain() {
stopFollowMain()
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const mainWin = await WebviewWindow.getByLabel('main')
if (!mainWin) return
_unlistenMove = await mainWin.onMoved(() => { void syncToMain() })
_unlistenResize = await mainWin.onResized(() => { void syncToMain() })
_unlistenMove = await mainWin.onMoved(() => { scheduleSync() })
_unlistenResize = await mainWin.onResized(() => { scheduleSync() })
}
/** 停止主窗口跟随 */
/** 停止主窗口跟随 + 清防抖计时器 */
function stopFollowMain() {
if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null }
_unlistenMove?.()
_unlistenResize?.()
_unlistenMove = null