后端(src-tauri): - project.rs: 4处空if目录校验补return Err(import/relocate/scan_directory/scan_project_with_ai),目录不存在不再静默放行 - ai/tools/git.rs: exec_git改返(String,bool)治git_commit·git_branch create/switch假成功 + git_merge补失败分支 + 加CREATE_NO_WINDOW - module.rs: run_git_cmd加CREATE_NO_WINDOW,治Windows cmd黑窗闪烁(原问题9) 前端: - DependencyGraph.vue: 环检测高亮注入renderGraph,原被fromJSON重建抹除致功能失效 - FileExplorer.vue: 工程下拉closeDropdown加closest判定,原打不开 附(预存编译阻断顺带修): - df-nodes ai_node_helpers.rs: extract_first_json_object临时值借用悬垂E0716 - MessageList.vue: 删isLastUser死代码(vue-tsc TS6133) 审查产出: docs/05-代码审查/UIUX扩展审查-2026-08-02.md(80条发现,8高grep核验全属实)+ docs/todo.md批次段
1150 lines
46 KiB
Vue
1150 lines
46 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 { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||
import ToolCardList from '../ToolCardList.vue'
|
||
import MessageItem from './MessageItem.vue'
|
||
import EmptyState from './EmptyState.vue'
|
||
import { formatRelative, formatDate } from '../../utils/time'
|
||
import type { AiMessage } 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)抽至
|
||
// composables/ai/useStreamRenderer(本组件经 opts 注入 saveSelection/restoreSelection/
|
||
// isFollowingBottom/scrollToBottom 回调),行为零变化。
|
||
//
|
||
// useStreamRenderer 的初始化延后到 saveSelection/restoreSelection 定义之后(下方),
|
||
// 因 composable 的 opts 依赖这两个函数。mdReady/renderMd/escapeFallback/streamingBlocks/
|
||
// scheduleStreamParse/renderStreamingBlocks/clearStreamingState 均由 composable 返回。
|
||
const { loadMarkdown } = useMarkdown()
|
||
|
||
// ── UX-2025-20: 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
|
||
|
||
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
|
||
}
|
||
|
||
/** 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 跟随意图锁存,已抽取至 useMessageScroll) ──
|
||
const {
|
||
showBackToBottom,
|
||
isFollowingBottom,
|
||
onMessagesScroll,
|
||
scrollToBottom,
|
||
onContentChange,
|
||
setFollowing,
|
||
} = useMessageScroll(messagesContainer)
|
||
|
||
// ═══ 流式 Markdown 块级 memo 渲染(抽至 composables/ai/useStreamRenderer) ═══
|
||
// opts 注入 saveSelection/restoreSelection(function 声明,提升可用,定义在下方)、
|
||
// isFollowingBottom/scrollToBottom(来自上方 useMessageScroll)。
|
||
const {
|
||
mdReady,
|
||
renderMd,
|
||
streamingBlocks,
|
||
scheduleStreamParse,
|
||
clearStreamingState,
|
||
cancelPendingRaf,
|
||
} = useStreamRenderer({
|
||
saveSelection,
|
||
restoreSelection,
|
||
isFollowingBottom,
|
||
scrollToBottom,
|
||
})
|
||
|
||
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) {
|
||
clearStreamingState()
|
||
}
|
||
onContentChange()
|
||
}
|
||
})
|
||
watch(mdReady, (ready) => {
|
||
if (ready && store.state.streaming && store.state.currentText) {
|
||
scheduleStreamParse(store.state.currentText)
|
||
}
|
||
})
|
||
watch(() => store.state.streaming, (s) => {
|
||
// 原行为:两分支均先取消 pending rAF(streaming 中途切换帧防泄漏);s===true 不清
|
||
// streamingBlocks(依赖 currentText watch 的 !text 分支按新轮时机清,BUG-260624-01)。
|
||
cancelPendingRaf()
|
||
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()
|
||
clearStreamingState()
|
||
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] : [])
|
||
// BUG-260624-01 残留修复:遍历所有实例调 scrollToFirstPending,各实例内部 querySelector 无
|
||
// pending 卡则 no-op,含 pending 的实例滚到其首张待审批卡。原 `return` 首个有方法的实例即停,
|
||
// pending 若落在后续实例(头部徽标点击场景)滚不到。移除 return 让所有实例尝试,no-op 实例零成本。
|
||
for (const r of refs) {
|
||
if (typeof (r as any)?.scrollToFirstPending === 'function') {
|
||
;(r as any).scrollToFirstPending()
|
||
}
|
||
}
|
||
}
|
||
|
||
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-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' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
|
||
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
|
||
* AI 拿到能直接 run_command 不需再问;装完 obscura 即可用,用户重试 fetch_url 即走 JS 渲染。
|
||
* 复用 store.sendMessage(经 useAiSend 暴露,触发 ai_chat_send → agentic loop)。
|
||
* 失败 toast(对齐 handleRetry 错误处理模式)。
|
||
*/
|
||
async function onInstallObscura(): Promise<void> {
|
||
try {
|
||
await store.sendMessage(t('aiTool.obscuraInstallPrompt'))
|
||
await nextTick()
|
||
scrollToBottom()
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
emit('toast', { msg: t('aiChat.sendFailed', { msg }), type: 'error' })
|
||
}
|
||
}
|
||
|
||
// ── F-15 阶段2: 消息分段(归档段折叠) ──
|
||
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
|
||
// null | undefined | 'active' → 正常消息(现状)
|
||
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
|
||
// 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息)
|
||
// 'truncated' → switchConversation 已过滤,不到这里
|
||
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
|
||
|
||
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.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].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: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
||
setFollowing(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) {
|
||
clearStreamingState()
|
||
} else {
|
||
cancelPendingRaf()
|
||
}
|
||
})
|
||
|
||
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
|
||
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
|
||
onBeforeUnmount(() => {
|
||
cancelPendingRaf()
|
||
})
|
||
|
||
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
||
// 多次调用安全,确保子组件挂载即预热)。
|
||
loadMarkdown()
|
||
|
||
defineExpose({
|
||
scrollToBottom,
|
||
scrollToFirstPending,
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- Messages -->
|
||
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
|
||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||
<EmptyState
|
||
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||
:has-provider="store.state.providers.length > 0"
|
||
@send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)"
|
||
@go-to-settings="emit('go-to-settings')"
|
||
/>
|
||
|
||
<!-- 消息列表(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="renderMd(item.msg.content)"></div>
|
||
</div>
|
||
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
|
||
<MessageItem
|
||
v-else-if="item.msg.role === 'user'"
|
||
:msg="item.msg"
|
||
@copy="copyMsgContent"
|
||
/>
|
||
|
||
<!-- 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="renderMd(item.msg.content)"></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-03 / UX-260616-01:错误气泡操作入口(重试 / 去设置) -->
|
||
<div v-if="item.msg.isError" 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 用量(显示在每条 assistant 消息底部,非流式且有数据时) -->
|
||
<div
|
||
v-if="item.msg.role === 'assistant' && !store.state.streaming && item.msg.tokenUsage"
|
||
class="ai-token-usage"
|
||
>
|
||
<span class="ai-token-usage-icon">🔣</span>
|
||
<span>{{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.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)"
|
||
@install-obscura="onInstallObscura"
|
||
/>
|
||
|
||
<!-- 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 样式已随 EmptyState.vue 提取 -- */
|
||
|
||
/* -- 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>
|