Files
DevFlow/src/components/ai/MessageList.vue

1590 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
//! 消息列表子组件 — 第四批抽自 src/components/AiChat.vue(零行为变更)。
//!
//! 职责:消息列表渲染(.ai-messages 容器 + 空状态 + renderItems 扁平渲染 +
//! 消息气泡 user/ai/system + 流式 Markdown 分块渲染 + 工具卡 ToolCardList 转发审批 +
//! 折叠归档段 + 选区保持 + 滚动跟随/回底部)。
//!
//! 共享:store(useAiStore 单例)/ Markdown 渲染器(useMarkdown 模块级单例)与父同实例,
//! currentText/streamingBlocks/messages 经 store,流式行为零变化。
//!
//! 与父交互(emit/expose):
//! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus
//! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt
//! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级)
//! - emit('toast', {msg, type}):copy/regenerate/retry 等的 toast 转发(父持单一 toast 源)
//! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底
import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
import ToolCardList from '../ToolCardList.vue'
import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
defineProps<{
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
editingMsgId?: string | null
}>()
const emit = defineEmits<{
(e: 'start-edit', msg: AiMessage): void
(e: 'send-example-prompt', i18nKey: string): void
(e: 'go-to-settings'): void
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)保留在本组件,
// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。
const {
mdReady,
loadMarkdown,
renderMd,
escapeFallback,
getMarked,
getPurify,
} = useMarkdown()
// ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══
// appSettings key `df-ai-streaming-md`,默认 true(开):流式走块级 memo + rAF 节流的
// Markdown 分块渲染(代码块/列表/标题流式过程中就有格式)。关(false):回退纯文本短路
// (escapeFallback,每 delta 全文转义,<br> 换行)——即 AR-1 修复前的原行为,供性能敏感/
// 极端掉帧场景降级。读走 appSettings 缓存(响应式,Settings 改动即时生效)。
const appSettings = useAppSettingsStore()
const streamingMdEnabled = computed(() => appSettings.get<boolean>('df-ai-streaming-md', true))
// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
// 流式 parse 经 getter 取最新引用,行为零变化。
// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
const _blockCache = new Map<string, string>()
const BLOCK_CACHE_LIMIT = 300
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01)
const streamingBlocks = ref<StreamBlock[]>([])
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 []
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !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
// AR-1 代码块降级:末块若是未闭合的代码围栏(```/~~~ 数为奇数,说明代码块还没结束),
// 不走 marked.parse(未闭合围栏 → marked 推断为代码块 + hljs 对不完整代码高亮,
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
const degrade = isTail && isUnclosedCodeFence(b)
const html = degrade
? escapeFallback(b)
: isTail ? parseBlockNoCache(b) : parseBlock(b)
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}`
return { html, key }
})
}
/// 末块未闭合代码围栏检测:统计行首(可选 ≤3 空格)的 ``` / ~~~ 围栏开/闭数量,
/// 奇数 = 有未闭合的代码块(流式还在写入该代码块)。仅末块调用,前块必已闭合(splitBlocks
/// 已把完整 code token 切成独立块)。对齐 marked 围栏规则(行首 ≤3 空格 + 3+ 反引号/波浪)。
function isUnclosedCodeFence(block: string): boolean {
let fenceCount = 0
const lines = block.split('\n')
for (const line of lines) {
// 行首 ≤3 空格 + 3+ 反引号或波浪(marked 围栏开关判定)
const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line)
if (m) fenceCount++
}
return fenceCount % 2 === 1
}
/// 轻量字符串哈希:用于 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)
}
// ── Input Augmentation: 用户消息按 mention 区间切段(弃正则,纯元数据驱动) ──
// 解决 🔴HIGH-2:正则误匹配(用户手打 [项目: x] 格式 / AI 模仿格式 / 项目名含 ] / 嵌套)。
// 按 msg.mentionSpans(无则纯文本一段)按 start 升序切段:text → chip 替换。
// 每 span 安全校验:content.slice(start, start+length) === span.label 否则降级为 text
// (用户编辑破坏区间,如改了项目名/删了字符致偏移失效)。重叠/越界 span 也降级。
type UserContentSegment =
| { type: 'text'; text: string }
| { type: 'chip'; span: MentionSpan }
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
const content = msg.content
const spans = msg.mentionSpans
// 无 mentionSpans(历史消息/纯文本)→ 一段 text 零回归
if (!spans || spans.length === 0) {
return [{ type: 'text', text: content }]
}
// 按 start 升序排序(稳定;不影响原数组)
const sorted = [...spans].sort((a, b) => a.start - b.start)
const segments: UserContentSegment[] = []
let cursor = 0 // 已消费到的字符偏移
for (const span of sorted) {
const start = span.start
const end = start + span.length
// 越界:起点/终点超出 content 长度 → 降级跳过(区间已失效)
if (start < 0 || end > content.length) continue
// 与前段重叠:起点在已消费 cursor 之前 → 降级跳过(防区间相互覆盖产生错乱)
if (start < cursor) continue
// 安全校验:切片内容必须 === span.label(用户编辑破坏区间则不等 → 降级跳过该 span)
if (content.slice(start, end) !== span.label) continue
// 前导 text(若有)
if (start > cursor) {
segments.push({ type: 'text', text: content.slice(cursor, start) })
}
// chip 段
segments.push({ type: 'chip', span })
cursor = end
}
// 收尾 tail text(末个 span 之后剩余)
if (cursor < content.length) {
segments.push({ type: 'text', text: content.slice(cursor) })
}
// 极端:所有 span 全降级且 content 非空 → segments 可能为空(无前导/tail),补一段完整 text
if (segments.length === 0 && content.length > 0) {
return [{ type: 'text', text: content }]
}
return segments
}
// ── UX-2025-20: 空状态示例问题 ──
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
const examplePrompts = [
'aiChat.examplePrompt1',
'aiChat.examplePrompt2',
'aiChat.examplePrompt3',
'aiChat.examplePrompt4',
] as const
const messagesContainer = ref<HTMLDivElement>()
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
// 当前视图是否正在生成(切走后台生成时光标不显示)
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.state.streaming && store.isGenerating(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
}
/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
/** 取消息的图片 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 → <img src>: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')
}
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存) ──
// 用户是否已在底部附近(上滑查看历史时不强制拉回)
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
}
function scrollToBottom(smooth = false) {
if (messagesContainer.value) {
isFollowingBottom = true // 显式回底 = 跟随意图
messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
})
showBackToBottom.value = false
}
}
// 消息/流式变化时:跟随意图则自动滚;否则只刷新回底按钮(不强制打断)
// 用 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 顺序执行)
// 流式 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 {
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):currentText 归零(新轮 AiAgentRound
// 清空 / 生成结束)时,同步清上一轮流式分块缓存 streamingBlocks + lastStreamText。
// 原:streaming watch(:435)仅在 streaming 翻 false 时清;但 agent 多轮连续生成时
// streaming 保持 true,新轮 AiAgentRound push 新空气泡 + currentText='',streamingBlocks
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
if (!text) {
streamingBlocks.value = []
lastStreamText = ''
}
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: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
// 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。
// buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。
if (store.isGenerating(store.state.activeConversationId)) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
} else {
// UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
saveSelection()
streamingBlocks.value = []
lastStreamText = ''
nextTick(() => restoreSelection())
}
})
// ── 工具卡片自动收起:新内容追加(新消息/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<string> {
const active = new Set<string>()
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<string>): void {
const tcl = toolCardListRef.value as unknown
const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : [])
refs.forEach((r: any) => r?.collapseInactive?.(active))
}
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 ToolCardList.scrollToFirstPending)。
* v-for 内 ref 数组,取第一个有该方法的实例。 */
function scrollToFirstPending(): void {
const tcl = toolCardListRef.value as unknown
const refs = Array.isArray(tcl) ? tcl : (tcl ? [tcl] : [])
for (const r of refs) {
if (typeof (r as any)?.scrollToFirstPending === 'function') {
;(r as any).scrollToFirstPending()
return
}
}
}
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))
},
)
// 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())
})
}
})
// ── 消息气泡拷贝(F-260615-13) ──
// 必须在 click 事件内(或其同步调用链)调 clipboard API (浏览器安全限制)
// navigator.clipboard 在非 secure context (Electron file:// / 旧版 webview) 不可用,
// 回退到 execCommand('copy') 兜底;两者均失败时提示复制失败而非假成功。
async function copyMsgContent(msg: AiMessage): Promise<void> {
const text = msg.content || ''
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
emit('toast', { msg: t('aiChat.copied'), type: '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)
emit('toast', {
msg: ok ? t('aiChat.copied') : t('aiChat.copyFailed'),
type: ok ? 'info' : 'error',
})
}
// ── UX-02: 重新生成最后一条 AI 回复 ──
// 复用 store.regenerate(后端 ai_regenerate:弹末尾 AI 回复→重跑 loop)。
// 失败 toast 提示,不影响其他状态(后端已复位 generating)。
async function handleRegenerate(): Promise<void> {
try {
await store.regenerate()
await nextTick()
scrollToBottom()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.regenerateFailed', { msg }), type: '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<void> {
try {
await store.regenerate()
await nextTick()
scrollToBottom()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.regenerateFailed', { msg }), type: 'error' })
}
}
// ── F-15 阶段2: 消息分段(归档段折叠) ──
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环):
// null | undefined | 'active' → 正常消息(现状)
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
// 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息)
// 'truncated' → switchConversation 已过滤,不到这里
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
interface AiMessageWithStatus extends AiMessage {
status?: string | null
}
type MessageSegment =
| { kind: 'normal'; msg: AiMessage }
| { kind: 'archived'; key: string; msgs: AiMessage[] }
| { kind: 'compressed'; key: string; msgs: AiMessage[] }
const messageSegments = computed<MessageSegment[]>(() => {
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<Set<string>>(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
}
type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
const renderItems = computed<RenderItem[]>(() => {
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
})
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
watch(() => store.state.activeConversationId, () => {
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
wasNearBottom = true
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
// 清 currentText 但实例级 streamingBlocks 未同步;② restoreGeneratingState(useAiWindow)
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
if (streamingBlocks.value.length) {
streamingBlocks.value = []
lastStreamText = ''
}
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
onBeforeUnmount(() => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
})
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。
loadMarkdown()
defineExpose({
scrollToBottom,
scrollToFirstPending,
})
</script>
<template>
<!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) -->
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
<template v-if="store.state.providers.length === 0">
<div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
<!-- Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 -->
<button class="ai-empty-action" @click="emit('go-to-settings')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettings') }}</span>
</button>
</template>
<template v-else>
<div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
<!-- UX-2025-20: 示例问题卡片(点击自动填入并发送) -->
<div class="ai-empty-prompts">
<button
v-for="(prompt, i) in examplePrompts"
:key="i"
class="ai-empty-prompt"
@click="emit('send-example-prompt', prompt)"
>{{ $t(prompt) }}</button>
</div>
</template>
</div>
<!-- 消息列表(F-15 阶段2: renderItems 扁平渲染 折叠段 emit 'sep' ,
正常消息 + 展开的折叠段内消息 emit 'msg' 连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文)
UX-2025-19: 每项外层包 .ai-msg-slot(B-260618-01 虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap .ai-messages 承担) -->
<template v-for="item in renderItems" :key="item.key">
<!-- B-260618-19: 纯空 assistant( content/error/toolCalls 且非末条流式) slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染; toolCalls 的空 assistant
仍渲染(工具卡可见修复 agentic 工具轮 LLM 无文本时工具卡消失) -->
<div
v-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
class="ai-msg-slot"
>
<!-- 折叠分隔条(归档段) 轻量,始终渲染 -->
<div
v-if="item.kind === 'sep'"
class="ai-msg-segment"
:class="'ai-msg-segment--' + item.seg.kind"
@click="toggleSegment(item.seg.key)"
>
<svg class="ai-msg-segment-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="item.seg.kind === 'archived'">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/>
</template>
<template v-else>
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>
</template>
</svg>
<span class="ai-msg-segment-text">
<template v-if="item.seg.kind === 'archived'">{{ $t('aiChat.contextArchived', { n: item.seg.msgs.length }) }}</template>
<template v-else>{{ $t('aiChat.compressed') }}</template>
</span>
<span class="ai-msg-segment-toggle">
{{ expandedSegmentIds.has(item.seg.key) ? $t('aiChat.collapse') : $t('aiChat.expand') }}
</span>
</div>
<!-- 消息(正常段 + 展开的折叠段内消息) 虚拟滚动已移除(UX-260617-01:IO/RO 时序致重叠),消息恒渲染 -->
<!-- B-260618-19: 纯空 assistant 过滤已上提到外层 .ai-msg-slot v-if(shouldRenderMsg),
此处 v-else 恒渲染 .ai-msg(sep 已在上方 v-if 分支处理,此处必为 msg) -->
<div
v-else
class="ai-msg"
:class="'ai-msg--' + item.msg.role"
:data-msg-id="item.msg.id"
>
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠, messageSegments) -->
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
</div>
<!-- 用户消息 -->
<div v-else-if="item.msg.role === 'user'" class="ai-msg-user">
<div class="ai-msg-avatar ai-msg-avatar--user">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<!-- Input Augmentation: mention 区间切段渲染(chip 精确替换 [类型: ] 标记,弃正则)
mentionSpans segmentUserContent 返回单段 text(零回归,等同原 {{ content }}) -->
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span
v-else
class="ai-msg-chip"
:class="'ai-msg-chip--' + seg.span.kind"
>{{ seg.span.label }}</span>
</template>
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
B-260618-01 虚拟滚动已移除,气泡内 <img> 随消息恒渲染无裁剪) -->
<template v-if="msgImageParts(item.msg).length">
<div class="ai-msg-images">
<img
v-for="(img, i) in msgImageParts(item.msg)"
:key="(item.msg.id) + '-img-' + i"
:src="imageSrc(img)"
:alt="imgAlt(img)"
class="ai-msg-image"
loading="lazy"
/>
</div>
</template>
<button
class="ai-copy-btn"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<!-- UX-09:末条 user 消息编辑按钮(非生成中非当前编辑态)hover 显示 -->
<button
v-if="isLastUser(item.msg) && !store.state.streaming && !editingMsgId"
class="ai-msg-edit-btn"
:title="$t('aiChat.editMessage')"
@click.stop="emit('start-edit', item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelative(item.msg.timestamp) }}</span>
</div>
<!-- AI 消息 -->
<div v-else class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>
</div>
<div class="ai-msg-content">
<!-- 文本内容流式或固定Markdown 渲染 -->
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 -->
<div
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
>
<!-- 流式:分块渲染(已完成块key稳定DOM不重建) -->
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText">
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
<!-- 完成/历史:整段v-html(不变) -->
<div v-else v-html="renderContent(item.msg)"></div>
<button
v-if="item.msg.content"
class="ai-copy-btn ai-copy-btn--ai"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<!-- 流式开始但尚无文本时显示光标 -->
<div v-else-if="isLastAi(item.msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
<span class="ai-cursor"></span>
</div>
<!-- UX-02:AI 消息操作栏(hover 显示:重新生成)
复制走气泡内右下角图标(ai-copy-btn--ai),本栏仅留重新生成,故只在
末条 AI 消息(重生成语义)非错误有内容非流式中显示 -->
<div
v-if="isLastAi(item.msg) && item.msg.content && !item.msg.isError && !store.state.streaming"
class="ai-msg-actions"
>
<button
class="ai-msg-act"
:disabled="store.state.streaming"
:title="$t('aiChat.regenerate')"
@click.stop="handleRegenerate"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('aiChat.regenerate') }}</span>
</button>
</div>
<!-- UX-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置)
isError 消息显示;重试仅可重试错误(network/timeout/unknown)显示,
Fatal (auth/provider_config 鉴权/4xx/参数错)重试必败故隐藏;
去设置 error_type {auth, provider_config} 显示;
Fatal 时附 notRetryable 提示,说明为何无重试按钮 -->
<div
v-if="item.msg.isError && !store.state.streaming"
class="ai-msg-actions ai-msg-actions--error"
>
<span v-if="!canRetry(item.msg)" class="ai-msg-not-retryable">{{ $t('ai.notRetryable') }}</span>
<button
v-if="canRetry(item.msg)"
class="ai-msg-act ai-msg-act--primary"
:disabled="store.state.streaming"
@click.stop="handleRetry"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
<span>{{ $t('aiChat.retry') }}</span>
</button>
<button
v-if="canOpenSettings(item.msg)"
class="ai-msg-act"
@click.stop="emit('go-to-settings')"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettingsAction') }}</span>
</button>
</div>
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
<div
v-if="isLastAi(item.msg) && !store.state.streaming && store.state.lastTokenUsage"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
<span>{{ formatTokens(store.state.lastTokenUsage.prompt) }} in · {{ formatTokens(store.state.lastTokenUsage.completion) }} out</span>
</div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path once/always/deny),
store.approveToolCall decision authorizeDir,缺省走 approve -->
<ToolCardList
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="item.msg.toolCalls"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
/>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间),流式生成中不显示避免抖动 -->
<span
v-if="!(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-time"
:title="formatDate(item.msg.timestamp)"
>{{ formatRelative(item.msg.timestamp) }}</span>
</div>
</div>
</div>
</div><!-- /.ai-msg-slot(B-260618-01:虚拟滚动已移除,恒渲染容器) -->
</template>
</div>
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
<Transition name="back-to-bottom">
<button v-if="showBackToBottom" class="ai-back-to-bottom" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
</Transition>
</template>
<style scoped>
/* ═══ 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;
}
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
.ai-msg-chip {
display: inline-block;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--df-radius);
font-size: 0.9em;
font-weight: 600;
line-height: 1.4;
background: rgba(255, 255, 255, 0.22);
color: #fff;
vertical-align: baseline;
white-space: nowrap;
}
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── 消息时间戳(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);
}
/* ── 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; }
/* 用户气泡内的图片(多模态消息渲染) */
.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);
}
/* ═══ 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; }
</style>