- TopBar 10 处 icon-only 按钮补 aria-label(复用同 :title 表达式,含三态 title) - ConversationSidebar: 4 icon-only aria-label + 2 分组标题键盘化(role/tabindex/aria-expanded/keydown) - MessageList 2 icon-only; KnowledgeDetail 3 章节折叠键盘化; dashboard 两列表项键盘化 - FileExplorer 面包屑/模块下拉项键盘化; ConflictResolver 冲突文件折叠键盘化 - 约定: 遮罩/点外关闭类(@click.self)非键盘缺口,跳过;零新增 i18n 键 - 验收: bunx vue-tsc --noEmit EXIT 0;看板 P3-2 更新(两批共 23 处)
3042 lines
132 KiB
Vue
3042 lines
132 KiB
Vue
<script setup lang="ts">
|
||
//! 消息列表子组件 — 第四批抽自 src/components/AiChat.vue(零行为变更)。
|
||
//!
|
||
//! 职责:消息列表渲染(.ai-messages 容器 + 空状态 + renderItems 扁平渲染 +
|
||
//! 消息气泡 user/ai/system + 流式 Markdown 分块渲染 + 工具卡 ToolCardList 转发审批 +
|
||
//! 折叠归档段 + 选区保持 + 滚动跟随/回底部)。
|
||
//!
|
||
//! 共享:store(useAiStore 单例)/ Markdown 渲染器(useMarkdown 模块级单例)与父同实例,
|
||
//! currentText/streamingBlocks/messages 经 store,流式行为零变化。
|
||
//!
|
||
//! 与父交互(emit/expose):
|
||
//! - emit('start-edit', msg):UX-09 编辑末条 user,父置 editingMsgId + 委托 ChatInput.startEditFocus
|
||
//! - emit('send-example-prompt', i18nKey):空状态示例问题,父委托 ChatInput.sendExamplePrompt
|
||
//! - emit('go-to-settings'):无 provider 引导 / 错误气泡「去设置」,父走 router/toast(detached 降级)
|
||
//! - emit('toast', {msg, type}):copy/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, 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' })
|
||
}
|
||
|
||
/**
|
||
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
|
||
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
|
||
* 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝)
|
||
* 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。
|
||
*/
|
||
let lastMsgSnapshot = ''
|
||
watch(
|
||
() => JSON.stringify({
|
||
n: store.state.messages.length,
|
||
tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))),
|
||
}),
|
||
(snap) => {
|
||
if (snap === lastMsgSnapshot) return
|
||
const isFirst = lastMsgSnapshot === ''
|
||
lastMsgSnapshot = snap
|
||
if (isFirst) return
|
||
// 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) 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>`
|
||
},
|
||
)
|
||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||
_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' })
|
||
}
|
||
}
|
||
|
||
// ── 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 单行文本:纯文本截断 + 空轮回退文案(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 的 traceCount,执行中=将成为的轨迹序号);
|
||
* 失败/拒绝/待审批段仅非零时显示;全部无数据回退「过程记录」。
|
||
*/
|
||
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 轮」,与执行中步骤行、轮次导航条同一计数来源)
|
||
| { 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 }
|
||
// 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 相对时间已移除。轨距追踪 lastMsgTs 只在消息桶边界推进,
|
||
* 轨迹卡与其内部轮次之间不插线(同轮间隔无叙事价值)。轨迹桶自身也不再发
|
||
* timesep 行:卡头「第 N 轮」+ 绝对起始时刻已承载时间行职责(两行合一)。
|
||
*/
|
||
const TIME_SEP_GAP_MS = 30 * 60 * 1000
|
||
let lastMsgTs = 0
|
||
|
||
/** 与上一条已渲染消息是否需要时间分隔线(间隔 ≥30 分钟或跨天;首条不插) */
|
||
function needsTimeSep(ts: number): boolean {
|
||
if (!lastMsgTs || !ts) return false
|
||
if (Math.abs(ts - lastMsgTs) >= TIME_SEP_GAP_MS) return true
|
||
const a = new Date(lastMsgTs)
|
||
const b = new Date(ts)
|
||
return a.getFullYear() !== b.getFullYear()
|
||
|| a.getMonth() !== b.getMonth()
|
||
|| a.getDate() !== b.getDate()
|
||
}
|
||
|
||
/** 需要时插 timesep 项并推进 lastMsgTs(同一出口防漏插/重插) */
|
||
function pushTimeSepIfNeeded(items: RenderItem[], ts: number): void {
|
||
if (needsTimeSep(ts)) {
|
||
items.push({ kind: 'timesep', key: 'tsep-' + ts + '-' + items.length, ts })
|
||
}
|
||
lastMsgTs = ts
|
||
}
|
||
|
||
const renderItems = computed<RenderItem[]>(() => {
|
||
const items: RenderItem[] = []
|
||
let bucket: AiMessage[] = []
|
||
lastMsgTs = 0
|
||
// 轨迹序号计数:轨迹卡头「第 N 轮」与执行中步骤行同一来源(computed 重算从 0 起,
|
||
// 计数即轨迹卡出现顺序,与轮次导航条序号一致)
|
||
let traceCount = 0
|
||
|
||
const flushBucket = () => {
|
||
const msgs = bucket.filter(shouldRenderMsg)
|
||
bucket = []
|
||
if (msgs.length === 0) return
|
||
|
||
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
|
||
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
|
||
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
|
||
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
|
||
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
|
||
const cur = msgs[msgs.length - 1]
|
||
if (isViewingGenerating.value && cur.id === lastStoreId) {
|
||
const prev = msgs.slice(0, -1)
|
||
if (prev.length > 0) {
|
||
pushTimeSepIfNeeded(items, msgs[0].timestamp)
|
||
traceCount++
|
||
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||
// 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。
|
||
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
|
||
} else {
|
||
pushTimeSepIfNeeded(items, cur.timestamp)
|
||
}
|
||
items.push({ kind: 'msg', key: 'm-' + cur.id, msg: cur, final: true })
|
||
lastMsgTs = cur.timestamp
|
||
return
|
||
}
|
||
// 最终答复:末条有正文且非纯 JSON 工具结果的消息;全无正文时保底末条(工具轮可见)
|
||
let fi = -1
|
||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||
const c = msgs[i].content
|
||
if (c && c.trim() && !isToolResultJson(c)) { 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) {
|
||
const key = 'trace-' + traceMsgs[0].id
|
||
const expanded = expandedTraceIds.value.has(key)
|
||
// 终点计入末轮时长:过程消息里最晚的工具完成时刻与末轮开始时刻取较大者
|
||
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 轮」+ 绝对起始时刻已承载时间行职责;
|
||
// 仅推进轨距,保持出口语义(后续消息的间隔判定从本桶起点起算)
|
||
lastMsgTs = traceMsgs[0].timestamp
|
||
traceCount++
|
||
items.push({
|
||
kind: 'trace', key,
|
||
round: traceCount,
|
||
stats: collectTurnStats(traceMsgs),
|
||
startTs: traceMsgs[0].timestamp,
|
||
endTs: Math.max(lastCompleted, finalMsg.timestamp),
|
||
tokens: sumTurnTokens(traceMsgs),
|
||
traceMsgs, expanded,
|
||
})
|
||
} else {
|
||
pushTimeSepIfNeeded(items, finalMsg.timestamp)
|
||
}
|
||
// 正文不折叠(2026-10-10 根因修复):此前整轮(含模型正文)折进轨迹卡默认收起 → 流式期间
|
||
// 能看到正文、轮末正文随卡收起,用户表象为「气泡被清空/进入等待」,历次在 flush/清空/骨架层
|
||
// 修补均无效。现把过程消息里**有正文**的各成一个常显气泡(剥掉 toolCalls——工具卡仍归轨迹卡,
|
||
// 不重复渲染),轨迹卡只折叠工具步骤与思考。
|
||
for (const m of traceMsgs) {
|
||
const c = m.content
|
||
if (m.role === 'assistant' && c && c.trim() && !isToolResultJson(c)) {
|
||
items.push({ kind: 'msg', key: 'm-' + m.id, msg: { ...m, toolCalls: undefined } })
|
||
}
|
||
}
|
||
items.push({ kind: 'msg', key: 'm-' + finalMsg.id, msg: finalMsg, final: true })
|
||
lastMsgTs = finalMsg.timestamp
|
||
}
|
||
|
||
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) {
|
||
pushTimeSepIfNeeded(items, m.timestamp)
|
||
items.push({ kind: 'msg', key: 'e-' + seg.key + '-' + m.id, msg: m })
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
const m = seg.msg
|
||
// assistant 非错误非压缩进轮次桶(compressed 用户诉求不折叠,原样展开)
|
||
if (m.role === 'assistant' && !m.isError && m.status !== 'compressed') {
|
||
bucket.push(m)
|
||
} else {
|
||
flushBucket()
|
||
pushTimeSepIfNeeded(items, 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
|
||
})
|
||
|
||
// ── 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">
|
||
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 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"
|
||
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
|
||
@click.stop="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">
|
||
<!-- 空内容轮(只剩「过程记录」占位)不渲染思考 pill,降噪;工具卡照常 -->
|
||
<template v-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.content?.trim()"
|
||
class="ai-think-full ai-md"
|
||
v-html="renderMdDecorated(m.content)"
|
||
></div>
|
||
</template>
|
||
<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">
|
||
<div class="ai-trace-head">
|
||
<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 ai-trace-toggle--running">{{ $t('ai.trace.running') }}</span>
|
||
</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>
|
||
|
||
<!-- 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);
|
||
}
|
||
|
||
/* -- 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>
|