Files
DevFlow/src/components/ai/MessageList.vue
T
lxy c2809bd927 新增: 压缩摘要折叠进所属轮次轨迹卡
- 压缩摘要进轮次桶随轨迹卡收起(旧口径原样展开作废)
- 轨迹卡内渲染为摘要 pill 单行, 点击展开全文(复用思考展开态)
- 压缩摘要不竞选最终答复气泡, 过程记录语义
2026-10-11 08:53:36 +08:00

3242 lines
144 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
//! 消息列表子组件 — 第四批抽自 src/components/AiChat.vue(零行为变更)。
//!
//! 职责:消息列表渲染(.ai-messages 容器 + 空状态 + renderItems 扁平渲染 +
//! 消息气泡 user/ai/system + 流式 Markdown 分块渲染 + 工具卡 ToolCardList 转发审批 +
//! 折叠归档段 + 选区保持 + 滚动跟随/回底部)。
//!
//! 共享:store(useAiStore 单例)/ Markdown 渲染器(useMarkdown 模块级单例)与父同实例,
//! currentText/streamingBlocks/messages 经 store,流式行为零变化。
//!
//! 与父交互(emit/expose):
//! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus
//! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt
//! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级)
//! - emit('toast', {msg, type}):copy/retry 等的 toast 转发(父持单一 toast 源)
//! - expose(scrollToBottom):父 ChatInput @sent 后调,滚动到底
import { ref, computed, nextTick, onBeforeUnmount, watch, type Ref } 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 { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { getConvAgentFinished, toolEpoch, type AgentFinishedCard } from '../../composables/ai/aiShared'
import { aiApi } from '../../api'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
import { formatDate, formatAbsolute } from '../../utils/time'
import { taskApi } from '../../api'
import type { AiMessage, TaskRecord } from '../../api/types'
defineProps<{
/** 当前正在编辑的消息 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: 'go-to-task', taskId: string): void
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ──
// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中
// (advance_task 用 id / run_workflow 用 task_id,与 useToolCardHeader.TASK_ID_TOOL_ARG 同源)。
// 不新增 AiMessage 字段:关联是「从已有 toolCalls 派生的计算属性」,单一事实源。
// 任务标题懒加载全量任务缓存(taskApi.list 无参=全量,失败空数组占位防重试),复用 ChatInput 既有模式。
const TASK_REF_TOOLS: Record<string, 'id' | 'task_id'> = {
advance_task: 'id',
run_workflow: 'task_id',
}
const taskTitleCache = ref<Map<string, string> | null>(null)
async function ensureTaskTitles(): Promise<void> {
if (taskTitleCache.value) return
try {
const list = await taskApi.list()
taskTitleCache.value = new Map(list.map((tk: TaskRecord) => [tk.id, tk.title]))
} catch (e) {
taskTitleCache.value = new Map() // 空占位防反复重试
console.error('[MessageList] 加载任务标题失败:', e)
}
}
/** 从消息的 toolCalls 提取关联任务(无任务工具则返回空数组 = 该消息无任务关联) */
function taskRefsOf(msg: AiMessage): { taskId: string; toolName: string }[] {
const refs: { taskId: string; toolName: string }[] = []
for (const tc of msg.toolCalls ?? []) {
const key = TASK_REF_TOOLS[tc.name]
if (!key) continue
const args = tc.args as Record<string, unknown> | undefined
const taskId = args?.[key]
if (typeof taskId === 'string' && taskId) refs.push({ taskId, toolName: tc.name })
}
return refs
}
/** 任务标题(缓存命中用标题,未命中显短 id;缓存未就绪时触发懒加载) */
function taskTitleOf(taskId: string): string {
if (!taskTitleCache.value) { void ensureTaskTitles(); return taskId.slice(0, 8) }
return taskTitleCache.value.get(taskId) ?? taskId.slice(0, 8)
}
/** 一轮轨迹内所有过程消息的任务关联去重(同一任务多次调用只留一个 chip) */
function traceTaskRefs(msgs: AiMessage[]): { taskId: string; toolName: string }[] {
const seen = new Set<string>()
const out: { taskId: string; toolName: string }[] = []
for (const m of msgs) {
for (const ref of taskRefsOf(m)) {
if (seen.has(ref.taskId)) continue
seen.add(ref.taskId)
out.push(ref)
}
}
return out
}
/** 点击任务 chip 跳转任务详情:委托父组件(AiChat)走 router.push('/tasks'),detached 降级 toast。
与既有 go-to-settings 同模式——子组件不直接持 router,降级策略归父。 */
function openTask(taskId: string): void {
emit('go-to-task', taskId)
}
// ═══ Markdown 渲染自研块级 memo,借鉴方案D流式核心) ═══
// 渲染基础设施(_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()
// ── 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
const messagesContainer = ref<HTMLDivElement>()
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
// 当前视图是否正在生成(切走后台生成时光标不显示)
// 多会话并发 — 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
}
// UX-09:末条 user 用倒序判定而非"最后一条消息是否 user"——AI 已回复后编辑按钮仍应挂在触发消息上
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
}
/**
* 是否渲染某条消息(供 .ai-msg-slot v-if 守卫)。
*
* 背景:agentic 多轮下,LLM 某轮只发 tool_calls 不输出文本时(GLM anthropic_compat 常见),
* 该轮 assistant 消息 content='' + toolCalls=[...];变中间条后,旧 v-else-if
* 漏判 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?.trim() // 根因修复:!content 只拦空串漏 whitespace(空气泡)
&& !msg.isError
&& !(msg.toolCalls && msg.toolCalls.length)
&& !(isLastAi(msg) && store.state.streaming)) {
return false
}
return true
}
/** token 数格式化收敛到 useTurnTrace.formatTokensK(与轨迹卡头部汇总同一格式化出口) */
// ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ──
//
// 各 provider 计费不同(deepseek:cache 命中低价/未命中全价/输出价高/reasoning 隐藏输出),
// 故不显 total(三者相加无意义)。in=cache_miss(全价真实),cache=cache_hit(命中),out=completion,
// reasoning > 0 才显(reason 后缀)。
//
// 详情面板(tokenPopoverMsgId 控制显隐):点击 token 区弹出,含完整 usage + 流程(模型/缓存命中率)。
// 流程数据从 message 取(model 在 msg.model;cache 命中率 = hit/(hit+miss))。
const tokenPopoverMsgId = ref<string | null>(null)
/** 取消息的 in token(全价输入)= prompt_tokens(总) - cache_hit(命中低价)。
* 统一 GLM(无 cache,prompt_tokens 即全价)与 deepseek(prompt=hit+miss,全价=miss=prompt-hit)。
* 原 cache_miss ?? prompt 因 agent 设 cache_miss=0(GLM 不报)致 ?? 不触发返 0,改减法。 */
function tokenInOf(m: AiMessage): number {
const prompt = m.tokenUsage?.prompt ?? m.prompt_tokens ?? 0
const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
return Math.max(0, prompt - hit)
}
/** 取消息的 cache hit token */
function tokenCacheOf(m: AiMessage): number {
return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
}
/** cache 段是否有真实上报数据(渲染闸):hit/miss 均为 0 时「provider 没报缓存」与
* 「真 0」不可区分(GLM 无缓存口径,估算轮更无真实用量),渲染「0 cache」会误导,不渲染。 */
function tokenCacheVisible(m: AiMessage): boolean {
if (tokenEstimated(m)) return false
const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
const miss = m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? 0
return hit > 0 || miss > 0
}
/** 取消息的 out token */
function tokenOutOf(m: AiMessage): number {
return m.tokenUsage?.completion ?? m.completion_tokens ?? 0
}
/** 取消息的 reasoning token */
function tokenReasonOf(m: AiMessage): number {
return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0
}
/** G4.3:该消息 token 用量是否为估算值(后端 AiCompleted.is_estimated 透传:
* round_usage.prompt_tokens==0 时用 estimated_prompt 兜底)。true 时主显示/详情标注『估算』,
* 让用户区分估算值 vs 真实用量。 */
function tokenEstimated(m: AiMessage): boolean {
return m.tokenUsage?.is_estimated === true
}
/** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */
function cacheHitRate(m: AiMessage): number | null {
const hit = tokenCacheOf(m)
const miss = tokenInOf(m)
const sum = hit + miss
return sum > 0 ? Math.round((hit / sum) * 100) : null
}
/** 切换 token 详情面板显隐(同消息再点关,不同消息切) */
function toggleTokenPopover(m: AiMessage, e: Event): void {
e.stopPropagation()
const id = m.id
tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id
}
// ── 滚动跟随 / 回到底部(跟随意图锁存,已抽取至 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,
resetStreamCache,
} = useStreamRenderer({
saveSelection,
restoreSelection,
isFollowingBottom,
scrollToBottom,
})
// ── G3.5:滚顶加载更早历史(load_more 分页) ──
// 后端 ai_conversation_switch 已返 has_more/earliest_seq 游标(useAiConversations 模块持有,
// 本组件经 getLoadMoreState 读同一引用)。滚顶到阈值触发 loadMoreHistory → IPC → prepend +
// 按 id 去重 + scrollTop 恢复(视口停留在原消息处不闪跳)。最小接线,不重构本组件。
const loadMoreCursor = getLoadMoreState()
const LOAD_MORE_TOP_PX = 60
async function handleMessagesScroll(): Promise<void> {
// 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持
// R10(2026-10-05):收起旧卡加 isFollowingBottom 门——上滚读历史时新轮次/新卡到达
// 不再强制收起正浏览的工具卡(跟随意图锁存语义对齐 useMessageScroll.isFollowingBottom);
// 用户在底部跟随态时行为不变(自动收起聚焦新生成)。
onMessagesScroll(() => {
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
})
// G3.5:滚顶阈值触发加载更早历史
const cur = loadMoreCursor
if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return
const el = messagesContainer.value
if (!el) return
if (el.scrollTop > LOAD_MORE_TOP_PX) return
const prevScrollHeight = el.scrollHeight
const inserted = await loadMoreHistory()
if (!inserted) return
const container = messagesContainer.value
if (!container) return
await nextTick()
// 恢复滚动:内容增高差 = 新 scrollHeight - 旧 scrollHeight,视口停留在原消息处不闪跳
const diff = container.scrollHeight - prevScrollHeight
container.scrollTop = Math.max(diff, 0)
}
watch(() => store.state.messages.length, onContentChange)
// currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
// - streaming 翻转:结束清 rAF + 清 streamingBlocks(回 renderMd 走 final marked + 缓存)
// - mdReady 翻转首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback
// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化
// 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。
// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。
watch(() => store.state.currentText, (text) => {
// 流式滚动跟随由 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) {
// 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
// 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。
// buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。
if (store.isGenerating(store.state.activeConversationId)) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
} else {
// 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
saveSelection()
clearStreamingState()
nextTick(() => restoreSelection())
anchorCollapseScroll()
}
})
// ── UX-P3: 长回复完成瞬间折叠的滚动锚 ──
// 末条 AI 回复超阈值(LONG_MSG_THRESHOLD)时,完成瞬间 shouldCollapseLong 从 false 翻 true
// (流式中不折叠的约束解除),气泡收缩到 400px;跟随意图下的 scrollToBottom 把视口钉在收缩后
// 的底部,内容整体上跳,刚流完的答案开头/结尾都看不全。改锚消息顶:本回调在流式结束当拍注册
// nextTick(此时折叠 class 尚未渲染),渲染拍后把该消息顶对齐视口顶,视口落点确定(答案开头,
// 回底按钮照常出现),不再随内容收缩被浏览器 clamp 到任意位置。
// 仅跟随意图成立时干预(用户上滑读历史不拉扯);手动展开过的消息不会折叠,无收跳不处理。
// 注册时序:currentText watch(先注册)的 scrollToBottom nextTick 先入队,本 nextTick 后入队
// 后执行,锚定是滚动链的最后一手,不被钉底覆盖。
const COLLAPSE_ANCHOR_PAD_PX = 8
function anchorCollapseScroll(): void {
if (!isFollowingBottom.value) return
const last = store.state.messages[store.state.messages.length - 1]
if (!last || !shouldCollapseLong(last)) return
nextTick(() => {
const c = messagesContainer.value
const el = c?.querySelector(`[data-msg-id="${last.id}"]`)
if (!c || !el) return
c.scrollTop += el.getBoundingClientRect().top - c.getBoundingClientRect().top - COLLAPSE_ANCHOR_PAD_PX
})
}
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
//
// 性能:快照仅含 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 短路掉无意义重算)。
//
// 触发时机扩展。除原 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()
}
}
}
/** 审批卡到达但用户不在底部(上滚读历史中):不强拉滚动,出「有新审批」轻提示。
* ToolCardList 侧已做 5s 去重,此处仅转发父 toast(单一出口)。 */
function onPendingOutOfView(): void {
emit('toast', { msg: t('aiChat.newApprovalPending'), type: 'info' })
}
/**
* 工具卡自动收起:工具状态翻转(新卡/完成/待审批/拒绝)时收起旧的已完成/已拒绝卡片,
* 保留 running/pending/write_file 活跃卡片。
*
* P0 渲染层:源从 `map(toolCalls→{id,s}) + JSON.stringify` 的 O(n) 指纹改为 toolEpoch 版本计数(O(1))。
* 版本号在工具态突变点(aiShared.bumpToolEpoch)自增,免每次工具事件全量遍历长会话数组。
* messages.length 变化(新消息/截断/切会话)同样触发(messages 长度与 toolEpoch 任一变即回调)。
*/
watch(
[() => store.state.messages.length, toolEpoch],
() => {
// R10 同门:非跟随态(上滚阅读中)不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
// AiAgentRound 新轮次到达时收起上一轮工具卡(round 递增)。
// 首次加载不触发(等真到达更"新"的轮次才收起,避免历史会话加载即折叠)。
watch(
() => store.state.agentRound,
(cur, prev) => {
if (cur <= (prev ?? 0)) return
// R10 同门:非跟随态(上滚阅读中)新轮次到达不强制收起正浏览的工具卡
if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
},
)
// mdReady 就绪后主动触发首屏 Markdown 重渲染
// (marked 加载慢 + 无新 delta 时末段停留纯文本,不触发模板重算)
const _mdRenderKey = ref(0)
function forceUpdate() { _mdRenderKey.value++ }
// ── 选文字保持(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())
})
}
})
// ── 消息气泡拷贝──
// 必须在 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',
})
}
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
// 用 html2canvas-pro 替代 html2canvas:后者不支持 color-mix/oklch 现代色彩函数,渲染气泡样式会抛错
async function copyMsgAsImage(msg: AiMessage): Promise<void> {
// msg.id 经 CSS.escape 转义:含特殊字符的 id 在属性选择器里会匹配失败(引号内转义合法)
const bubble = messagesContainer.value?.querySelector<HTMLElement>(
`.ai-msg[data-msg-id="${CSS.escape(msg.id)}"] .ai-msg-bubble`,
)
if (!bubble) {
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
return
}
try {
const { default: html2canvas } = await import('html2canvas-pro')
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('toBlob failed')
if (typeof ClipboardItem !== 'undefined') {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
return
} catch {
// 剪贴板被拒/不可用时降级下载,保证用户仍能拿到图片
}
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'message.png'
a.click()
URL.revokeObjectURL(url)
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
} catch {
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
}
}
// ── R4 代码块复制:渲染层 html 装饰 + 容器级事件委托 ──
// 代码块经 marked 渲染后以 v-html 注入,模板层无法逐块插按钮;改为在 html 字符串层把
// <pre><code> 包进 .md-code-block 并注入复制按钮(发生在 renderMd 的 DOMPurify 消毒之后,
// 注入节点不经消毒通道,仅静态结构无用户内容,安全)。点击走容器级事件委托(单监听器,
// 不逐块绑定)。结果 memo 缓存:v-html 按字符串相等决定是否重写 DOM,不缓存会导致组件每次
// 重渲染都重设 innerHTML,打破 AC-EFF-S2-1「已完成块 DOM 稳定」优化。
const _decoCache = new Map<string, string>()
const DECO_CACHE_LIMIT = 800
function decorateCodeHtml(raw: string): string {
if (!raw || !raw.includes('<pre><code')) return raw
const hit = _decoCache.get(raw)
if (hit !== undefined) {
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
_decoCache.delete(raw)
_decoCache.set(raw, hit)
return hit
}
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
const out = raw.replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
(_m: string, attrs: string, body: string) => {
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
const langAttr = lang ? ` data-lang="${lang}"` : ''
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
},
)
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
if (_decoCache.size >= DECO_CACHE_LIMIT) {
const oldest = _decoCache.keys().next().value
if (oldest !== undefined) _decoCache.delete(oldest)
}
_decoCache.set(raw, out)
return out
}
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
function renderMdDecorated(raw: string): string {
// 闪烁修复(2026-10-09):读 _mdRenderKey 建立响应式依赖——mdReady 就绪后 forceUpdate()
// 递增此 ref 触发本函数重算(拿最新 marked 渲染),但**不再把 _mdRenderKey 放进 :key**
// (key 变化会让 Vue 销毁重建整个气泡 DOM,marked 冷加载完成瞬间即「回复闪一下」)。
// 依赖放在渲染函数内:只更新 v-html 内容,气泡元素复用不重建。
void _mdRenderKey.value
return decorateCodeHtml(renderMd(raw))
}
// 流式分块同走装饰(computed 缓存:块 html 未变时返回同串,v-html 不触发 DOM 重写)
const decoratedStreamingBlocks = computed(() =>
streamingBlocks.value.map(b => ({ key: b.key, html: decorateCodeHtml(b.html) })),
)
/** 容器级事件委托:点击 .md-code-copy 复制所属代码块原文(pre.textContent 自动还原 HTML 转义) */
async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
const btn = (e.target as HTMLElement | null)?.closest('button.md-code-copy') as HTMLButtonElement | null
if (!btn) return
const code = btn.parentElement?.querySelector('pre')?.textContent ?? ''
// 必须在 click 同步链内发起 clipboard 调用(浏览器安全限制);非 secure context 走
// execCommand 兜底(对齐 copyMsgContent 降级链),两者均失败如实反馈
let ok = false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code)
ok = true
}
} catch { /* 权限拒绝/中断,落入 execCommand 兜底 */ }
if (!ok) {
const ta = document.createElement('textarea')
ta.value = code
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try { ok = document.execCommand('copy') } catch { ok = false }
document.body.removeChild(ta)
}
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
const prev = btn.textContent
btn.textContent = ok ? '已复制' : '复制失败'
btn.classList.add(ok ? 'is-ok' : 'is-err')
setTimeout(() => {
btn.textContent = prev
btn.classList.remove('is-ok', 'is-err')
}, 1200)
}
// ── 错误气泡操作入口 ──
// 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'
}
/**
* 错误是否可重试(显隐「重试」按钮)。
* Fatal 类错误(鉴权/Provider 配置错——4xx/401/403/invalid_api_key)重试必再败,
* 隐藏重试按钮,引导用户走「去设置」。network/timeout/unknown 为瞬时错误,可重试。
* error_type 由后端 emit 点分类(2026-10-04 起 emit_fatal_error 对模型不存在/令牌无权/
* 参数拒绝类 400 归 provider_config);后端判不了的真 Unknown 仍可重试,维持现状。
*/
function canRetry(msg: AiMessage): boolean {
// 重试过渡气泡(isRetry)非终态错误,不提供重试入口(终态 AiError 才有重试按钮)
if (msg.isRetry) return false
const et = (msg as AiMessageWithErrorType).errorType
// auth/provider_config = Fatal(鉴权/配置错),重试无意义
if (et === 'auth' || et === 'provider_config') return false
return true
}
/**
* 错误气泡「重试」:取错误前的末尾 user 消息重发。
* 复用 regenerate 链路(后端弹末尾 AI 回复——错误消息本身——取触发它的 user 消息重跑)。
* 语义等价"重新生成",但语义场景是错误恢复,文案/入口独立。
*/
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' })
}
}
/**
* 看板 953:截断气泡「继续输出」按钮。回复被 provider max_tokens 上限腰斩时,注入续写指令
* 走既有发送通道(复用 store.sendMessage,对齐 onInstallObscura 模式),模型从上条回复的
* 中断处衔接输出,不再整段重发。streaming 中按钮 disabled(模板绑定);失败 toast 对齐
* handleRetry 错误处理模式。
*/
async function onContinueAfterTruncation(): Promise<void> {
try {
await store.sendMessage(t('ai.continueOutputPrompt'))
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
})
// ── 展开/切换态集合工厂(DRY 2026-10-05) ──
// 五组「Set 集合 + 同构 toggle」(折叠段/长消息/JSON 块/轨迹卡/思考 pill)此前五连逐字重复,
// 收敛本工厂。ids 返回 Ref<Set> 本体:script 侧 .value.has / 整体重置赋值 / 模板侧自动解包
// .has() 三种既有用法全部零改动,仅收敛重复的 toggle 实现。
// 不抽独立 composable 文件:五处仅本组件消费,文件内 DRY 即可,跨文件反增跳转成本。
function useToggleSet(): { ids: Ref<Set<string>>; toggle: (id: string) => void } {
const s = ref<Set<string>>(new Set())
return {
ids: s,
toggle: (id) => {
const next = new Set(s.value)
if (next.has(id)) next.delete(id)
else next.add(id)
s.value = next
},
}
}
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet()
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。
// 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
const LONG_MSG_THRESHOLD = 1500
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet()
function isLongMsg(msg: AiMessage): boolean {
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
}
/// 是否折叠展示该 assistant 文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。
function shouldCollapseLong(msg: AiMessage): boolean {
if (!isLongMsg(msg)) return false
if (expandedLongIds.value.has(msg.id)) return false
// 流式约束:正在生成的当前消息不折叠(避免 delta 重排抖动),参考 L92 同款 streaming 判定。
if (isLastAi(msg) && store.state.streaming) return false
return true
}
/// 折叠态是否跳过 markdown 渲染(B5 惰性渲染):折叠气泡视觉上只有 400px 高的 CSS 裁剪,
/// 但 v-html 仍全量渲染整段 markdown(150+ msg 长会话切换/挂载成本随消息数线性涨的主因)。
/// 跳过渲染后折叠态只显示首屏预览(前 800 字符)——展开瞬间才付全量渲染成本(一次性,可接受)。
/// 注意:预览截断可能断在 markdown 语法中间(半代码栅栏/半表格),渲染器按普通文本处理无副作用,
/// 仅视觉上「预览尾部可能带半个语法符号」;展开即见完整内容,不误导。
function collapsedPreviewHtml(msg: AiMessage): string {
return renderMdDecorated(msg.content.slice(0, 800))
}
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
// 展开态仍复用既有 aiChat.collapse 键。
const CHAR_PER_TOKEN_EST = 2.5
function collapsedLongLabel(msg: AiMessage): string {
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
}
/**
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
* 成功)时,弱化为灰小字折叠块(.ai-msg-json),不渲染 markdown 大气泡刷屏。
* 仅完成/历史态生效:isLastAi && streaming 时 content 是流式累积的截断 JSON(JSON.parse
* 必失败/误判),仍走 currentText 分块渲染,本检测对流式态短路。
*/
function isToolResultJson(content: string | undefined | null): boolean {
if (!content) return false
const t = content.trim()
if (!(t.startsWith('{') || t.startsWith('['))) return false
try {
JSON.parse(t)
return true
} catch {
return false
}
}
/// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动)
const { ids: expandedJsonIds, toggle: toggleJson } = useToggleSet()
// A 过程压缩:执行轨迹展开态
// 完成轮次的过程文本+工具卡默认折叠为一张轨迹卡;用户手动展开后记入此集合,
// 不再被自动收回(换轮次/切会话回来都保持,会话切换时整体重置)。
const { ids: expandedTraceIds, toggle: toggleTrace } = useToggleSet()
// UX 密度改造:轨迹内每轮思考单行 pill(灯泡+单行截断+右端 token 小字),点击展开完整思考段落。
// 展开态按 msg.id 记忆,与轨迹卡展开态(expandedTraceIds)互不影响。
const { ids: expandedThinkIds, toggle: toggleThink } = useToggleSet()
/** 轮次思考纯文本:reasoning_content 优先(DeepSeek/Anthropic thinking 同通道),content 兜底;
* 压平 markdown 换行;两者皆空回退「过程记录」占位(纯工具轮也有锚点) */
function thinkPlainText(m: AiMessage): string {
// 只承载「推理内容」(正文不折叠,2026-10-10):原 `reasoning_content || content` 会把模型正文
// 也当思考吞进单行 pill——正文改由常显气泡渲染(见 renderItems),此处只认 reasoning_content。
const src = m.reasoning_content || ''
return src.replace(/\s+/g, ' ').trim()
}
/** 压缩摘要 pill 单行:纯文本空白归一截断(961 轮次记录形态,title 承载全文) */
function compactSnippet(content: string | undefined): string {
const txt = (content || '').replace(/\s+/g, ' ').trim()
return txt.length > 80 ? txt.slice(0, 80) + '…' : txt
}
/** pill 单行文本:纯文本截断 + 空轮回退文案(title 承载全文,此处只管单行) */
function thinkSnippet(m: AiMessage): string {
const txt = thinkPlainText(m)
return txt || t('ai.trace.processOnly')
}
/** pill hover title:完整思考文本(超长截断防 tooltip 卡顿) */
function thinkFullText(m: AiMessage): string {
const txt = thinkPlainText(m)
if (!txt) return t('ai.trace.processOnly')
return txt.length > 500 ? txt.slice(0, 500) + '…' : txt
}
/** 该轮是否有实质思考内容:空内容轮(pill 只会显示「过程记录」占位)不渲染 pill,消除占位噪音;
* 该轮工具卡照常渲染,有实质内容的思考行不受影响。 */
function hasThinkBody(m: AiMessage): boolean {
const txt = thinkPlainText(m)
return !!txt && txt !== t('ai.trace.processOnly')
}
/** 思考 pill 右端 token 小字:「30.3k in(估算) · 12k cache · 157 out」(无 token 数据返回空串隐藏)。
* cache 段仅在 provider 真实上报时渲染(tokenCacheVisible),避免把「无数据」显示成「0 cache」。 */
function roundTokenText(m: AiMessage): string {
if (!m.tokenUsage && m.prompt_tokens == null) return ''
const est = tokenEstimated(m) ? `(${t('aiChat.estimated')})` : ''
const parts = [`${formatTokensK(tokenInOf(m))} in${est}`]
if (tokenCacheVisible(m)) parts.push(`${formatTokensK(tokenCacheOf(m))} cache`)
parts.push(`${formatTokensK(tokenOutOf(m))} out`)
if (tokenReasonOf(m) > 0) parts.push(`${formatTokensK(tokenReasonOf(m))} reason`)
return parts.join(' · ')
}
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.turnSummaryText) */
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
return turnSummaryText(stats, endTs - startTs)
}
/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */
function traceHoverTitle(startTs: number, endTs: number): string {
return `${formatDate(startTs)} ~ ${formatDate(endTs)}`
}
/**
* 执行中实时步骤摘要:「第 N 轮 · ✓ N 次调用完成 · 失败 N · 正在 {工具} · ⏳ N 项待审批」。
* 轮次号与轨迹卡同源(flushBucket 的 currentRound,发起计数口径见 renderItems);
* 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。
*/
function stepText(s: TurnCallStats, round: number): string {
const parts: string[] = [t('ai.trace.roundN', { n: round })]
const done = s.ok + s.fail
if (done > 0) parts.push(t('ai.trace.stepDone', { n: done }))
if (s.fail > 0) parts.push(t('ai.trace.fail', { n: s.fail }))
if (s.rejected > 0) parts.push(t('ai.trace.rejected', { n: s.rejected }))
if (s.running > 0 && s.runningName) parts.push(t('ai.trace.stepRunning', { name: s.runningName }))
if (s.pending > 0) parts.push(t('ai.trace.stepPending', { n: s.pending }))
return parts.join(' · ') || t('ai.trace.processOnly')
}
type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage; final?: boolean }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
// A 过程压缩:完成轮次的执行轨迹卡(traceMsgs 为折叠在内的过程消息)与执行中的实时步骤摘要;
// round=会话轮号(卡头「第 N 轮」,961③ 发起计数口径,导航条/步骤行同源)
| { kind: 'trace'; key: string; round: number; stats: TurnCallStats; startTs: number; endTs: number; tokens: number; traceMsgs: AiMessage[]; expanded: boolean }
| { kind: 'step'; key: string; round: number; stats: TurnCallStats; traceMsgs: AiMessage[]; expanded: boolean }
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
| { kind: 'timesep'; key: string; ts: number }
// S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
| { kind: 'agentFin'; key: string; card: AgentFinishedCard }
/**
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
*
* 轮次 = 末条 user 之后的连续 assistant 消息(错误气泡/压缩摘要/归档分段不进桶,原样渲染)。
* - 执行中:仅末条消息(当前步骤)展开,先前步骤收进一行实时摘要(step 项,由工具事件聚合)。
* - 完成:轮次内除最终答复外的过程消息折叠为一张执行轨迹卡(trace 项,默认一行聚合),
* 点开在卡内 rail 面板回看(每轮思考 pill + 工具卡);用户手动展开记入 expandedTraceIds
* 不被收回。正文区只留最终答复。
* 无过程消息的轮次(单轮问答)零改动原样渲染。
*
* UX 密度改造:相邻消息间隔 ≥30 分钟或跨天时插居中时间分隔线(timesep 项),
* per-block 相对时间已移除。轨距追踪 lastTs(renderItems 局部)只在消息桶边界推进,
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
*/
const TIME_SEP_GAP_MS = 30 * 60 * 1000
/** 与上一时刻是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插)。
* prevTs 由调用方传入(桶级缓存不持跨桶可变态,改参数化保纯)。 */
function needsTimeSep(ts: number, prevTs: number): boolean {
if (!prevTs || !ts) return false
if (Math.abs(ts - prevTs) >= TIME_SEP_GAP_MS) return true
const a = new Date(prevTs)
const b = new Date(ts)
return a.getFullYear() !== b.getFullYear()
|| a.getMonth() !== b.getMonth()
|| a.getDate() !== b.getDate()
}
// ── 桶级 memo(P0 渲染层):同签名桶复用同一批 item 对象 ──
// 两重收益:①工具事件时 O(n)→O(变更桶)(其余桶命中缓存不重算);②item 对象引用稳定
// → 模板 v-memo 的 [item] dep 生效前提(桶未变 → item 同引用 → v-memo 跳过该 item 的 patch)。
// 签名覆盖 flushBucket 产出真正消费的全部输入(消息身份/文本/token + 工具态 + 轮号 + 生成态 +
// 前桶时刻 + 轨迹展开态);漏字段 → 命中陈旧 item → 局部不刷新,故从宽纳入。LRU 有界(沿用 _decoCache 范式)。
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
const BUCKET_CACHE_LIMIT = 128
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
for (const m of msgs) {
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '')
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? '')
+ ':' + (m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? '')
+ ':' + (m.tokenUsage?.is_estimated ? 1 : 0)
+ ':' + (m.parts?.length ?? 0) + ':' + (m.mentionSpans?.length ?? 0)
for (const t of m.toolCalls ?? []) {
s += ',' + t.id + ':' + t.status + ':' + (t.completedAt ?? 0)
}
parts.push(s)
}
return parts.join('|')
}
/** 无缓存构建一个桶的 item(重活:collectTurnStats/sumTurnTokens/isToolResultJson 均在此)。
* 返回 endTs(末条时刻)= 该桶结束后应作为下一桶 prevTs 的值。 */
function genBucketItems(
msgs: AiMessage[],
round: number,
executing: boolean,
prevTs: number,
traceKey: string,
traceExpanded: boolean,
): { items: RenderItem[]; endTs: number } {
const items: RenderItem[] = []
const cur = msgs[msgs.length - 1]
const pushSep = (ts: number) => {
if (needsTimeSep(ts, prevTs)) items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
if (executing) {
const prev = msgs.slice(0, -1)
if (prev.length > 0) {
pushSep(msgs[0].timestamp)
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
// 961①: step 卡可展开——key 与完成态 trace 同构,复用 expandedTraceIds,
// 执行中展开看实时过程,完成后切换 trace 态展开不丢
items.push({
kind: 'step', key: 'trace-' + prev[0].id, round, stats: collectTurnStats(msgs),
traceMsgs: prev, expanded: expandedTraceIds.value.has('trace-' + prev[0].id),
})
} else {
pushSep(cur.timestamp)
}
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
return { items, endTs: cur.timestamp }
}
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
let fi = -1
for (let i = msgs.length - 1; i >= 0; i--) {
const c = msgs[i].content
// compressed(压缩摘要)不竞选最终答复:摘要是过程记录,轨迹卡内 pill 呈现
if (c && c.trim() && !isToolResultJson(c) && msgs[i].status !== 'compressed') { fi = i; break }
}
const finalMsg = fi >= 0 ? msgs[fi] : msgs[msgs.length - 1]
// 过程消息 = finalMsg 之外全部(含其后尾气泡):异常收尾(错误/停止/流中断)时末轮气泡
// 常只有 toolCalls 无正文,按「finalMsg 之前」切片会把尾气泡丢出渲染与轨迹卡,
// 执行中可见的工具卡在收尾瞬间整块消失(用户实测「空白覆盖一大堆内容」)。
const traceMsgs = msgs.filter(m => m !== finalMsg)
// 完成:轨迹折叠为一张卡,轮次内容在卡内 rail 面板展开(不再摊平成独立消息项)
if (traceMsgs.length > 0) {
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
let lastCompleted = 0
for (const m of traceMsgs) {
for (const tc of m.toolCalls || []) {
if (tc.completedAt && tc.completedAt > lastCompleted) lastCompleted = tc.completedAt
}
}
// 轨迹桶不再发 timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责;
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
items.push({
kind: 'trace', key: traceKey,
round,
stats: collectTurnStats(traceMsgs),
startTs: traceMsgs[0].timestamp,
endTs: Math.max(lastCompleted, finalMsg.timestamp),
tokens: sumTurnTokens(traceMsgs),
traceMsgs, expanded: traceExpanded,
})
} else {
pushSep(finalMsg.timestamp)
}
// 结果/过程分离(2026-10-10 用户口径:过程太多压住结果、密度过大):
// 气泡**只放最终结果**(finalMsg 正文);本桶其余轮的叙述与工具卡全收进「执行轨迹卡」(默认一行
// 收起,卡内展开可看逐轮全文与工具卡)——既不再"流式后正文消失",又不再"一条消息回 N 个气泡"。
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
return { items, endTs: finalMsg.timestamp }
}
/** 桶 memo 取用:签名命中回同一对象数组(引用稳定);未命中构建 + 有界 LRU 写入(命中置末)。 */
function buildBucket(msgs: AiMessage[], round: number, executing: boolean, prevTs: number): { items: RenderItem[]; endTs: number } {
const traceKey = 'trace-' + msgs[0].id
const traceExpanded = expandedTraceIds.value.has(traceKey)
const sig = bucketSig(msgs, round, executing, prevTs, traceExpanded)
const hit = _bucketCache.get(sig)
if (hit) {
_bucketCache.delete(sig)
_bucketCache.set(sig, hit)
return hit
}
const built = genBucketItems(msgs, round, executing, prevTs, traceKey, traceExpanded)
if (_bucketCache.size >= BUCKET_CACHE_LIMIT) {
const oldest = _bucketCache.keys().next().value
if (oldest !== undefined) _bucketCache.delete(oldest)
}
_bucketCache.set(sig, built)
return built
}
const renderItems = computed<RenderItem[]>(() => {
const items: RenderItem[] = []
let bucket: AiMessage[] = []
let lastTs = 0
// 961②③ 轮次号=「用户发起」计数(单一真值来源,数据派生天然稳定):
// 发起=user 且非补充注入(source.channel!='inject',老数据无 source 视为发起);
// 补充注入/压缩摘要/错误气泡均归属当前轮不递增——修复旧口径两漂移根源:
// ①旧 traceCount=轨迹卡出现顺序,单消息桶不计数致编号随桶形态漂移;
// ②压缩摘要切桶即开新号(用户表象「压缩后就是新轮次」)。
let currentRound = 0
const pushSep = (ts: number) => {
if (needsTimeSep(ts, lastTs)) {
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
}
lastTs = ts
}
const flushBucket = () => {
const msgs = bucket.filter(shouldRenderMsg)
bucket = []
if (msgs.length === 0) return
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
// 961③: 轮号=当前发起计数(与轨迹卡是否产出解耦,单消息桶不吞号)
const round = currentRound
const built = buildBucket(msgs, round, executing, lastTs)
for (const it of built.items) items.push(it)
lastTs = built.endTs
}
for (const seg of messageSegments.value) {
if (seg.kind !== 'normal') {
flushBucket()
// 折叠段:总 emit sep;展开时再 emit 段内每条 msg(同样按间隔插时间线)
items.push({ kind: 'sep', key: seg.key, seg })
if (expandedSegmentIds.value.has(seg.key)) {
for (const m of seg.msgs) {
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
}
}
continue
}
const m = seg.msg
// 961②: 发起边界=user 且非补充注入——flush 当前轮并递增轮号;补充注入归属
// 当前轮原样渲染不递增。压缩摘要/错误气泡也不递增(压缩后仍是同轮过程)
if (m.role === 'user') {
flushBucket()
if (m.source?.channel !== 'inject') currentRound++
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
continue
}
// assistant 非错误进轮次桶——compressed(压缩摘要)也进桶(用户令 10-11:压缩摘要
// 的动作作为轮次记录折叠,随轨迹卡收起;旧口径「原样展开」作废,961② 已定其归属当前轮)
if (m.role === 'assistant' && !m.isError) {
bucket.push(m)
} else {
flushBucket()
pushSep(m.timestamp)
items.push({ kind: 'msg', key: 'm-' + m.id, msg: m })
}
}
flushBucket()
// S1 子代理完成卡:按完成时刻插到所属轮次桶尾,不恒贴消息流最底部——恒贴时用户继续对话,
// 早前轮次的卡被顶到后面内容之下,与所属轮次脱钩。归桶判据:finishedAt 落在哪桶的跨度内
// (trace.startTs ≤ finishedAt < 下一桶),卡即插到该桶 trace 卡之后与其同层(spawn 就发生
// 在这个桶里;桶内末条答复的落库时刻必晚于 finishedAt,时间比较天然落在这里)。
// 无时间语义的项(step/timesep/sep/agentFin)不作锚点,防插进时间线分隔的语义缝隙。
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
const cards = [...getConvAgentFinished(store.state.activeConversationId)].sort((a, b) => a.finishedAt - b.finishedAt)
for (const card of cards) {
let at = items.length
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
const ts = it.kind === 'trace' ? it.startTs : it.kind === 'msg' ? it.msg.timestamp : 0
if (ts && ts <= card.finishedAt) { at = i + 1; break }
}
items.splice(at, 0, { kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
}
return items
})
// ── v-memo 依赖数组(P0 渲染层,内联在模板 v-for 的 v-memo 处) ──
// renderItems(computed)已读源经 [item] 自动失效(桶 memo 保证桶未变时 item 引用稳定 → 跳过 patch);
// v-memo 额外补「renderItems 未读、但渲染该 item 时读了」的兄弟响应式源 —— 漏一个 → 该项局部不刷新:
// - expandedThinkIds / expandedJsonIds / expandedLongIds:思考 pill / JSON 块 / 长文折叠展开态
// (整体 Set 引用纳入;useToggleSet 每次 toggle 换新 Set → 引用变 → 该项重渲);
// - tokenPopoverMsgId / editingMsgId / agentPeekLoadingId:token 面板 / user 编辑按钮 / 子代理 peek 转圈;
// - store.state.streaming / isViewingGenerating:光标、user 编辑按钮 can-edit、流式块闸直接读的源
// (非 renderItems 已读,须显式纳入,否则流式启停该 item 不刷新);
// - _mdRenderKey:marked 就绪后 forceUpdate 递增、经 renderMdDecorated 读;taskTitleCache:任务 chip 标题懒加载。
// - 流式文本源(定向 dep):流式 delta 只改 currentText→streamingBlocks→decoratedStreamingBlocks,
// 不改任何消息 content、不改桶签名 → item 引用稳定。若此处不纳入流式源,流式 item 的 v-memo 恒命中,
// 子树跳过 patch,气泡冻结在首个空帧(要等工具事件/收尾才整段出现)。
// 只对「正在渲染流式块的 item」(isLastAi + streaming,即模板内 v-if 渲染 decoratedStreamingBlocks 的那一项)
// 纳入 decoratedStreamingBlocks(每 rAF 换新数组引用 → dep 失效 → 该项重渲),其余 item 取常量 '' 占位,
// 使 v-memo 仅在流式项失效,空闲/历史项不重渲的 P0 收益不受影响。
// 注:vue-tsc 不解析 v-memo 表达式,抽独立函数会触发 noUnusedLocals 误报,故直接内联数组。
// ── S1 子代理完成卡(AiAgentFinished) ──
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err' {
if (status === 'truncated') return 'warn'
if (status === 'failed') return 'err'
return 'ok'
}
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应。
// stop_kind 结构化优先(根治启发式):后端终态枚举直达语义,截断三细分各自透出;
// 未知值兜底中性 completed 词不误报。无字段(旧后端)回退 summary 启发式:用户停止
// 级联取消子代理时 stop_reason=「已被取消(...)」并经 summary 终止说明透出(后端两处
// 取消检查点均写此中文字面量),按该特征分流为「已取消」;无此特征才是系统截断。
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提 agent-{n} 序号,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防「3 轮/2 轮」乱序观感误读。
function agentFinStatusText(card: AgentFinishedCard): string {
const k = card.stop_kind
if (k) {
if (k === 'cancelled') return t('ai.agentFinished.cancelled')
if (k === 'max_rounds') return t('ai.agentFinished.truncMaxRounds')
if (k === 'wall_clock') return t('ai.agentFinished.truncWallClock')
if (k === 'token_budget') return t('ai.agentFinished.truncTokenBudget')
if (k === 'empty_convergence') return t('ai.agentFinished.noReport')
if (k === 'error') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
if (card.status === 'truncated') {
return card.summary.includes('已被取消')
? t('ai.agentFinished.cancelled')
: t('ai.agentFinished.truncated')
}
if (card.status === 'failed') return t('ai.agentFinished.failed')
return t('ai.agentFinished.completed')
}
// A-3(2026-10-05):并行多子代理卡序号身份——从 agentId 尾段提取 agent-{n} 的 n,
// 并行交错完成时卡面可区分「哪张卡是哪个代理」,防多卡「N 轮」乱序观感误读;
// 解析失败(形态不符)返回 null,模板不渲染序号徽标。
function agentFinSeq(card: AgentFinishedCard): number | null {
const m = /::agent-(\d+)$/.exec(card.agentId || '')
return m ? Number(m[1]) : null
}
// 启动失败型子代理(并发占满/父会话已删/provider 缺失)在进循环前就返回,子会话未落库,
// 点卡 peek 必报「对话不存在」死胡同;rounds=0 是前端可得的最稳特征(进循环后的失败
// 也至少有一轮落库记录,可正常 peek)。
function agentFinNoTrace(card: AgentFinishedCard): boolean {
return card.status === 'failed' && card.rounds === 0
}
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
// 全文经 title hover 兜底不丢信息。
function agentFinSummary(summary: string): string {
const s = (summary || '').replace(/\s+/g, ' ').trim()
return s.length > 160 ? s.slice(0, 160) + '…' : s
}
// 被拒工具 chip(P2-1):前 2 枚枚举 + 「+N」收尾防长清单撑爆细条,完整清单经 hover title 查看
const AGENT_FIN_REJECTED_MAX = 2
function agentFinRejectedText(list: string[]): string {
const shown = list.slice(0, AGENT_FIN_REJECTED_MAX).join(' / ')
const rest = list.length - AGENT_FIN_REJECTED_MAX
return t('ai.agentFinished.rejected', { tools: rest > 0 ? `${shown} +${rest}` : shown })
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
// 不进 aiShared/stores——子会话轨迹纯只读,与主会话状态机(消息流/生成态)完全无关。
interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system'
text: string
toolNames: string[]
// deny envelope 消息标记:命中子代理拒绝信封的 tool 结果,渲染为红字人话而非原始 JSON
denied?: boolean
}
const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false)
// 加载中的卡 id:spinner 挂在具体完成卡上而非浮层(浮层此时尚未打开)
const agentPeekLoadingId = ref<string | null>(null)
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 浮层头身份标识:agent-{n} 尾段 + 子会话标题(IPC title,子代理常为空则不显)+ 任务书首行,
// 多代理场景辨认「在看谁」;关浮层即弃
const agentPeekSubject = ref({ agent: '', convTitle: '', task: '', taskFull: '' })
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000
/**
* 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 启动失败无轨迹卡直接忽略(agentFinNoTrace);加载中/已开时忽略重复点击(防连点重复 IPC);
* 失败走既有 toast 通道给可见反馈,不开空浮层。
*/
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentFinNoTrace(card)) return
if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true
agentPeekLoadingId.value = card.agentId
try {
const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
// tool_call_id → 工具名映射:tool 消息只带 id,名字在前条 assistant.tool_calls 里
const toolNameById = new Map<string, string>()
for (const m of raw) {
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
if (tc?.id && tc?.function?.name) toolNameById.set(String(tc.id), String(tc.function.name))
}
}
}
agentPeekMsgs.value = raw.map((m) => {
const rawText = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role
: 'assistant'
const msg: AgentPeekMsg = {
role,
text: '',
toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [],
}
if (role === 'tool') {
// 回显工具名 chip(与前条 assistant 消息的调用 chip 对得上,行内可读)
const callName = toolNameById.get(String(m.tool_call_id || ''))
if (callName) msg.toolNames.push(callName)
// deny envelope 先解析后截断(信封很小不会被截),命中转人话不走原文
const deny = parseDenyEnvelope(rawText)
if (deny) {
msg.denied = true
const risk = agentPeekRiskText(deny.risk_level)
msg.text = t('ai.agentTrace.deniedTool', { tool: deny.tool_name || callName || '?' })
+ (risk ? `(${risk})` : '')
+ (deny.message ? ` · ${deny.message}` : '')
return msg
}
}
msg.text = rawText.length > AGENT_PEEK_TEXT_MAX ? rawText.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : rawText
return msg
})
// 任务行取子会话首条 user 消息(即任务书)首行,取不到回退卡摘要;title 挂全文
const firstUser = raw.find((m) => m.role === 'user')
const taskLine = String(firstUser?.content || '').split('\n').map((s: string) => s.trim()).filter(Boolean)[0] || ''
const full = taskLine || (card.summary || '').replace(/\s+/g, ' ').trim()
agentPeekSubject.value = {
agent: card.agentId.split('::').pop() || card.agentId,
convTitle: String(res.title || '').trim(),
task: full.length > 120 ? full.slice(0, 120) + '…' : full,
taskFull: full,
}
agentPeekVisible.value = true
} catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally {
agentPeekLoading.value = false
agentPeekLoadingId.value = null
}
}
/** deny envelope 识别:与后端 is_sub_agent_deny_envelope 同构(status=rejected + reason=sub_agent_denied) */
function parseDenyEnvelope(text: string): { tool_name?: string; risk_level?: string; message?: string } | null {
const s = text.trim()
if (!s.startsWith('{')) return null
try {
const v = JSON.parse(s)
if (v?.status === 'rejected' && v?.reason === 'sub_agent_denied') {
return { tool_name: v.tool_name, risk_level: v.risk_level, message: v.message }
}
} catch {
// 截断/非 JSON 内容即非信封,按原文渲染
}
return null
}
/** 风险级别人话化(后端 RiskLevel serde lowercase,字面量 low/medium/high) */
function agentPeekRiskText(level: unknown): string {
if (level === 'low') return t('ai.agentTrace.riskLow')
if (level === 'medium') return t('ai.agentTrace.riskMedium')
if (level === 'high') return t('ai.agentTrace.riskHigh')
return ''
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void {
agentPeekVisible.value = false
agentPeekMsgs.value = []
agentPeekSubject.value = { agent: '', convTitle: '', task: '', taskFull: '' }
}
/** role → 本地化角色名(简版渲染,无头像气泡) */
function agentPeekRoleText(role: AgentPeekMsg['role']): string {
if (role === 'user') return t('ai.agentTrace.roleUser')
if (role === 'tool') return t('ai.agentTrace.roleTool')
if (role === 'system') return t('ai.agentTrace.roleSystem')
return t('ai.agentTrace.roleAssistant')
}
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
)
/** 点击轮次 chip 滚动到对应轨迹卡(block:start 卡头对齐视口顶) */
function jumpToTrace(key: string): void {
messagesContainer.value
?.querySelector(`[data-trace-key="${key}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// 切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管。
// V12 per-conv 滚动位置记忆:换出会话存 scrollTop,换入会话有存档则恢复(不再一律回底)。
// 容量收敛:仅记最近 20 个会话(Map 超限删最早),防长会话列表无限增长。
const _scrollTopMemo = new Map<string, number>()
const _SCROLL_MEMO_LIMIT = 20
watch(() => store.state.activeConversationId, (newId, oldId) => {
// 换出:记旧会话滚动位置(容器就绪且旧 id 有效才记)
if (oldId && messagesContainer.value) {
if (_scrollTopMemo.size >= _SCROLL_MEMO_LIMIT) {
_scrollTopMemo.delete(_scrollTopMemo.keys().next().value!)
}
_scrollTopMemo.set(oldId, messagesContainer.value.scrollTop)
}
// 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
setFollowing(true)
// 切会话重置执行轨迹展开态(展开记忆按会话隔离,防跨会话 key 撞车误展开)
expandedTraceIds.value = new Set()
// 切会话关子代理轨迹浮层(浮层是组件级单例,不关则旧会话轨迹叠在新会话上)
closeAgentTrace()
// 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()
}
// 切会话同步清流式块级 memo 缓存(_blockCache),防旧会话已缓存块被新会话命中。
resetStreamCache()
// UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话
// 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明
// 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。
// V12(2026-10-05):有滚动位置存档的会话恢复存档位置(回读不被打断),无存档(首次/新会话)
// 保持回底。跟随意图仅在回底路径置位;恢复路径置 setFollowing(false) 尊重用户阅读位置。
nextTick(() => {
const saved = newId ? _scrollTopMemo.get(newId) : undefined
const el = messagesContainer.value
if (saved != null && el) {
el.scrollTop = Math.min(saved, el.scrollHeight)
setFollowing(false)
} else {
scrollToBottom()
}
})
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
onBeforeUnmount(() => {
cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
document.removeEventListener('keydown', closeAgentTraceOnEsc)
})
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
function closeTokenPopoverOnOutsideClick(): void {
tokenPopoverMsgId.value = null
}
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
}
document.addEventListener('keydown', closeAgentTraceOnEsc)
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
// 多次调用安全,确保子组件挂载即预热)。
loadMarkdown()
defineExpose({
scrollToBottom,
scrollToFirstPending,
})
</script>
<template>
<!-- Messages -->
<!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 -->
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
<!-- 空状态 (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')"
/>
<!-- 轮次导航条(UX 密度改造:仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做轮次跳转。
调用统计收敛到轨迹卡头部,此处不放数字) -->
<div v-if="traceNav.length >= 3" class="ai-trace-nav">
<button
v-for="tr in traceNav"
:key="tr.key"
class="ai-trace-nav-chip"
:title="$t('ai.trace.roundN', { n: tr.round })"
@click="jumpToTrace(tr.key)"
>{{ tr.round }}</button>
</div>
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文)
A 过程压缩:trace 项=完成轮次执行轨迹卡,step 项=执行中实时步骤摘要,均先于 msg 分支拦截。
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
<div v-if="item.kind === 'trace'" class="ai-msg-slot ai-msg-slot--proc">
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }" :data-trace-key="item.key">
<div
class="ai-trace-head"
role="button"
tabindex="0"
:aria-expanded="item.expanded"
@click="toggleTrace(item.key)"
@keydown.enter.prevent="toggleTrace(item.key)"
@keydown.space.prevent="toggleTrace(item.key)"
>
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
<span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
<span v-if="item.tokens > 0" class="ai-trace-tokens">≈{{ formatTokensK(item.tokens) }} tokens</span>
<!-- 任务关联 chip(有的轮次有,有的没):从该轮过程消息的 toolCalls[].args 派生,
点击跳转任务详情。无任务工具调用则不渲染,零噪音。 -->
<span
v-for="ref in traceTaskRefs(item.traceMsgs)"
:key="ref.taskId"
class="ai-trace-task-chip"
role="button"
tabindex="0"
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
@click.stop="openTask(ref.taskId)"
@keydown.enter.stop.prevent="openTask(ref.taskId)"
@keydown.space.stop.prevent="openTask(ref.taskId)"
>🔗 {{ taskTitleOf(ref.taskId) }}</span>
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
</div>
<!-- 展开态 rail 面板:每轮=思考单行 pill(点击展开全文)+工具卡,图标锚定左轨连线贯通 -->
<div v-if="item.expanded" class="ai-trace-body">
<div v-for="m in item.traceMsgs" :key="m.id" class="ai-trace-round">
<!-- 压缩摘要(用户令 10-11):轮次过程记录形态,pill 单行+点击展开全文,
复用思考 pill 展开态(expandedThinkIds/toggleThink 同交互语义零新状态) -->
<template v-if="m.status === 'compressed'">
<button
class="ai-think-pill"
:title="m.content"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-9-9"/><path d="M21 3l-9 9"/></svg>
</span>
<span class="ai-think-text">{{ $t('ai.trace.compressed') }} · {{ compactSnippet(m.content) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.content)"
></div>
</template>
<!-- 空内容轮(只剩「过程记录」占位)不渲染思考 pill,降噪;工具卡照常 -->
<template v-else-if="hasThinkBody(m)">
<button
class="ai-think-pill"
:title="thinkFullText(m)"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.reasoning_content?.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.reasoning_content)"
></div>
</template>
<!-- 轮内正文(该轮叙述):随卡默认收起(结果优先、控密度),展开轨迹卡即见全文。
复用 .ai-think-full 的弱化样式(12px/次要色/左缩进),零新增 CSS。 -->
<div
v-if="m.role === 'assistant' && m.content && m.content.trim()"
class="ai-think-full ai-md"
v-html="renderMdDecorated(m.content)"
></div>
<ToolCardList
v-if="m.toolCalls && m.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="m.toolCalls"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
@pending-out-of-view="onPendingOutOfView"
/>
</div>
</div>
</div>
</div>
<!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开)。
骨架与完成态 .ai-trace-card 完全同构(卡头结构一致),仅 chevron→脉冲点、右侧→「进行中」,
使 step→trace 切换时只 patch 内部文字,无高度跳变/闪烁 -->
<div v-else-if="item.kind === 'step'" class="ai-msg-slot ai-msg-slot--proc">
<div class="ai-trace-card ai-trace-card--running" :class="{ 'ai-trace-card--open': item.expanded }">
<div
class="ai-trace-head"
role="button"
tabindex="0"
:aria-expanded="item.expanded"
@click="toggleTrace(item.key)"
@keydown.enter.prevent="toggleTrace(item.key)"
@keydown.space.prevent="toggleTrace(item.key)"
>
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
<span class="ai-step-dot"></span>
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
<span class="ai-trace-agg">{{ stepText(item.stats, item.round) }}</span>
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.running') }}</span>
</div>
<!-- 961① 执行中展开:实时过程回看(每条过程消息的工具卡),与完成态 trace 展开同诉求 -->
<div v-if="item.expanded" class="ai-trace-body">
<template v-for="m in item.traceMsgs" :key="m.id">
<div v-if="m.toolCalls && m.toolCalls.length > 0" class="ai-trace-round">
<ToolCardList
:toolCalls="m.toolCalls"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
@pending-out-of-view="onPendingOutOfView"
/>
</div>
</template>
</div>
</div>
</div>
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
轻量细条范式,非消息数据不进气泡。
S3:整卡可点,点击 peek 子会话轨迹开只读浮层(数据关即弃,见 openAgentTrace) -->
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div
class="ai-agent-fin"
:class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]"
role="button"
:title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 -->
<span v-if="agentFinSeq(item.card) !== null" class="ai-agent-fin-seq">#{{ agentFinSeq(item.card) }}</span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card) }}</span>
<!-- 被拒工具 chip(P2-1):非空才渲染,琥珀警示层与 truncated 同色系;
单枚直显,多枚前 2 枚枚举 + N,完整清单 hover title -->
<span
v-if="item.card.rejected_tools?.length"
class="ai-agent-fin-rejected"
:title="item.card.rejected_tools.join('\n')"
>{{ agentFinRejectedText(item.card.rejected_tools) }}</span>
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
<!-- 回补卡 rounds/tokens 未知为 0,双零隐藏 meta 防「0 轮 · 0 tokens」噪音 -->
<span v-if="item.card.rounds || item.card.tokens" class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
<!-- peek 加载微标:IPC 期间转圈,防「点了没反应」;挂在对应卡上,多卡各自反馈 -->
<span v-if="agentPeekLoadingId === item.card.agentId" class="ai-agent-fin-spin" aria-hidden="true"></span>
</div>
</div>
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
<span>{{ formatAbsolute(item.ts) }}</span>
</div>
<!-- 重试过渡细条(AiStreamRetry 过渡气泡,isRetry 标记):琥珀点+单行中性文案,
不走 avatar/错误红卡/操作条;重试耗尽的终态错误(AiError,无 isRetry)仍走红卡 -->
<div v-else-if="item.kind === 'msg' && item.msg.isRetry" class="ai-msg-slot">
<div class="ai-retry-line">
<span class="ai-retry-dot"></span>
<span class="ai-retry-text">{{ item.msg.content }}</span>
</div>
</div>
<!-- 纯空 assistant(无 content/error/toolCalls 且非末条流式)整 slot 不渲染,
消除空 div + flex gap 堆积的大片空白;sep 始终渲染;有 toolCalls 的空 assistant
仍渲染(工具卡可见——修复 agentic 工具轮 LLM 无文本时工具卡消失)。
UX 密度改造:per-block 相对时间已移除,精确时刻上移到消息容器 title(hover 可查)。 -->
<div
v-else-if="item.kind === 'sep' ? true : shouldRenderMsg(item.msg)"
class="ai-msg-slot"
:title="item.kind === 'msg' ? formatDate(item.msg.timestamp) : undefined"
>
<!-- 折叠分隔条(归档段)— 轻量,始终渲染 -->
<div
v-if="item.kind === 'sep'"
class="ai-msg-segment"
:class="'ai-msg-segment--' + item.seg.kind"
@click="toggleSegment(item.seg.key)"
>
<svg aria-hidden="true" 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>
<!-- 消息(正常段 + 展开的折叠段内消息)— 虚拟滚动已移除(IO/RO 时序致重叠),消息恒渲染 -->
<!-- 纯空 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="renderMdDecorated(item.msg.content)"></div>
</div>
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
<MessageItem
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
/>
<!-- AI 消息 -->
<div v-else class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg aria-hidden="true" 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 渲染) -->
<!-- JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354):
assistant content 为纯 JSON 工具结果时弱化为灰小字折叠块(.ai-msg-json),
不渲染 markdown 大气泡刷屏。仅完成/历史态生效:isLastAi && streaming 时 content
是流式累积的截断 JSON(JSON.parse 必失败/误判),仍走下方流式分块渲染。
UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
<div
v-if="isToolResultJson(item.msg.content) && !(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-json"
:class="{ 'ai-msg-json--expanded': expandedJsonIds.has(item.msg.id) }"
@click="toggleJson(item.msg.id)"
><pre>{{ item.msg.content }}</pre></div>
<div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
:key="'md-' + item.msg.id"
>
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。
闪烁修复(2026-10-09):末条流式气泡在 blocks 暂空(新轮 AiAgentRound 清空 blocks 到
首个 delta 到达之间的一帧窗口)时仍走本分支,只显光标占位;否则会落到下方
v-else 渲染空 content,产生「气泡空一帧再填充」的闪烁。 -->
<template v-if="isLastAi(item.msg) && store.state.streaming">
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮)。
B5 惰性渲染:折叠态仅渲染前 800 字符预览(全量渲染延迟到展开瞬间),
长(>1500字)历史消息的 markdown 渲染成本不再随会话长度线性累积。 -->
<div v-else-if="shouldCollapseLong(item.msg)" v-html="collapsedPreviewHtml(item.msg)"></div>
<div v-else v-html="renderMdDecorated(item.msg.content)"></div>
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
<button
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-bubble-toggle"
@click.stop="toggleLong(item.msg.id)"
>{{ shouldCollapseLong(item.msg) ? collapsedLongLabel(item.msg) : $t('aiChat.collapse') }}</button>
<button
v-if="item.msg.content"
class="ai-copy-btn ai-copy-btn--ai"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg aria-hidden="true" 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>
<button
v-if="item.msg.content"
class="ai-copy-btn ai-copy-btn--ai ai-copy-btn--image"
:title="$t('aiChat.copyAsImage')"
@click.stop="copyMsgAsImage(item.msg)"
>
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></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>
<!-- 看板 953:max_tokens 截断提示条(仅末条 assistant 带标记时,气泡尾部弱提示)。
点击继续输出=注入续写指令复用 sendMessage,衔接勿重复;streaming 中禁用防叠发。 -->
<div v-if="isLastAi(item.msg) && item.msg.truncatedByMaxTokens" class="ai-msg-truncated">
<span class="ai-msg-truncated-text">{{ $t('ai.truncatedByMaxTokens') }}</span>
<button
class="ai-msg-act"
:disabled="store.state.streaming"
@click.stop="onContinueAfterTruncation"
>
<svg aria-hidden="true" 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('ai.continueOutput') }}</span>
</button>
</div>
<!-- UX-03 / 错误气泡操作入口(重试 / 去设置) -->
<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 aria-hidden="true" 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 aria-hidden="true" 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 分项显示(2026-08-02):in/cache/out/reason 分计费,不显 total(相加无意义)。
in=cache_miss(全价真实,非 prompt 总),cache=cache_hit(命中低价),out=completion,
reasoning > 0 才显。点击 token 区弹详情面板(完整 usage + 流程:模型/缓存命中率)。
tokenUsage(内存,AiCompleted 实时)优先;fallback prompt_tokens/completion_tokens
(DB 持久化字段,reload 自动有)—— 不依赖某个 reload 映射点,压缩/切会话都生效。 -->
<div
v-if="item.msg.role === 'assistant' && (item.msg.tokenUsage || item.msg.prompt_tokens != null)"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
<button
class="ai-token-usage-trigger"
:aria-expanded="tokenPopoverMsgId === item.msg.id"
@click="toggleTokenPopover(item.msg, $event)"
>
<span>{{ formatTokensK(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span>
<!-- cache 段仅在 provider 真实上报时渲染(tokenCacheVisible),避免「无数据」显示成「0 cache」 -->
<template v-if="tokenCacheVisible(item.msg)">
<span class="ai-token-sep">·</span>
<span>{{ formatTokensK(tokenCacheOf(item.msg)) }} cache</span>
</template>
<span class="ai-token-sep">·</span>
<span>{{ formatTokensK(tokenOutOf(item.msg)) }} out</span>
<template v-if="tokenReasonOf(item.msg) > 0">
<span class="ai-token-sep">·</span>
<span>{{ formatTokensK(tokenReasonOf(item.msg)) }} reason</span>
</template>
</button>
<!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 -->
<div
v-if="tokenPopoverMsgId === item.msg.id"
class="ai-token-popover"
@click.stop
>
<div class="ai-token-popover-title">{{ $t('aiChat.tokenDetailTitle') }}<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenInLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
</div>
<div v-if="tokenCacheVisible(item.msg)" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenOutLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
</div>
<div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<!-- #12(2026-10-05):会话累计(convTokenTotal)入面板——数据只写不读的闲置数据源接上消费;
累计为前端内存态(切换会话由 switchConversation 重算),会话末条 AI 回复的 popover 展示 -->
<template v-if="isLastAi(item.msg) && store.state.convTokenTotal">
<div class="ai-token-popover-sep"></div>
<div class="ai-token-popover-row ai-token-popover-row--total">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotal') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.total) }} ({{ store.state.convTokenTotal.total }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalIn') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.prompt) }} ({{ store.state.convTokenTotal.prompt }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenConvTotalOut') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(store.state.convTokenTotal.completion) }} ({{ store.state.convTokenTotal.completion }})</span>
</div>
</template>
<!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 -->
<div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
</div>
<div v-if="item.msg.model" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenModelLabel') }}</span>
<span class="ai-token-popover-val">{{ item.msg.model }}</span>
</div>
<div v-if="tokenEstimated(item.msg)" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenUsageNoteLabel') }}</span>
<span class="ai-token-popover-val">{{ $t('aiChat.tokenUsageNote') }}</span>
</div>
</div>
</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"
@pending-out-of-view="onPendingOutOfView"
/>
</div>
</div>
</div>
</div><!-- /.ai-msg-slot(虚拟滚动已移除,恒渲染容器) -->
</template>
</div>
<!-- 回到底部(上滑看历史 / 有新内容时显示) -->
<!-- R11(2026-10-05):生成中且非跟随态 → 按钮加脉冲点,提示「下面有新内容」而非仅导航 -->
<Transition name="back-to-bottom">
<button v-if="showBackToBottom" class="ai-back-to-bottom" :class="{ 'has-new': store.state.streaming && !isFollowingBottom }" @click="() => scrollToBottom()" :title="$t('aiChat.backToBottom')" :aria-label="$t('aiChat.backToBottom')">
<svg aria-hidden="true" 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>
<span v-if="store.state.streaming && !isFollowingBottom" class="ai-btb-new-dot"></span>
</button>
</Transition>
<!-- S3 子代理轨迹浮层(只读,关即弃):与回底部按钮同为模板根级兄弟,position:fixed
不受 .ai-messages 滚动容器 overflow 裁剪;backdrop 点击关闭,数据不缓存 -->
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head">
<div class="ai-agent-peek-head-text">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<!-- 身份标识行:agent-{n} + 子会话标题 + 任务书首行,多代理场景辨认「在看谁」 -->
<div class="ai-agent-peek-subject" :title="agentPeekSubject.taskFull">
<span class="ai-agent-peek-agent">{{ agentPeekSubject.agent }}</span>
<span v-if="agentPeekSubject.convTitle" class="ai-agent-peek-conv">{{ agentPeekSubject.convTitle }}</span>
<span v-if="agentPeekSubject.task" class="ai-agent-peek-task">{{ agentPeekSubject.task }}</span>
</div>
</div>
<button class="ai-agent-peek-close" :title="$t('common.close')" :aria-label="$t('common.close')" @click="closeAgentTrace">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染);
deny envelope 消息(denied)红字人话,不再直出原始 JSON -->
<div
v-for="(m, i) in agentPeekMsgs"
:key="i"
class="ai-agent-peek-msg"
:class="['ai-agent-peek-msg--' + m.role, { 'ai-agent-peek-msg--denied': m.denied }]"
>
<div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span>
<span v-for="name in m.toolNames" :key="name" class="ai-agent-peek-tool">{{ name }}</span>
</div>
<div v-if="m.text" class="ai-agent-peek-text">{{ m.text }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ═══ Messages ═══ */
.ai-messages {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* .ai-messages 的直接 flex 子虚拟滚动 sentinel 已移除,
现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */
.ai-msg-slot { display: flex; flex-direction: column; }
/* 体验优化(2026-10-09):过程卡 slot 与紧随其后的结果 slot 压缩间距(14px→6px),
使「过程→结果」视觉成组,结果气泡成为该组主体;过程卡自身左缩进 4px 已由 .ai-trace-card 承担。
仅作用于「过程 slot 紧随结果 slot」的相邻对,不影响消息与消息的常规间距。 */
.ai-msg-slot--proc + .ai-msg-slot { margin-top: -8px; }
/* ── 回到底部按钮(浮动,绝对定位于 .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); }
/* R11 新内容脉冲点:生成中非跟随态提示下方有新内容(amber 脉冲,2s 循环) */
.ai-btb-new-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--df-warning, #e8a33d);
animation: ai-btb-pulse 2s ease-in-out infinite;
}
@keyframes ai-btb-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.25); }
}
/* -- 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 在先,视觉反转成【气泡】【头像】靠右 */
margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */
}
.ai-msg-ai {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
}
/* token 详情 popover 展开时,提升本消息 z-index 盖住后续消息气泡
(后续 .ai-msg-user 也是 relative 同级 context,DOM 在后会遮挡 popover) */
.ai-msg-ai:has(.ai-token-popover) { z-index: 30; }
.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);
}
/* 修正 的 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;
}
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景,
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
.ai-msg-bubble--collapsed {
max-height: 400px;
overflow: hidden;
position: relative;
}
.ai-msg-bubble--collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(transparent, var(--df-bg-card));
pointer-events: none; /* 让点击穿透到下方展开按钮 */
}
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
.ai-msg-bubble-toggle {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 1;
padding: 2px 12px;
font-size: 11px;
line-height: 1.4;
color: var(--df-accent);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
cursor: pointer;
user-select: none;
}
.ai-msg-bubble-toggle:hover {
background: var(--df-bg-card-hover);
}
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */
.ai-msg-json {
max-height: 80px;
overflow-y: auto;
padding: 6px 10px;
border-radius: var(--df-radius);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.5;
color: #b0b0b0;
cursor: pointer;
user-select: none;
}
.ai-msg-json pre {
margin: 0;
font: inherit;
white-space: pre-wrap;
word-break: break-all;
}
.ai-msg-json--expanded {
max-height: none;
overflow: visible;
}
.ai-msg-json:hover {
border-color: var(--df-accent);
}
/* ── 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 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
.ai-time-sep {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
user-select: none;
white-space: nowrap;
}
.ai-time-sep::before,
.ai-time-sep::after {
content: '';
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */
.ai-msg-bubble--answer {
border-left: 2px solid var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card));
}
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
.ai-trace-nav {
position: sticky;
top: 0;
z-index: 6;
display: flex;
align-items: center;
gap: 4px;
margin: -6px -2px 0;
padding: 5px 6px;
background: color-mix(in srgb, var(--df-bg) 86%, transparent);
backdrop-filter: blur(6px);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-border) 70%, transparent);
}
.ai-trace-nav-chip {
min-width: 20px;
height: 18px;
padding: 0 5px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-family: var(--df-font-mono);
font-size: 10px;
line-height: 1;
cursor: pointer;
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.ai-trace-nav-chip:hover {
color: var(--df-accent);
border-color: var(--df-accent);
}
/* ── 消息气泡拷贝按钮── */
.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);
}
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */
.ai-copy-btn--image {
right: 30px;
}
/* ── R4 代码块复制按钮:节点经 v-html 注入,非 scoped 受控,经模板锚点 .ai-md :deep 命中。
悬浮显示(代码块 hover 才现,右上角);is-ok/is-err 为复制反馈态(常显 1.2s 后由 JS 还原)。 ── */
.ai-md :deep(.md-code-block) {
position: relative;
}
.ai-md :deep(.md-code-copy) {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
padding: 2px 8px;
font-size: 10px;
line-height: 1.4;
color: var(--df-text-dim);
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease);
}
.ai-md :deep(.md-code-block:hover .md-code-copy) {
opacity: 1;
}
.ai-md :deep(.md-code-copy:hover) {
color: var(--df-accent);
border-color: var(--df-accent);
}
.ai-md :deep(.md-code-copy.is-ok) {
opacity: 1;
color: var(--df-success, #4caf50);
border-color: var(--df-success, #4caf50);
}
.ai-md :deep(.md-code-copy.is-err) {
opacity: 1;
color: var(--df-danger);
border-color: var(--df-danger);
}
/* R4 语言角标:左上角,hover 随复制按钮一起显现(hover pre 块时) */
.ai-md :deep(.md-code-block)::before {
content: attr(data-lang);
position: absolute;
top: 6px;
left: 8px;
z-index: 1;
font-size: 9px;
line-height: 1.4;
letter-spacing: 0.03em;
color: var(--df-text-dim);
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
padding: 1px 6px;
border-radius: var(--df-radius-sm);
opacity: 0;
transition: opacity 0.15s var(--df-ease);
pointer-events: none;
}
.ai-md :deep(.md-code-block:hover)::before {
opacity: 1;
}
/* 无 data-lang 的裸块不渲染空角标 */
.ai-md :deep(.md-code-block:not([data-lang]))::before {
content: none;
}
/* ── UX-02/消息操作栏(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;
}
/* 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);
}
/* 看板 953:max_tokens 截断弱提示条(末条 assistant 气泡尾部,warning 弱化不着色抢焦) */
.ai-msg-truncated {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 3px 8px;
border-radius: var(--df-radius-sm);
background: color-mix(in srgb, var(--df-warning, #ffc107) 10%, transparent);
width: fit-content;
}
.ai-msg-truncated-text {
font-size: 10.5px;
line-height: 1.4;
color: var(--df-text-dim);
}
/* -- 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 {
position: relative;
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; }
/* token 分项显示(2026-08-02):trigger 是可点击按钮(无背景,继承 dim 样式) */
.ai-token-usage-trigger {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0;
border: none;
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
opacity: 1;
}
.ai-token-usage-trigger:hover {
color: var(--df-text);
opacity: 1;
}
.ai-token-sep {
opacity: 0.5;
}
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
.ai-token-est {
color: var(--df-warning, #f0c75e);
opacity: 0.85;
}
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
.ai-token-popover {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
padding: 8px 10px;
min-width: 200px;
background: var(--df-bg-card, var(--df-bg));
border: 0.5px solid var(--df-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text);
z-index: 10;
text-align: left;
}
.ai-token-popover-title {
font-weight: 600;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-token-popover-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 2px 0;
}
/* #12 会话累计段分隔线 + 总计行加重 */
.ai-token-popover-sep {
height: 0.5px;
background: var(--df-border);
margin: 4px 0;
}
.ai-token-popover-row--total .ai-token-popover-label {
font-weight: 600;
}
.ai-token-popover-label {
color: var(--df-text-dim);
}
.ai-token-popover-val {
color: var(--df-text);
font-weight: 500;
}
/* 用户气泡内的图片(多模态消息渲染) */
.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);
}
/* ═══ 压缩摘要 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; }
/* A 过程压缩:执行轨迹卡(完成轮次过程内容折叠为一张,视觉对齐 .ai-msg-segment 弱化风格)。
体验优化(2026-10-09):过程卡视觉降级——左缩进 4px + 收窄 8px,与结果气泡拉开主次;
紧随其后的结果气泡用相邻兄弟选择器压缩间距,使「过程→结果」成组,一眼看清结果主体。 */
.ai-trace-card {
max-width: calc(var(--df-msg-max-width) - 8px);
margin-left: 4px;
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-text-dim) 6%, transparent);
overflow: hidden;
transition: border-color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-trace-card:hover {
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
}
.ai-trace-head {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
font-size: 11px;
cursor: pointer;
user-select: none;
color: var(--df-text-secondary);
}
.ai-trace-chevron {
font-size: 10px;
color: var(--df-text-dim);
transition: transform 0.15s var(--df-ease);
flex-shrink: 0;
}
.ai-trace-chevron.is-open { transform: rotate(90deg); }
.ai-trace-title { font-weight: 600; flex-shrink: 0; }
.ai-trace-agg {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
.ai-trace-toggle { flex-shrink: 0; font-size: 10px; color: var(--df-accent); opacity: 0.85; }
/* 全程 token 汇总(轨迹卡头部一处,per-round 明细下沉思考 pill 行) */
.ai-trace-tokens {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
/* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */
.ai-trace-task-chip {
flex-shrink: 0;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
color: var(--df-accent);
background: color-mix(in srgb, var(--df-accent) 12%, transparent);
cursor: pointer;
transition: background 0.15s var(--df-ease);
}
.ai-trace-task-chip:hover {
background: color-mix(in srgb, var(--df-accent) 22%, transparent);
}
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
.ai-trace-body {
position: relative;
padding: 2px 10px 8px 0;
}
.ai-trace-body::before {
content: '';
position: absolute;
left: 22px;
top: 0;
bottom: 0;
width: 1px;
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
}
.ai-trace-round {
position: relative;
padding: 2px 0;
}
.ai-trace-round + .ai-trace-round { margin-top: 2px; }
/* ── 思考单行 pill:灯泡锚轨 + 单行截断 + 右端 token 小字
(空间不足先压缩摘要文本段(hover title 有全文),token 尾段不再被截,恒完整可见) ── */
.ai-think-pill {
position: relative;
display: flex;
align-items: center;
gap: 7px;
width: 100%;
min-height: 24px;
padding: 3px 8px 3px 38px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 11px;
line-height: 1.5;
text-align: left;
cursor: pointer;
user-select: none;
transition: background 0.15s var(--df-ease);
}
.ai-think-pill:hover {
background: color-mix(in srgb, var(--df-text-dim) 8%, transparent);
}
.ai-think-ic {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border-radius: var(--df-radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: var(--df-accent-bg);
color: var(--df-accent);
flex-shrink: 0;
}
.ai-think-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-think-tokens {
flex: 0 0 auto;
white-space: nowrap;
font-family: var(--df-font-mono);
font-size: 9.5px;
color: var(--df-text-dim);
opacity: 0.75;
margin-left: auto;
}
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
.ai-think-full {
margin: 2px 0 6px 38px;
padding: 8px 12px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
font-size: 12px;
line-height: 1.6;
color: var(--df-text-secondary);
max-height: 320px;
overflow-y: auto;
}
/* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron,
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变 */
.ai-trace-card--running {
border-style: solid;
border-color: color-mix(in srgb, var(--df-accent) 28%, transparent);
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
}
.ai-trace-toggle--running {
color: var(--df-text-dim);
opacity: 0.9;
}
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
.ai-step-line {
display: flex;
align-items: center;
gap: 6px;
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-step-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite;
}
.ai-step-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes ai-step-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 重试过渡细条(isRetry 过渡气泡):琥珀点呼吸+单行中性文案,骨架对齐 .ai-step-line。
warning 色调与错误红卡分层:重试是过渡态非终态失败,终态仍走 .ai-msg-bubble--error */
.ai-retry-line {
display: flex;
align-items: center;
gap: 6px;
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
}
.ai-retry-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-warning, #f0c75e);
flex-shrink: 0;
animation: ai-step-pulse 1.2s ease-in-out infinite;
}
.ai-retry-text {
font-family: var(--df-font-mono);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看。
S3:整卡可点开轨迹浮层,cursor pointer + hover 提示可交互 */
.ai-agent-fin {
display: flex;
align-items: center;
gap: 6px;
max-width: var(--df-msg-max-width);
padding: 4px 10px;
border-radius: var(--df-radius);
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
cursor: pointer;
}
.ai-agent-fin:hover {
background: color-mix(in srgb, var(--df-text-secondary) 12%, transparent);
}
.ai-agent-fin-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-text-secondary);
flex-shrink: 0;
}
.ai-agent-fin--warn {
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
}
.ai-agent-fin--warn .ai-agent-fin-dot {
background: var(--df-warning, #f0c75e);
}
.ai-agent-fin--err {
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
}
.ai-agent-fin--err .ai-agent-fin-dot {
background: var(--df-danger);
}
.ai-agent-fin-label {
flex-shrink: 0;
font-weight: 600;
}
/* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */
.ai-agent-fin-seq {
flex-shrink: 0;
font-family: var(--df-font-mono, monospace);
font-size: 11px;
opacity: 0.65;
}
.ai-agent-fin-status {
flex-shrink: 0;
}
.ai-agent-fin--warn .ai-agent-fin-status {
color: var(--df-warning, #f0c75e);
}
.ai-agent-fin--err .ai-agent-fin-status {
color: var(--df-danger);
}
.ai-agent-fin-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-agent-fin-meta {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
}
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
.ai-agent-fin-rejected {
flex-shrink: 0;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 6px;
border-radius: 999px;
font-size: 10.5px;
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
color: var(--df-warning, #f0c75e);
}
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
.ai-agent-fin--notrace {
cursor: default;
opacity: 0.72;
}
.ai-agent-fin--notrace:hover {
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
}
/* peek 拉取中的加载微标(挂在具体卡右端) */
.ai-agent-fin-spin {
flex-shrink: 0;
width: 10px;
height: 10px;
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
border-top-color: var(--df-text-secondary);
border-radius: 50%;
animation: ai-agent-fin-spin 0.8s linear infinite;
}
@keyframes ai-agent-fin-spin {
to { transform: rotate(360deg); }
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
}
.ai-agent-peek {
width: min(680px, 86vw);
max-height: 76vh;
display: flex;
flex-direction: column;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.ai-agent-peek-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.ai-agent-peek-title {
font-size: 12.5px;
font-weight: 600;
color: var(--df-text);
}
.ai-agent-peek-close {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
padding: 4px;
border-radius: var(--df-radius-sm);
display: flex;
}
.ai-agent-peek-close:hover {
color: var(--df-text);
background: color-mix(in srgb, var(--df-text-secondary) 10%, transparent);
}
.ai-agent-peek-body {
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-agent-peek-empty {
color: var(--df-text-dim);
font-size: 12px;
text-align: center;
padding: 24px 0;
}
.ai-agent-peek-msg {
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 8px 10px;
}
/* role 左侧色条:user 中性/accent、assistant 默认、tool 结果弱化、system 弱化斜体 */
.ai-agent-peek-msg--user { border-left: 2px solid var(--df-accent); }
.ai-agent-peek-msg--assistant { border-left: 2px solid var(--df-success); }
.ai-agent-peek-msg--tool,
.ai-agent-peek-msg--system { border-left: 2px solid var(--df-border-strong); }
.ai-agent-peek-role {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
font-weight: 600;
color: var(--df-text-secondary);
margin-bottom: 4px;
}
.ai-agent-peek-tool {
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 500;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-text {
font-size: 12px;
line-height: 1.55;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-word;
}
.ai-agent-peek-msg--tool .ai-agent-peek-text,
.ai-agent-peek-msg--system .ai-agent-peek-text {
color: var(--df-text-secondary);
font-size: 11.5px;
}
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
.ai-agent-peek-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-agent-peek-subject {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-agent-peek-agent {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10.5px;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-conv {
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text);
}
.ai-agent-peek-task {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--df-text-dim);
}
/* deny envelope 消息:红字人话,置于 tool/system 弱化规则之后以覆盖其色 */
.ai-agent-peek-msg--denied {
border-left: 2px solid var(--df-danger);
}
.ai-agent-peek-msg--denied .ai-agent-peek-text {
color: var(--df-danger);
}
</style>