1590 lines
67 KiB
Vue
1590 lines
67 KiB
Vue
<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>
|