diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 945a8d2..39cbf1e 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -30,255 +30,16 @@ @provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')" /> - -
- -
- - -
- - - -
- - - - - - - + +
@@ -388,170 +149,22 @@ import ConfirmDialog from './ConfirmDialog.vue' import ConversationSidebar from './ai/ConversationSidebar.vue' import ChatInput from './ai/ChatInput.vue' import TopBar from './ai/TopBar.vue' +import MessageList from './ai/MessageList.vue' import { useConfirm } from '../composables/useConfirm' -import { useMarkdown } from '../composables/useMarkdown' -import ToolCardList from './ToolCardList.vue' -import { formatRelativeZh, formatDate } from '../utils/time' import { useProjectStore } from '../stores/project' -import type { AiMessage, ContentPart } from '../api/types' +import type { AiMessage } from '../api/types' const props = defineProps<{ detached?: boolean }>() -// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══ -// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/ -// renderMd)抽至 composables/useMarkdown(模块级单例),本组件与 TaskDetail 共享。 -// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)保留在本组件, -// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。 -const { - mdReady, - loadMarkdown, - renderMd, - escapeFallback, - getMarked, - getPurify, -} = useMarkdown() -// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null); -// 流式 parse 经 getter 取最新引用,行为零变化。 -// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2) -const _blockCache = new Map() -const BLOCK_CACHE_LIMIT = 300 -// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧) -// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01) -const streamingBlocks = ref([]) -let rafId: number | null = null -let lastStreamText = '' - -/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块 -/// (跨双换行不切),非代码段按双换行切段。 -/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。 -/// -/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则 -/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错, -/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏 -/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏), -/// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 + -/// 同一份围栏规则,切块边界与渲染边界一致。 -function splitBlocks(text: string): string[] { - const marked = getMarked() - const blocks: string[] = [] - // marked 未就绪时 splitBlocks 不应被调用(renderStreamingBlocks 已 mdReady 守卫), - // 此处兜底返回原文,不引入正则。 - if (!marked) return [text] - const tokens = marked.lexer(text) - for (const tok of tokens) { - if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue - const raw = (tok as { raw: string }).raw - if ((tok as { type: string }).type === 'code') { - // 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串) - if (raw.trim()) blocks.push(raw) - continue - } - // 非代码 token(paragraph/space/heading/list/blockquote/...): - // 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内) - for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b) - } - return blocks.length ? blocks : [text] -} - -/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token -function parseBlockNoCache(block: string): string { - // _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。 - // UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/ - // getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的 - // renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse() - // 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback, - // parse 抛错时 try/catch 同样降级,保证流式不中断。 - const marked = getMarked() - const purify = getPurify() - if (!marked || !purify) return escapeFallback(block) - try { - return purify.sanitize(marked.parse(block) as string) - } catch { - return escapeFallback(block) - } -} - -/// 块级 memo parse:已完成块缓存命中,O(末块) -function parseBlock(block: string): string { - const cached = _blockCache.get(block) - if (cached !== undefined) return cached - const html = parseBlockNoCache(block) - if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!) - _blockCache.set(block, html) - return html -} - -/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse) -/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增), -/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。 -interface StreamBlock { - html: string - key: string // 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每次变) -} -function renderStreamingBlocks(text: string): StreamBlock[] { - if (!text) return [] - if (!mdReady.value || !getMarked() || !getPurify()) { - return [{ html: escapeFallback(text), key: 'fallback' }] - } - const blocks = splitBlocks(text) - const n = blocks.length - let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新 - return blocks.map((b, i) => { - const isTail = i === n - 1 - const html = isTail ? parseBlockNoCache(b) : parseBlock(b) - // 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新) - const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}` - return { html, key } - }) -} - -/// 轻量字符串哈希:用于 block key 稳定性(非加密,仅避免长文本做 key) -function simpleHash(s: string): number { - let h = 5381 - for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0 - return h >>> 0 -} - -/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse -function scheduleStreamParse(text: string) { - lastStreamText = text - if (rafId !== null) return - rafId = requestAnimationFrame(() => { - rafId = null - // UX-2025-01: 末块 innerHTML 每帧替换,保存选区 - saveSelection() - streamingBlocks.value = renderStreamingBlocks(lastStreamText) - nextTick(() => { - restoreSelection() - // B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。 - // 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF, - // 滚到渲染前高度)致"差一截"。 - if (isFollowingBottom) scrollToBottom() - }) - }) -} - -/// template 绑定入口:最后AI消息流式时用 streamingBlocks(分块v-for,已完成块DOM稳定), -/// 否则 renderMd(完成/历史,整段string给v-html) -function renderContent(msg: AiMessage): string { - // 流式:由模板直接读 streamingBlocks(v-for 分块),此处不参与 - return renderMd(msg.content) -} const store = useAiStore() const { t } = useI18n() const router = useRouter() -// ── UX-2025-20: 空状态示例问题 + 跳 Settings + 标题生成淡入 ── -// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。 -const examplePrompts = [ - 'aiChat.examplePrompt1', - 'aiChat.examplePrompt2', - 'aiChat.examplePrompt3', - 'aiChat.examplePrompt4', -] as const +// ── UX-2025-20: 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内); +// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。── /** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */ function goToSettings() { @@ -597,32 +210,8 @@ function onChatInputError(payload: { msg: string; type: 'error' | 'warning' | 'i showToast(payload.msg, payload.type) } -/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */ -type ImageContentPart = Extract - -/** 取消息的图片 ContentPart 片(仅 type==='image');空数组=无图 */ -function msgImageParts(msg: AiMessage): ImageContentPart[] { - if (!msg.parts) return [] - return msg.parts.filter((p): p is ImageContentPart => p.type === 'image') -} - -/** ContentPart.image → :base64 模式拼 data URI,url 模式原样 */ -function imageSrc(img: ImageContentPart): string { - if (img.base64) { - // media_type 缺失兜底 image/png(provider 转发端点需完整 data URI) - const mt = img.media_type || 'image/png' - return `data:${mt};base64,${img.base64}` - } - return img.url || '' -} - -/** ContentPart.image → alt 文案 */ -function imgAlt(img: ImageContentPart): string { - return img.alt || t('aiChat.imagePreviewAlt') -} -const messagesContainer = ref() -// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡) -const toolCardListRef = ref>() +// 第四批抽离: MessageList 子组件引用(expose scrollToBottom/scrollToFirstPending) +const messageListRef = ref | null>(null) // AE-2025-02:头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示) const pendingApprovalCount = computed(() => store.state.pendingApprovals.length) @@ -640,9 +229,9 @@ const agenticProgressText = computed(() => { : t('ai.agenticProgressNoPending', { round: store.state.agentRound }) }) -/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 ToolCardList.scrollToFirstPending) */ +/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */ function scrollToFirstPending(): void { - toolCardListRef.value?.scrollToFirstPending() + messageListRef.value?.scrollToFirstPending() } // ── 确认弹层(删对话等不可逆操作,复用 ConfirmDialog 组件) ── @@ -687,13 +276,10 @@ const projectsStore = useProjectStore() // 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。 // 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。 // 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至 -// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消 + 滚动边沿重置。 +// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。 +// 滚动边沿/跟随意图重置(SW-260618-18/B-260618-24)随 MessageList 迁移,由子组件自管。 watch(() => store.state.activeConversationId, () => { if (editingMsgId.value) cancelEdit() - // SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists - wasNearBottom = true - // B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false) - isFollowingBottom = true }) @@ -711,47 +297,6 @@ const isViewingGenerating = computed(() => store.state.streaming && store.state.generatingConvId === store.state.activeConversationId ) - -function isLastAi(msg: AiMessage): boolean { - const msgs = store.state.messages - return msg.role === 'assistant' && msgs[msgs.length - 1]?.id === msg.id -} - -/** - * B-260618-19: 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。 - * - * 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见), - * 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后,旧 v-else-if(UX-260617-22) - * 漏判 toolCalls 把它整条过滤 → 工具卡片消失,且外层 .ai-msg-slot 恒渲染留空 div, - * flex gap 堆积成大片空白,末条光标气泡随每轮 AiAgentRound push 往下移。 - * - * 现仅过滤「纯空」assistant(无 content / 无 error / 无 toolCalls / 非末条流式): - * - 有 toolCalls → 渲染(工具卡可见,即便本轮 LLM 无文本) - * - 末条 + 流式 → 渲染(光标气泡占位,接收后续 delta) - * - 其余(user/system/有内容 AI)→ 渲染 - * - 纯空中间条 → 不渲染(连 slot 一起省,消除空白) - */ -function shouldRenderMsg(msg: AiMessage): boolean { - if (msg.role === 'assistant' - && !msg.content - && !msg.isError - && !(msg.toolCalls && msg.toolCalls.length) - && !(isLastAi(msg) && store.state.streaming)) { - return false - } - return true -} - -/** UX-09:msg 是否为 messages 中最后一条 user 消息(可编辑判定)。 */ -function isLastUser(msg: AiMessage): boolean { - if (msg.role !== 'user') return false - const msgs = store.state.messages - for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].role === 'user') return msgs[i].id === msg.id - } - return false -} - /** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */ function startEdit(msg: AiMessage): void { editingMsgId.value = msg.id @@ -764,11 +309,6 @@ function cancelEdit(): void { chatInputRef.value?.clearInput() } -/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */ -function formatTokens(n: number): string { - return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) -} - // ── 全局快捷键(window 级,非输入框内 handleKeydown) ── // 只绑不与浏览器/WebView2 默认冲突的键: // - Ctrl+L(浏览器聚焦地址栏——WebView2 内测,preventDefault 通常有效) @@ -893,101 +433,12 @@ async function handleSendQueuedNow(idx: number) { await store.sendQueuedNow(idx) } -// 用户是否已在底部附近(上滑查看历史时不强制拉回) -function isNearBottom(): boolean { - const el = messagesContainer.value - if (!el) return true - return el.scrollHeight - el.scrollTop - el.clientHeight < 80 -} - -// 回到底部按钮:内容溢出且不在底部时显示 -const showBackToBottom = ref(false) -// UX-260616-03: 跟随阅读位置自动收起 —— 用户滚近底部(在读最新内容)时, -// 收起不再活跃的旧卡(视为已读)。用 wasNearBottom 边沿检测避免每个 scroll tick 都触发: -// 仅在"远离底部 → 滚近底部"的转换瞬间收起一次,已在底部继续滚动不重复触发。 -let wasNearBottom = true -// B-260618-24: 跟随意图锁存。原 onContentChange 仅靠 isNearBottom() 瞬时测量,流式 rAF 高频 -// 渲染下单帧增量 >80px 即判不在底 → 跟随意图一帧丢失(点回到底部后又失跟)。锁存解耦 -// "意图"与"瞬时测量":点回到底部/滚到底/发新消息→true;用户上滑→false。 -let isFollowingBottom = true -function refreshBackToBottom() { - const el = messagesContainer.value - if (!el) { showBackToBottom.value = false; return } - const overflow = el.scrollHeight - el.clientHeight > 100 - showBackToBottom.value = overflow && !isNearBottom() -} -function onMessagesScroll() { - refreshBackToBottom() - // UX-260616-03: 滚近底部 → 收起已读旧卡(边沿触发,防抖) - const near = isNearBottom() - if (near && !wasNearBottom) { - collapseAllToolLists(buildActiveToolIds(store.state.messages)) - } - wasNearBottom = near - // B-260618-24: 用户主动滚离底部 = 放弃跟随;滚回底部 = 恢复跟随 - isFollowingBottom = near -} - +// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随 +// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。 +// 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。 function scrollToBottom(smooth = false) { - if (messagesContainer.value) { - isFollowingBottom = true // 显式回底 = 跟随意图 - messagesContainer.value.scrollTo({ - top: messagesContainer.value.scrollHeight, - behavior: smooth ? 'smooth' : 'instant', - }) - showBackToBottom.value = false - } + messageListRef.value?.scrollToBottom(smooth) } - -// 消息/流式变化时:跟随意图则自动滚;否则只刷新回底按钮(不强制打断) -// 用 isFollowingBottom 锁存而非 isNearBottom 瞬时测量,避免流式高频下单帧突破阈值误判失跟 -function onContentChange() { - if (isFollowingBottom) { - nextTick(scrollToBottom) - } else { - refreshBackToBottom() - } -} -watch(() => store.state.messages.length, onContentChange) -// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行两 handler) - -// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo): -// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块 -// - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存) -// - mdReady 翻转(CR-260615-06):首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback -// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化 -// SW-260618-07: 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。 -// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。 -watch(() => store.state.currentText, (text) => { - // B-260618-24: 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚), - // 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。 - // 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。 - if (store.state.streaming && text) scheduleStreamParse(text) - else onContentChange() -}) -watch(mdReady, (ready) => { - if (ready && store.state.streaming && store.state.currentText) { - scheduleStreamParse(store.state.currentText) - } -}) -watch(() => store.state.streaming, (s) => { - if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } - if (s) { - // UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。 - // 仅当前视图正在生成时触发(generatingConvId 对齐 activeConversationId), - // 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。 - // buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。 - if (store.state.generatingConvId === store.state.activeConversationId) { - collapseAllToolLists(buildActiveToolIds(store.state.messages)) - } - } else { - // UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建 - saveSelection() - streamingBlocks.value = [] - lastStreamText = '' - nextTick(() => restoreSelection()) - } -}) onBeforeUnmount(() => { // 全局快捷键(UX-2025-07):卸载时移除 window listener,防分离窗口/切组件后泄漏 window.removeEventListener('keydown', onGlobalKeydown) @@ -995,7 +446,8 @@ onBeforeUnmount(() => { window.removeEventListener('beforeunload', onBeforeUnloadGuard) // 注:导出菜单外部点击监听/侧栏拖拽 listener/标题淡入计时器现由 ConversationSidebar // 子组件 onBeforeUnmount 自管(第一批抽离),父不再持有这些引用。 - if (rafId !== null) cancelAnimationFrame(rafId) + // 流式 rAF 清理(streaming watch 内 cancelAnimationFrame)随 MessageList 迁移,子组件 + // onBeforeUnmount 自管(子 watch streaming 已注册,卸载自动停)。 // CR-260615-24: 释放后端 listener(ai-chat-event/ai-conversation-changed)+清流式看门狗。 // 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat // 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。 @@ -1007,68 +459,6 @@ onBeforeUnmount(() => { if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null } }) -// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时, -// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。 -// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ── -// -// B-260616-13 性能:快照仅含 toolCalls 的 (id,status)+消息条数,剔除 content.length。 -// 收起判定只依赖 active Set(由 toolCalls 状态推导),与文本长度无关。 -// 原快照含 len → 流式每 token 增长末条消息 content → snap 变 → body 每帧重算 -// (deep watch 检测仍每帧跑,但 body 跳过)。剔除 len 后流式 delta 不再触发收起重算, -// 只在 toolCall 状态翻转(新卡片/完成/拒绝)时触发,与折叠语义精确对齐。 -// snap/MESSAGE_CAP 兜底机制保持不变(deep watch 仍做变化检测,只是 body 短路掉无意义重算)。 -// -// UX-260616-03: 触发时机扩展。除原 messages deep watch(toolCall 状态翻转/新消息条数), -// 另加 agentRound watch(AiAgentRound 新轮次 → 收起上一轮工具卡,聚焦当前轮)。 -// active Set 推导逻辑复用 buildActiveToolIds,两 watch 共用。 -function buildActiveToolIds(msgs: typeof store.state.messages): Set { - const active = new Set() - for (const m of msgs) { - for (const t of (m.toolCalls || [])) { - // 与 ToolCard.shouldKeepOpen 同源:running/pending_approval/rejected/write_file 保持展开 - if (t.status === 'running' || t.status === 'pending_approval' - || t.status === 'rejected' || t.name === 'write_file') { - active.add(t.id) - } - } - } - return active -} - -/** 逐实例调 collapseInactive(v-for 内 ref 收集为数组,逐实例调否则报 not a function) */ -function collapseAllToolLists(active: Set): void { - const tcl = toolCardListRef.value as unknown - const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : []) - refs.forEach((r: any) => r?.collapseInactive?.(active)) -} - -let lastMsgSnapshot = '' -watch( - () => store.state.messages, - (msgs) => { - const snap = JSON.stringify({ - n: msgs.length, - tc: msgs.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))), - }) - if (snap === lastMsgSnapshot) return - const isFirst = lastMsgSnapshot === '' - lastMsgSnapshot = snap - if (isFirst) return // 首次加载/切换对话不触发收起 - collapseAllToolLists(buildActiveToolIds(msgs)) - }, - { deep: true }, -) - -// UX-260616-03: AiAgentRound 新轮次到达时收起上一轮工具卡(round 递增)。 -// 首次加载不触发(等真到达更"新"的轮次才收起,避免历史会话加载即折叠)。 -watch( - () => store.state.agentRound, - (cur, prev) => { - if (cur <= (prev ?? 0)) return - collapseAllToolLists(buildActiveToolIds(store.state.messages)) - }, -) - onMounted(async () => { // 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期) window.addEventListener('keydown', onGlobalKeydown) @@ -1081,7 +471,8 @@ onMounted(async () => { const t0 = (window as any).__APP_T0 ?? 0 // 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看) console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`) - loadMarkdown() // 后台预热 Markdown 渲染器,不阻塞首屏 + // Markdown 渲染器预热随 MessageList 子组件迁移(子 onMounted 内 loadMarkdown); + // useMarkdown 模块级单例,父不再直接调用。 await store.startListener() await store.loadProviders() await store.loadConversations() @@ -1122,150 +513,6 @@ onMounted(async () => { console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`) }) -// CR-260615-06: mdReady 就绪后主动触发首屏 Markdown 重渲染 -// (marked 加载慢 + 无新 delta 时末段停留纯文本,不触发模板重算) -const _mdRenderKey = ref(0) -function forceUpdate() { _mdRenderKey.value++ } - -// ── UX-2025-01: 选文字保持(save/restore Selection) ── -// v-html 整体替换 innerHTML 会销毁子 DOM 节点 → Node 引用断连 → 选区丢失。 -// 方案: 保存为「气泡内文本偏移量 + 选中文字」,重建后按偏移量重新定位。 -// 偏移量对 Markdown 渲染结果稳定(同内容→同DOM文本节点序列→同偏移)。 - -interface SavedSelection { - /** 选区所在消息的 msg.id (用于重建后定位目标 DOM) */ - msgId: string - /** 选区起始在气泡文本内容中的字符偏移 */ - startOffset: number - /** 选区结束在气泡文本内容中的字符偏移 */ - endOffset: number - /** 选中的原始文字(模糊回退:偏移漂移时用文本搜索重新定位) */ - selectedText: string -} -let _savedSel: SavedSelection | null = null - -/** - * 计算一个 offset 在 DOM 子树文本内容中对应的 (textNode, nodeOffset) 位置。 - * 遍历 Text 子节点累加长度,找到 offset 落入的节点。 - */ -function offsetToDOMPosition(root: Element, offset: number): { node: Text; offsetInNode: number } | null { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) - let currentOffset = 0 - let node: Node | null - // 每轮 walker.nextNode() 推进游标(createTreeWalker 后 currentNode===root 是 Element, - // SHOW_TEXT 过滤下 nextNode() 直接跳到第一个 Text 后代,root 本身不会被当作 Text)。 - // 不能用 `walker.currentNode as Text ?? nextNode()` —— as 仅类型断言不改变运行时值, - // root 非 null 恒走 ?? 左分支,nextNode() 永不执行,walker 不前进。 - while ((node = walker.nextNode())) { - const textNode = node as Text - const len = textNode.textContent!.length - if (currentOffset + len >= offset) { - return { node: textNode, offsetInNode: offset - currentOffset } - } - currentOffset += len - } - return null -} - -/** 保存当前文字选区(仅在 .ai-messages 内有效),转为文本偏移量 */ -function saveSelection(): void { - const sel = window.getSelection() - if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { _savedSel = null; return } - const range = sel.getRangeAt(0) - const container = messagesContainer.value - if (!container || !container.contains(range.commonAncestorContainer)) { _savedSel = null; return } - - // 向上查找所属 .ai-msg-bubble.ai-md 气泡 - let bubble = range.commonAncestorContainer as Element - while (bubble && !bubble.classList?.contains('ai-md')) { - bubble = bubble.parentElement! - } - if (!bubble) { _savedSel = null; return } - - // 从消息行取 msgId - const msgItem = bubble.closest('.ai-msg[data-msg-id]') - const msgId = msgItem?.getAttribute('data-msg-id') ?? '' - if (!msgId) { _savedSel = null; return } - - // 将选区的 Node 偏移转换为气泡内全局文本偏移 - function toTextOffset(node: Node, nodeOffset: number): number { - const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT) - let accumulated = 0 - let cur: Node | null - while ((cur = walker.nextNode())) { - if (cur === node) return accumulated + nodeOffset - accumulated += (cur as Text).textContent!.length - } - return -1 - } - - const startOff = toTextOffset(range.startContainer, range.startOffset) - const endOff = toTextOffset(range.endContainer, range.endOffset) - if (startOff < 0 || endOff < 0) { _savedSel = null; return } - - _savedSel = { - msgId, - startOffset: startOff, - endOffset: endOff, - selectedText: sel.toString(), - } -} - -/** 恢复之前保存的选区(nextTick 后 DOM 已更新,用偏移量重新定位) */ -function restoreSelection(): void { - if (!_savedSel) return - - // 找到目标消息的 .ai-md 气泡(DOM 已重建,是新的节点) - const msgItem = messagesContainer.value?.querySelector(`[data-msg-id="${_savedSel.msgId}"]`) - const bubble = msgItem?.querySelector('.ai-md') as Element | null - if (!bubble) { _savedSel = null; return } - - try { - // 策略1: 按偏移量直接定位 - const startPos = offsetToDOMPosition(bubble, _savedSel.startOffset) - const endPos = offsetToDOMPosition(bubble, _savedSel.endOffset) - - if (startPos && endPos) { - const range = new Range() - range.setStart(startPos.node, startPos.offsetInNode) - range.setEnd(endPos.node, endPos.offsetInNode) - const sel = window.getSelection() - sel?.removeAllRanges() - sel?.addRange(range) - } else if (_savedSel.selectedText) { - // 策略2: 偏移越界时用文本搜索模糊回退(处理 Markdown 渲染后微小的结构差异) - const bubbleText = bubble.textContent ?? '' - const idx = bubbleText.indexOf(_savedSel.selectedText) - if (idx >= 0) { - const startPos2 = offsetToDOMPosition(bubble, idx) - const endPos2 = offsetToDOMPosition(bubble, idx + _savedSel.selectedText.length) - if (startPos2 && endPos2) { - const range = new Range() - range.setStart(startPos2.node, startPos2.offsetInNode) - range.setEnd(endPos2.node, endPos2.offsetInNode) - const sel = window.getSelection() - sel?.removeAllRanges() - sel?.addRange(range) - } - } - } - } catch { - // 定位失败,静默忽略 - } finally { - _savedSel = null - } -} - -watch(mdReady, (ready) => { - if (ready) { - saveSelection() - nextTick(() => { - forceUpdate() - nextTick(() => restoreSelection()) - }) - } -}) - // ── 置顶功能 ── const alwaysOnTop = ref(false) async function toggleAlwaysOnTop() { @@ -1275,97 +522,6 @@ async function toggleAlwaysOnTop() { alwaysOnTop.value = !alwaysOnTop.value } -// ── 消息气泡拷贝(F-260615-13) ── -// 必须在 click 事件内(或其同步调用链)调 clipboard API (浏览器安全限制) -// navigator.clipboard 在非 secure context (Electron file:// / 旧版 webview) 不可用, -// 回退到 execCommand('copy') 兜底;两者均失败时提示复制失败而非假成功。 -async function copyMsgContent(msg: AiMessage): Promise { - const text = msg.content || '' - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - showToast(t('aiChat.copied'), 'info') - return - } - } catch { - // 权限拒绝/中断,落入下方兜底 - } - // fallback: 隐藏 textarea + execCommand('copy') - const ta = document.createElement('textarea') - ta.value = text - ta.style.position = 'fixed' - ta.style.top = '0' - ta.style.left = '0' - ta.style.opacity = '0' - document.body.appendChild(ta) - ta.focus() - ta.select() - let ok = false - try { - ok = document.execCommand('copy') - } catch { - ok = false - } - document.body.removeChild(ta) - showToast(ok ? t('aiChat.copied') : t('aiChat.copyFailed'), ok ? 'info' : 'error') -} - -// ── UX-02: 重新生成最后一条 AI 回复 ── -// 复用 store.regenerate(后端 ai_regenerate:弹末尾 AI 回复→重跑 loop)。 -// 失败 toast 提示,不影响其他状态(后端已复位 generating)。 -async function handleRegenerate(): Promise { - try { - await store.regenerate() - await nextTick() - scrollToBottom() - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - showToast(t('aiChat.regenerateFailed', { msg }), 'error') - } -} - -// ── UX-03: 错误气泡操作入口 ── -// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段, -// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。 -interface AiMessageWithErrorType extends AiMessage { - errorType?: import('../api/types').AiErrorType -} - -/** error_type ∈ {auth, provider_config} → 显示「去设置」按钮(其余隐藏) */ -function canOpenSettings(msg: AiMessage): boolean { - const et = (msg as AiMessageWithErrorType).errorType - return et === 'auth' || et === 'provider_config' -} - -/** - * UX-260616-01: 错误是否可重试(显隐「重试」按钮)。 - * Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败, - * 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。 - * retryable 推断依据后端 error_type(本批前端推断;后端 AiStreamRetry/AiError 加 retryable 字段下批 df-ai 增强)。 - */ -function canRetry(msg: AiMessage): boolean { - const et = (msg as AiMessageWithErrorType).errorType - // auth/provider_config = Fatal(鉴权/配置错),重试无意义 - if (et === 'auth' || et === 'provider_config') return false - return true -} - -/** - * 错误气泡「重试」:取错误前的末尾 user 消息重发。 - * 复用 regenerate 链路(后端弹末尾 AI 回复——错误消息本身——取触发它的 user 消息重跑)。 - * 语义等价"重新生成",但语义场景是错误恢复,文案/入口独立(UX-03)。 - */ -async function handleRetry(): Promise { - try { - await store.regenerate() - await nextTick() - scrollToBottom() - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - showToast(t('aiChat.regenerateFailed', { msg }), 'error') - } -} - // ── F-260616-03: 达 max_iterations 暂停态操作卡 ── // pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。 // maxRoundsActing 本地 ref 持按钮 loading:点继续/停止后禁双按钮,等后端事件 @@ -1422,94 +578,9 @@ interface AiMessageWithStatus extends AiMessage { status?: string | null } -/** - * 消息列表分段预处理 — 连续同 status 合并成一条"段"。 - * - * 段类型: - * { kind:'normal', msg } — 单条正常消息(保持现状渲染) - * { kind:'archived', key, msgs: [...] } — 连续 archived_segment 合并,折叠成分隔条 - * { kind:'compressed', key, msgs: [...] }— 连续 compressed 合并,折叠成摘要块 - * - * 设计:用 computed 自动随 messages 变化重算;展开/收起由 expandedSegmentIds 控制 - * (段 key=首条消息 id,展开时内联渲染段内每条消息,复用现有 user/ai 气泡模板)。 - * - * 注意:折叠段展开时内部消息按原样渲染(走 normal 段的同款分支), - * 故抽出 renderSingleMessage(msg) 复用——但 Vue 模板无函数式组件, - * 此处用 v-if 分支在段内展开块中复刻 user/ai 渲染逻辑(模板侧实现)。 - */ -type MessageSegment = - | { kind: 'normal'; msg: AiMessage } - | { kind: 'archived'; key: string; msgs: AiMessage[] } - | { kind: 'compressed'; key: string; msgs: AiMessage[] } - -const messageSegments = computed(() => { - const msgs = store.state.messages - const segments: MessageSegment[] = [] - let i = 0 - while (i < msgs.length) { - const m = msgs[i] - const status = (m as AiMessageWithStatus).status - // F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩 - // 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。 - // 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。 - if (status === 'archived_segment') { - // 连续 archived_segment 合并 - const group: AiMessage[] = [] - const key = `seg-${m.id}` - while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === 'archived_segment') { - group.push(msgs[i]) - i++ - } - segments.push({ kind: 'archived', key, msgs: group }) - } else { - segments.push({ kind: 'normal', msg: m }) - i++ - } - } - return segments -}) - -/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠 -const expandedSegmentIds = ref>(new Set()) -function toggleSegment(key: string): void { - const next = new Set(expandedSegmentIds.value) - if (next.has(key)) next.delete(key) - else next.add(key) - expandedSegmentIds.value = next -} - -/** - * 扁平渲染项 — 把 segments 展开成 v-for 可直接遍历的扁平列表,避免模板内双重 v-for - * + 消息块复制(原消息块约 150 行,复制展开分支代价过大且易漂移)。 - * - * 项类型: - * { kind:'msg', key, msg } — 渲染单条消息(走原 user/ai 气泡块,模板 v-if seg.kind==='msg') - * { kind:'sep', key, seg } — 渲染分隔条(折叠态标题/计数 + 展开/收起按钮) - * - * 规则:折叠段未展开时只 emit sep 项(段内消息隐藏);展开后 emit sep + 段内每条 msg 项。 - * 正常段 emit msg 项。messages 变化或展开态切换都触发重算(computed 依赖两者)。 - */ -type RenderItem = - | { kind: 'msg'; key: string; msg: AiMessage } - | { kind: 'sep'; key: string; seg: MessageSegment & { key: string } } - -const renderItems = computed(() => { - const items: RenderItem[] = [] - for (const seg of messageSegments.value) { - if (seg.kind === 'normal') { - items.push({ kind: 'msg', key: 'm-' + seg.msg.id, msg: seg.msg }) - } else { - // 折叠段:总 emit sep;展开时再 emit 段内每条 msg - items.push({ kind: 'sep', key: seg.key, seg }) - if (expandedSegmentIds.value.has(seg.key)) { - for (const m of seg.msgs) { - items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m }) - } - } - } - } - return items -}) +// 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/ +// toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。 +// AiMessageWithStatus 保留:hasActiveMessages(清空/压缩按钮启用判定,TopBar 用)仍需 cast 读 status。 /** * 是否存在"活跃消息"(可清空/可压缩)— 控制清空/压缩按钮启用。 @@ -2092,352 +1163,6 @@ body.ai-sidebar-resizing * { .ai-provider-switch-enter-to, .ai-provider-switch-leave-from { opacity: 1; max-height: 32px; } -/* ═══ Messages ═══ */ -.ai-messages { - flex: 1; - overflow-y: auto; - padding: 14px; - display: flex; - flex-direction: column; - gap: 14px; -} -/* UX-2025-19: .ai-messages 的直接 flex 子(B-260618-01:虚拟滚动 sentinel 已移除, - 现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */ -.ai-msg-slot { display: flex; flex-direction: column; } - -/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area) ── */ -.ai-back-to-bottom { - position: absolute; - right: 18px; - bottom: 88px; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - border: 0.5px solid var(--df-border); - border-radius: 50%; - background: var(--df-bg-card); - color: var(--df-text-dim); - cursor: pointer; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); - z-index: 15; - transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease); -} -.ai-back-to-bottom:hover { - color: var(--df-accent); - border-color: var(--df-accent); -} -.back-to-bottom-enter-active, -.back-to-bottom-leave-active { transition: opacity 0.15s, transform 0.15s; } -.back-to-bottom-enter-from, -.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); } - -/* -- Empty State -- */ -.ai-empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - opacity: 0.5; -} -.ai-empty-icon { margin-bottom: 4px; } -.ai-empty-text { - font-size: 14px; - font-weight: 500; - color: var(--df-text-secondary); -} -.ai-empty-sub { - font-size: 12px; - color: var(--df-text-dim); - text-align: center; - line-height: 1.5; -} -/* UX-2025-20: 无 provider 引导 — 跳 Settings 按钮(克制:边框线 + accent 文字,不喧宾) */ -.ai-empty-action { - display: inline-flex; - align-items: center; - gap: 5px; - margin-top: 6px; - padding: 4px 12px; - border: 0.5px solid var(--df-accent); - border-radius: var(--df-radius); - background: var(--df-accent-bg); - color: var(--df-accent); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: filter 0.15s var(--df-ease); -} -.ai-empty-action:hover { filter: brightness(1.08); } -/* UX-2025-20 §7.3: 示例问题卡片网格(2 列,轻量;复用 .ai-* 卡片风格) */ -.ai-empty-prompts { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 6px; - width: 100%; - max-width: 360px; - margin-top: 10px; -} -.ai-empty-prompt { - padding: 8px 10px; - border: 0.5px solid var(--df-border); - border-radius: var(--df-radius); - background: var(--df-bg-card); - color: var(--df-text-secondary); - font-size: 11.5px; - line-height: 1.4; - text-align: left; - cursor: pointer; - opacity: 0.85; - transition: all 0.15s var(--df-ease); -} -.ai-empty-prompt:hover { - border-color: var(--df-accent); - color: var(--df-text); - opacity: 1; - background: color-mix(in srgb, var(--df-accent) 8%, var(--df-bg-card)); -} - -/* -- Message Layout -- */ -.ai-msg-user { - position: relative; - display: flex; - gap: 8px; - align-items: flex-start; - flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */ -} -.ai-msg-ai { - position: relative; - display: flex; - gap: 8px; - align-items: flex-start; -} -.ai-msg-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -/* -- Avatar -- */ -.ai-msg-avatar { - width: 24px; - height: 24px; - border-radius: var(--df-radius); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} -.ai-msg-avatar--user { - background: var(--df-info-bg); - color: var(--df-info); -} -.ai-msg-avatar--ai { - background: var(--df-accent-bg); - color: var(--df-accent); -} - -/* -- Bubble -- */ -.ai-msg-bubble { - position: relative; - font-size: 13px; - line-height: 1.6; - max-width: var(--df-msg-max-width); - padding: 8px 12px; - border-radius: var(--df-radius-lg); - overflow-wrap: anywhere; -} -.ai-msg-bubble--user { - background: var(--df-accent); - color: #fff; - border-bottom-right-radius: var(--df-radius-sm); -} -.ai-msg-bubble--ai { - background: var(--df-bg-card); - color: var(--df-text); - border: 0.5px solid var(--df-border); - border-bottom-left-radius: var(--df-radius-sm); -} -.ai-msg-bubble--error { - background: var(--df-danger-bg); - color: var(--df-danger); - border-color: rgba(240,101,101,0.3); -} -/* B-260618-06:修正 UX-260617-21 的 display:block 方案——display:block 让 table 失去表格 - 布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。 - 正解:table 保持默认 display:table(列对齐恢复)+ max-width:100%;宽表格横向滚动交由气泡 - 容器 .ai-msg-bubble--ai.ai-md 的 overflow-x:auto 承载(CSS spec 中 table-display 下 - overflow 被浏览器忽略,滚动须由 block-layout 父容器承担)。:deep() 因 table 经 v-html - 注入非 scoped 受控节点。 */ -.ai-msg-bubble.ai-md :deep(table) { - max-width: 100%; -} -.ai-msg-bubble--ai.ai-md { - overflow-x: auto; -} - -/* ── 消息时间戳(UX-2025-13) ── */ -.ai-msg-time { - font-size: 10px; - line-height: 1; - color: var(--df-text-dim); - margin-top: 2px; - align-self: flex-start; /* AI 消息:靠气泡左对齐 */ - flex-shrink: 0; - cursor: default; - user-select: none; -} -.ai-msg-time--user { - align-self: flex-end; /* 用户消息:靠气泡右对齐 */ -} - -/* ── 消息气泡拷贝按钮(F-260615-13) ── */ -.ai-copy-btn { - position: absolute; - right: 4px; - bottom: 4px; - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border: none; - border-radius: var(--df-radius-sm); - background: rgba(0, 0, 0, 0.35); - color: #fff; - cursor: pointer; - opacity: 0; - transition: opacity 0.2s; - flex-shrink: 0; -} -.ai-msg-user:hover .ai-copy-btn, -.ai-msg-ai:hover .ai-copy-btn--ai { - opacity: 1; -} -.ai-copy-btn:hover { - background: rgba(0, 0, 0, 0.55); -} - -/* ── UX-09: 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */ -.ai-msg-edit-btn { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border: none; - border-radius: var(--df-radius-sm); - background: transparent; - color: var(--df-text-dim); - cursor: pointer; - opacity: 0; - transition: opacity 0.2s, background 0.2s; - flex-shrink: 0; - align-self: flex-end; -} -.ai-msg-user:hover .ai-msg-edit-btn { - opacity: 1; -} -.ai-msg-edit-btn:hover { - background: var(--df-bg-hover, rgba(0, 0, 0, 0.06)); - color: var(--df-text); -} -/* 编辑态输入框视觉区分(左侧高亮条,提示正在编辑而非新发) */ -.ai-input--editing { - box-shadow: inset 2px 0 0 var(--df-accent, #3b82f6); -} - -/* ── UX-02/UX-03: 消息操作栏(hover 显示 / 错误气泡常显) ── */ -.ai-msg-actions { - display: flex; - align-items: center; - gap: 4px; - margin-top: 4px; - opacity: 0; - transition: opacity 0.15s var(--df-ease); -} -/* AI 消息操作栏:hover 整条 AI 消息行时显;错误气泡操作栏(--error)常显 */ -.ai-msg-ai:hover .ai-msg-actions, -.ai-msg-actions--error { - opacity: 1; -} -/* UX-260616-01: Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮, - 说明为何无重试入口,引导走「去设置」。 */ -.ai-msg-not-retryable { - font-size: 10.5px; - color: var(--df-text-dim); - font-style: italic; - align-self: center; -} -.ai-msg-act { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 2px 7px; - border: none; - border-radius: var(--df-radius-sm); - background: transparent; - color: var(--df-text-dim); - font-size: 10.5px; - line-height: 1.4; - cursor: pointer; - transition: background 0.15s, color 0.15s; - flex-shrink: 0; -} -.ai-msg-act:hover { - background: var(--df-bg-card); - color: var(--df-text); -} -.ai-msg-act:disabled { - opacity: 0.5; - cursor: not-allowed; -} -/* 错误气泡重试主按钮:accent 强调(错误恢复引导主操作) */ -.ai-msg-act--primary { - background: var(--df-accent-bg); - color: var(--df-accent); -} -.ai-msg-act--primary:hover { - background: color-mix(in srgb, var(--df-accent) 18%, transparent); - color: var(--df-accent); -} - -/* -- Streaming Cursor -- */ -.ai-cursor { - display: inline-block; - width: 2px; - height: 14px; - background: var(--df-accent); - margin-left: 2px; - vertical-align: text-bottom; - animation: blink 0.8s step-end infinite; -} -@keyframes blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} - -/* ── token 用量条(克制:右对齐 / 最小字号 / dim / 分隔线,不抢正文焦点) ── */ -.ai-token-usage { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 4px; - margin-top: 6px; - padding-top: 6px; - border-top: 0.5px solid var(--df-border); - font-family: var(--df-font-mono); - font-size: 9px; - color: var(--df-text-dim); - opacity: 0.7; -} -.ai-token-usage-icon { font-size: 9px; } - /* ═══ Input ═══ */ .ai-input-area { padding: 10px 14px; @@ -2590,23 +1315,6 @@ body.ai-sidebar-resizing * { color: #fff; } -/* 用户气泡内的图片(多模态消息渲染) */ -.ai-msg-images { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 6px; -} -.ai-msg-image { - max-width: 200px; - max-height: 200px; - width: auto; - height: auto; - border-radius: var(--df-radius); - object-fit: contain; - display: block; - background: var(--df-bg); -} .ai-input-wrap:focus-within { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); } @@ -2837,64 +1545,6 @@ body.ai-sidebar-resizing * { opacity: 0.6; } -/* ═══ F-15: 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══ - 用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开, - 摘要 system 块置顶 accent 提示,让用户知情"AI 看到的是摘要"。 */ -.ai-msg-system { - margin: 6px 0; - padding: 8px 12px; - border-left: 3px solid var(--df-accent); - background: color-mix(in srgb, var(--df-accent) 6%, transparent); - border-radius: 4px; - font-size: 12px; -} -.ai-msg-system-label { - display: block; - font-weight: 600; - color: var(--df-accent); - margin-bottom: 4px; - font-size: 11px; -} -.ai-msg-system-summary { color: var(--df-text-secondary); line-height: 1.5; } -.ai-msg-system-summary :is(h1, h2, h3) { font-size: 12px; margin: 4px 0 2px; } - -/* ═══ F-15 阶段2: 折叠分隔条(归档段 / 压缩段) ═══ - 连续 archived_segment/compressed 消息合并成一条分隔条;点击展开看原文。 - 风格对齐 .ai-conv-group-title(dim 小字 + 上下留白),不抢正常消息视觉焦点。 */ -.ai-msg-segment { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - margin: 2px 0; - background: color-mix(in srgb, var(--df-text-dim) 8%, transparent); - border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent); - border-radius: var(--df-radius); - font-size: 11px; - color: var(--df-text-dim); - cursor: pointer; - user-select: none; - transition: background 0.15s var(--df-ease), border-color 0.15s var(--df-ease); -} -.ai-msg-segment:hover { - background: color-mix(in srgb, var(--df-text-dim) 14%, transparent); - border-color: color-mix(in srgb, var(--df-text-dim) 50%, transparent); - color: var(--df-text-secondary); -} -.ai-msg-segment--compressed { - /* 压缩段与归档段视觉区分:accent 色调(压缩是 LLM 产出的摘要,更"主动") */ - background: color-mix(in srgb, var(--df-accent) 8%, transparent); - border-color: color-mix(in srgb, var(--df-accent) 30%, transparent); - color: var(--df-accent); -} -.ai-msg-segment--compressed:hover { - background: color-mix(in srgb, var(--df-accent) 14%, transparent); - border-color: color-mix(in srgb, var(--df-accent) 45%, transparent); -} -.ai-msg-segment-icon { flex-shrink: 0; } -.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; } - /* ── F-15 阶段2: 按钮禁用态(清空/压缩上下文,空会话/全归档/生成中)── 复用现有 .ai-btn-icon:hover 风格,禁用时降透明度 + not-allowed,不发 hover 高亮。 */ .ai-btn-icon:disabled { diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue new file mode 100644 index 0000000..d9e970f --- /dev/null +++ b/src/components/ai/MessageList.vue @@ -0,0 +1,1444 @@ + + + + +