优化: 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
+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