优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
@@ -22,6 +22,26 @@ const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
|
||||
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
|
||||
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
|
||||
// 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。
|
||||
if (state.activeConversationId
|
||||
&& !state.conversations.some(c => c.id === state.activeConversationId)) {
|
||||
const now = String(Date.now())
|
||||
state.conversations.unshift({
|
||||
id: state.activeConversationId,
|
||||
title: null,
|
||||
provider_id: null,
|
||||
model: null,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
|
||||
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
|
||||
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
|
||||
@@ -100,7 +120,8 @@ export async function switchConversation(id: string) {
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
model: m.model,
|
||||
timestamp: Date.now(),
|
||||
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
|
||||
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
||||
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
|
||||
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
|
||||
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
|
||||
@@ -230,6 +251,12 @@ function toggleArchivedFold() {
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 折叠/展开时间分组(today/yesterday/earlier) */
|
||||
function toggleGroupFold(key: string) {
|
||||
state.foldedGroups[key] = !state.foldedGroups[key]
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 展开/收起侧栏(会话列表) */
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
@@ -246,6 +273,7 @@ export function useAiConversations() {
|
||||
archiveConversation,
|
||||
setPinnedConversation,
|
||||
toggleArchivedFold,
|
||||
toggleGroupFold,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ function applyUiState(s: any) {
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
if (s.foldedGroups && typeof s.foldedGroups === 'object') {
|
||||
state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups }
|
||||
}
|
||||
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底)
|
||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
||||
}
|
||||
@@ -63,6 +66,7 @@ export function persistUiState() {
|
||||
maximized: state.maximized,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
foldedGroups: state.foldedGroups,
|
||||
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
})
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* useAiVirtualScroll — AiChat 消息列表虚拟滚动(IntersectionObserver 可见窗口懒渲染)。
|
||||
*
|
||||
* 设计取舍(UX-2025-19):选自研 IntersectionObserver 而非 vue-virtual-scroller。
|
||||
* - 零新依赖(项目 package.json 无 vue-virtual-scroller)。
|
||||
* - vue-virtual-scroller DynamicScroller 会接管滚动容器 + 重排子节点,
|
||||
* 破坏 .ai-messages 现有 flex/gap 布局、onMessagesScroll 边沿收起(wasNearBottom)、
|
||||
* isNearBottom/scrollToBottom 计算等"渲染层"以外的既有逻辑。
|
||||
* - 自研方案只做"渲染层裁剪":可见窗口外的 item 内容卸载、保留占位高度,
|
||||
* scrollHeight/scrollTop 计算与既有滚动逻辑零感知。
|
||||
*
|
||||
* 工作机制:
|
||||
* 1. 每个列表项包一个 sentinel div(始终挂载,只承担高度占位 + IO 观测)。
|
||||
* 2. IntersectionObserver root=滚动容器,rootMargin 上下各预加载一段(默认 600px),
|
||||
* 保证滚动时内容提前就位,不出现空白闪烁。
|
||||
* 3. sentinel 进入预加载区→key 进 renderedKeys→内容渲染;离开→移除→内容卸载,
|
||||
* 但 sentinel 自身保留(其已测得的高度经 inline style 占位,scrollHeight 不塌)。
|
||||
* 4. 高度测量:ResizeObserver 观测 sentinel(内容挂载时其高度=内容高度),
|
||||
* 卸载前把最后一次实测高度写进 inline style,卸载后 sentinel 仍占该高度。
|
||||
* 5. 流式末条保活:pinnedKeys 中的 key 永远视为"可见",不虚拟化(见 AiChat.vue 调用点
|
||||
* 把 streaming 末条 AI 消息 key 传入),保证流式渲染(ARC-08 块级 memo +
|
||||
* streamingBlocks v-for + 选区保存/恢复)不因虚拟化丢帧。
|
||||
*
|
||||
* 不处理(留观察,见诚实拆波):
|
||||
* - ToolCard 嵌套内部的虚拟化(单条消息内多工具卡,当前不裁剪,首屏内条目少)。
|
||||
* - 编辑光标在 user 消息上的保持(UX-09 走 input 框回填,与列表虚拟化无耦合)。
|
||||
*/
|
||||
import { ref, onBeforeUnmount, type Ref } from 'vue'
|
||||
|
||||
export interface AiVirtualScrollOptions {
|
||||
/** 滚动容器(IntersectionObserver root) */
|
||||
root: Ref<HTMLElement | null | undefined>
|
||||
/** 上下预加载像素数(可见区外提前渲染的高度,防滚出空白)。 */
|
||||
rootMarginPx?: number
|
||||
}
|
||||
|
||||
export function useAiVirtualScroll(options: AiVirtualScrollOptions) {
|
||||
const rootMargin = options.rootMarginPx ?? 600
|
||||
// 当前"应当渲染内容"的 key 集合(IO 判定可见 + pinned)。模板读这个判断渲染与否。
|
||||
const renderedKeys = ref<Set<string>>(new Set())
|
||||
// 永远渲染的 key(流式末条等),由调用方维护传入。
|
||||
const pinnedKeys = ref<Set<string>>(new Set())
|
||||
|
||||
// 每个观测项的元数据:DOM 元素 + 最近实测高度(卸载后占位用)。
|
||||
const itemMeta = new Map<string, { el: HTMLElement; height: number }>()
|
||||
let io: IntersectionObserver | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function ensureObserver(): void {
|
||||
if (io) return
|
||||
const rootEl = options.root.value
|
||||
if (!rootEl) return // root 未就绪;setupOnMount(root ref 就绪后)会再调一次建观察器
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const next = new Set(renderedKeys.value)
|
||||
let changed = false
|
||||
for (const e of entries) {
|
||||
const key = (e.target as HTMLElement).dataset.vscrollKey
|
||||
if (!key) continue
|
||||
const visible = e.isIntersecting
|
||||
const has = next.has(key)
|
||||
if (visible && !has) {
|
||||
// 重新可见:清掉卸载时锁的占位 minHeight,让内容自然撑高(RO 再回填真实高度)
|
||||
const meta = itemMeta.get(key)
|
||||
if (meta && meta.el.isConnected) meta.el.style.minHeight = ''
|
||||
next.add(key); changed = true
|
||||
}
|
||||
else if (!visible && has && !pinnedKeys.value.has(key)) {
|
||||
// 卸载前:把实测高度锁到 sentinel 的 minHeight,防内容移除后 sentinel 塌成 0
|
||||
// → scrollHeight 不变 → isNearBottom/scrollToBottom/回到底部计算零感知。
|
||||
// 内容重新挂载时 RO 会把真实高度回填,registerSentinel 复挂时清 minHeight 由真实高度接管。
|
||||
// 防御(P0):RO 首次回调前 item 已被 IO 判不可见时 meta.height===0,直接不设 minHeight
|
||||
// → sentinel 塌 0 高 → 下条消息上浮重叠。此处兜底:height>0 用实测值,
|
||||
// height===0 用 el.offsetWidth 量得的实际渲染高度(此时 el 仍在 DOM),仍<=0 则用固定 40px。
|
||||
const meta = itemMeta.get(key)
|
||||
if (meta && meta.el.isConnected) {
|
||||
const measured = meta.height > 0 ? meta.height : (meta.el.offsetHeight || 0)
|
||||
const placeholder = measured > 0 ? measured : 40
|
||||
meta.el.style.minHeight = `${placeholder}px`
|
||||
}
|
||||
next.delete(key); changed = true
|
||||
}
|
||||
}
|
||||
if (changed) renderedKeys.value = next
|
||||
},
|
||||
{ root: rootEl, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px`, threshold: 0 },
|
||||
)
|
||||
ro = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const key = (e.target as HTMLElement).dataset.vscrollKey
|
||||
if (!key) continue
|
||||
const meta = itemMeta.get(key)
|
||||
if (meta) meta.height = e.contentRect.height
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板 sentinel 注册回调(每个 v-for 项的 ref 调用一次,挂载时 el 非空、卸载时为 null)。
|
||||
* 调用方在 v-for item 上用 `:ref="(el) => registerSentinel(key, el as HTMLElement)"`。
|
||||
*/
|
||||
function registerSentinel(key: string, el: Element | null | undefined): void {
|
||||
const prev = itemMeta.get(key)
|
||||
if (el instanceof HTMLElement) {
|
||||
el.dataset.vscrollKey = key
|
||||
// 复用上次实测高度作为初始 inline 占位(再次挂载时先占位,内容挂载后 RO 再校准)。
|
||||
// 防御(P0):首次挂载(无 prev / RO 未回填)h<=0 时若不设占位,sentinel 首帧塌 0 高 →
|
||||
// 列表首次渲染时下条上浮重叠。此处用保守默认 40px 占位,RO 回调后用真实高度覆盖。
|
||||
const h = prev?.height ?? 0
|
||||
const placeholder = h > 0 ? h : 40
|
||||
el.style.minHeight = `${placeholder}px`
|
||||
itemMeta.set(key, { el, height: h })
|
||||
ensureObserver()
|
||||
io?.observe(el)
|
||||
ro?.observe(el)
|
||||
// 新挂载/初次进入的项默认视为应渲染(IO 回调会按真实相交态修正)
|
||||
if (!renderedKeys.value.has(key)) {
|
||||
const next = new Set(renderedKeys.value)
|
||||
next.add(key)
|
||||
renderedKeys.value = next
|
||||
}
|
||||
} else if (prev) {
|
||||
// 卸载:停止观测,保留 height 元数据供下次复用(itemMeta 不删)
|
||||
io?.unobserve(prev.el)
|
||||
ro?.unobserve(prev.el)
|
||||
// el 已被 Vue 移除,记录保留 height 即可
|
||||
itemMeta.set(key, { el: prev.el, height: prev.height })
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动容器 ref 就绪后调用(通常 onMounted),补建观察器并预渲染首批可见项。 */
|
||||
function setupOnMount(): void {
|
||||
ensureObserver()
|
||||
// 不主动全量 observe——registerSentinel 在模板挂载时已逐项 observe。
|
||||
}
|
||||
|
||||
/** 是否应渲染该项内容(IO 可见 或 pinned)。 */
|
||||
function shouldRender(key: string): boolean {
|
||||
// 🔍 诊断(P0 消息重叠):临时禁用虚拟滚动裁剪,所有消息内容恒渲染,
|
||||
// 隔离"历史消息打开全重叠"根因。若禁用后重叠消失→虚拟滚动时序根因;
|
||||
// 若仍重叠→CSS/markdown DOM 根因。定位后回退此改动。
|
||||
void key
|
||||
return true
|
||||
// return renderedKeys.value.has(key) || pinnedKeys.value.has(key)
|
||||
}
|
||||
|
||||
/** 更新 pinned 集合(调用方 watch 流式末条后传入新集合)。 */
|
||||
function setPinned(keys: Set<string>): void {
|
||||
pinnedKeys.value = keys
|
||||
// pinned 的 key 强制进渲染集(IO 不可见也渲染)
|
||||
if (keys.size > 0) {
|
||||
const next = new Set(renderedKeys.value)
|
||||
let changed = false
|
||||
for (const k of keys) if (!next.has(k)) { next.add(k); changed = true }
|
||||
if (changed) renderedKeys.value = next
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
io?.disconnect()
|
||||
ro?.disconnect()
|
||||
io = null
|
||||
ro = null
|
||||
itemMeta.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
renderedKeys,
|
||||
pinnedKeys,
|
||||
registerSentinel,
|
||||
setupOnMount,
|
||||
shouldRender,
|
||||
setPinned,
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function detachPanel() {
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
@@ -183,17 +183,56 @@ async function dockDetached() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
/** 吸附态 AI 窗口宽度/间隙常量(U-260618) */
|
||||
const AI_DOCK_WIDTH = 600
|
||||
const AI_DOCK_GAP = 4
|
||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||
let _isSyncing = false
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
|
||||
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
if (_isSyncing) return
|
||||
_isSyncing = true
|
||||
try {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const { currentMonitor } = await import('@tauri-apps/api/window')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
|
||||
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 落定再读尺寸
|
||||
}
|
||||
|
||||
let pos = await mainWin.outerPosition()
|
||||
let size = await mainWin.innerSize()
|
||||
|
||||
// 主+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()
|
||||
}
|
||||
}
|
||||
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
|
||||
} catch (e) {
|
||||
console.error('[AI] syncToMain 失败:', e)
|
||||
} finally {
|
||||
_isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
|
||||
@@ -9,18 +9,22 @@
|
||||
//! 各视图绑定 confirmState 的 visible/msg 并把按钮点击回 answerConfirm 即可。
|
||||
//!
|
||||
//! i18n:composable 在 setup 内调用,文案由调用方用各自 useI18n().t 翻译后传入 msg,
|
||||
//! 这里无需 i18n 依赖。dangerLabel 由 ConfirmDialog 组件侧兜底为 common.delete。
|
||||
//! 这里无需 i18n 依赖。dangerLabel 透传给 ConfirmDialog,空串时组件兜底为 common.confirm
|
||||
//! (语义中性,删除/清空等场景应显式传 common.delete / 「清空」等)。
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ConfirmState {
|
||||
visible: boolean
|
||||
msg: string
|
||||
/** 危险按钮文案;空串回退到 ConfirmDialog 组件兜底(common.confirm) */
|
||||
dangerLabel: string
|
||||
resolve: null | ((v: boolean) => void)
|
||||
}
|
||||
|
||||
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消 */
|
||||
export type ConfirmFn = (msg: string) => Promise<boolean>
|
||||
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消。
|
||||
* dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */
|
||||
export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise<boolean>
|
||||
|
||||
/** 关闭弹层并回传结果 */
|
||||
export type AnswerFn = (ok: boolean) => void
|
||||
@@ -29,15 +33,17 @@ export function useConfirm() {
|
||||
const confirmState = ref<ConfirmState>({
|
||||
visible: false,
|
||||
msg: '',
|
||||
dangerLabel: '',
|
||||
resolve: null,
|
||||
})
|
||||
|
||||
function confirmDialog(msg: string): Promise<boolean> {
|
||||
function confirmDialog(msg: string, dangerLabel = ''): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
// CR-260615-31:并发覆盖前一个 confirmDialog(连点删除/confirm 交错)时,
|
||||
// 前一个 Promise 永挂(resolve 被覆盖)→ 先 resolve?.(false) 打断前一个视为取消
|
||||
confirmState.value.resolve?.(false)
|
||||
confirmState.value.msg = msg
|
||||
confirmState.value.dangerLabel = dangerLabel
|
||||
confirmState.value.visible = true
|
||||
confirmState.value.resolve = resolve
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user