WorkspaceMessageTimeline.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. <script setup lang="ts">
  2. import { useChatAppearance } from '@/stores/chatAppearance'
  3. import { bubbleVariables } from '@/utils/chatAppearance'
  4. import '@/styles/chat-bubbles.css'
  5. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  6. import AppIcon from '@/components/common/AppIcon.vue'
  7. import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
  8. import { messageFailure } from '@/utils/messageFailure'
  9. import MessageSystemEvent from '@/components/messages/MessageSystemEvent.vue'
  10. import { systemMessageNotice } from '@/utils/systemMessageNotice'
  11. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  12. import TeamAvatar from '@/components/common/TeamAvatar.vue'
  13. import EmptyState from '@/components/common/EmptyState.vue'
  14. import TypingIndicator from '@/components/common/TypingIndicator.vue'
  15. import InteractionCard from '../messages/InteractionCard.vue'
  16. import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
  17. import TaskResultNotice from './TaskResultNotice.vue'
  18. import MarkdownContent from '../messages/MarkdownContent.vue'
  19. import ToolTrace from '../messages/ToolTrace.vue'
  20. import TurnTrace from '../messages/TurnTrace.vue'
  21. import type { UiInteraction, UiLocalFileLink, UiMessage } from '../messages/types'
  22. import { buildMessageTimelineRows } from '@/utils/turnTrace'
  23. import { copyTextToClipboard } from '@/utils/clipboard'
  24. import { displayContentForMessage } from '@/utils/messageDisplay'
  25. import { formatMessageTime } from '@/utils/messageTime'
  26. const { appearance } = useChatAppearance()
  27. type AvatarState = 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'
  28. type HeaderAvatarMember = { name: string; avatar?: string; state?: AvatarState; activityKey?: number }
  29. const props = withDefaults(defineProps<{
  30. messages: UiMessage[]
  31. identity: string
  32. title?: string
  33. subtitle?: string
  34. headerAvatarName?: string
  35. headerAvatar?: string
  36. headerAvatarKind?: 'agent' | 'team'
  37. headerAvatarMembers?: HeaderAvatarMember[]
  38. headerAvatarState?: AvatarState
  39. headerAvatarActivityKey?: string | number
  40. emptyTitle?: string
  41. emptyDescription?: string
  42. emptyLogo?: boolean
  43. transparentHeader?: boolean
  44. forkSourceTitle?: string
  45. loading?: boolean
  46. loadingOlder?: boolean
  47. hasOlder?: boolean
  48. showTools?: boolean
  49. showAssistantIdentity?: boolean
  50. interaction?: UiInteraction | null
  51. interactionBusy?: boolean
  52. interactionError?: string
  53. approvalAgentName?: string
  54. mentionNames?: string[]
  55. agentAvatars?: Record<string, string>
  56. agentStates?: Record<string, AvatarState>
  57. thinking?: boolean
  58. thinkingIdentity?: { name: string; avatar: string }
  59. allowBranch?: boolean
  60. readOnly?: boolean
  61. }>(), {
  62. title: '',
  63. subtitle: '',
  64. headerAvatarName: '',
  65. headerAvatar: '',
  66. headerAvatarKind: 'agent',
  67. headerAvatarMembers: () => [],
  68. headerAvatarState: 'idle',
  69. emptyTitle: '开始一段新对话',
  70. emptyDescription: '从下方输入框发送消息,或选择左侧的历史会话。',
  71. emptyLogo: false,
  72. transparentHeader: false,
  73. forkSourceTitle: '',
  74. loading: false,
  75. loadingOlder: false,
  76. hasOlder: false,
  77. showTools: true,
  78. showAssistantIdentity: true,
  79. interaction: null,
  80. mentionNames: () => [],
  81. agentAvatars: () => ({}),
  82. agentStates: () => ({}),
  83. thinking: false,
  84. allowBranch: true,
  85. readOnly: false,
  86. })
  87. const emit = defineEmits<{
  88. loadOlder: []
  89. quote: [message: UiMessage]
  90. branch: [message: UiMessage]
  91. preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
  92. previewFile: [file: UiLocalFileLink]
  93. approvalChoice: [choice: 'once' | 'deny' | 'auto']
  94. clarify: [text: string]
  95. }>()
  96. const scroller = ref<HTMLElement | null>(null)
  97. const pinnedToBottom = ref(true)
  98. const showJump = ref(false)
  99. const copiedMessageId = ref('')
  100. const copyFailedMessageId = ref('')
  101. // Like OpenMausBot, mount the tail first and explicitly expand older content.
  102. const windowStart = ref(0)
  103. const windowSize = 120
  104. const windowedMessages = computed(() => props.messages.slice(windowStart.value))
  105. const messageBodies = new WeakMap<UiMessage, UiMessage>()
  106. const timelineRows = computed(() => buildMessageTimelineRows(windowedMessages.value, messageBodies))
  107. const mentionSignature = computed(() => props.mentionNames.join("\u0000"))
  108. let transcriptObserver: ResizeObserver | undefined
  109. let followFrame: number | undefined
  110. let previousTop = 0
  111. let touchY = 0
  112. let historyAnchor: { id: string; top: number } | undefined
  113. function stopFollowing() { pinnedToBottom.value = false }
  114. function captureHistoryAnchor() {
  115. const el = scroller.value
  116. const row = el && [...el.querySelectorAll<HTMLElement>('[data-message-id]')].find(row => row.getBoundingClientRect().bottom > el.getBoundingClientRect().top)
  117. if (row) historyAnchor = { id: row.dataset.messageId!, top: row.getBoundingClientRect().top }
  118. stopFollowing()
  119. }
  120. async function restoreHistoryAnchor() {
  121. await nextTick()
  122. const el = scroller.value, anchor = historyAnchor
  123. if (!el || !anchor) return
  124. const row = el.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(anchor.id)}"]`)
  125. if (row) el.scrollTop += row.getBoundingClientRect().top - anchor.top
  126. previousTop = el.scrollTop; historyAnchor = undefined
  127. }
  128. async function showEarlier() {
  129. captureHistoryAnchor()
  130. if (windowStart.value > 0) { windowStart.value = Math.max(0, windowStart.value - windowSize); await restoreHistoryAnchor() }
  131. else emit('loadOlder')
  132. }
  133. watch(() => props.identity, () => {
  134. windowStart.value = Math.max(0, props.messages.length - windowSize)
  135. pinnedToBottom.value = true; showJump.value = false; historyAnchor = undefined
  136. void nextTick(() => scheduleFollow())
  137. }, { immediate: true })
  138. watch(() => props.messages[0]?.id, (id, oldId) => {
  139. if (!oldId || id === oldId) return
  140. const offset = props.messages.findIndex(message => message.id === oldId)
  141. if (offset > 0 && windowStart.value > 0) windowStart.value += offset
  142. if (historyAnchor) void restoreHistoryAnchor()
  143. })
  144. function scheduleFollow() {
  145. if (!pinnedToBottom.value || followFrame !== undefined) return
  146. followFrame = requestAnimationFrame(() => {
  147. followFrame = undefined
  148. if (pinnedToBottom.value) scrollToBottom('auto')
  149. })
  150. }
  151. const showThinkingIndicator = computed(() => {
  152. return props.thinking
  153. })
  154. const thinkingAgent = computed(() =>
  155. [...props.messages].reverse().find(message => message.role === 'assistant' && message.status === 'streaming')
  156. ?? [...props.messages].reverse().find(message => message.role === 'assistant'),
  157. )
  158. const thinkingAvatarName = computed(() => props.thinkingIdentity?.name || thinkingAgent.value?.author || thinkingAgent.value?.profile || '夭')
  159. const thinkingAvatar = computed(() => {
  160. if (props.thinkingIdentity) return props.thinkingIdentity.avatar
  161. const profile = thinkingAgent.value?.profile
  162. return profile ? props.agentAvatars[profile] || '' : ''
  163. })
  164. let copyResetTimer: number | undefined
  165. const formatTime = formatMessageTime
  166. function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying' {
  167. if (message.status === 'streaming') return (message.profile && props.agentStates[message.profile]) || 'working'
  168. if (message.status === 'pending' || message.status === 'unknown-receipt') return 'waiting'
  169. if (message.status === 'failed') return 'failure'
  170. const outcome = message.profile ? props.agentStates[message.profile] : undefined
  171. if ((outcome === 'success' || outcome === 'failure') && [...props.messages].reverse().find(m => m.profile === message.profile && m.role === 'assistant')?.id === message.id) return outcome
  172. return 'idle'
  173. }
  174. function onScroll() {
  175. const el = scroller.value
  176. if (!el) return
  177. const distance = el.scrollHeight - el.scrollTop - el.clientHeight
  178. // Content growth is not a user gesture. Only a downward return to the end re-arms follow.
  179. if (el.scrollTop > previousTop && distance < 80) pinnedToBottom.value = true
  180. previousTop = el.scrollTop
  181. showJump.value = !pinnedToBottom.value && distance > 100
  182. }
  183. function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
  184. const el = scroller.value
  185. if (!el) return
  186. pinnedToBottom.value = true
  187. el.scrollTo({ top: el.scrollHeight, behavior })
  188. previousTop = el.scrollTop
  189. }
  190. function onMarkdownRendered() { scheduleFollow() }
  191. function scrollToMessage(id: string): boolean {
  192. const root = scroller.value
  193. if (!root) return false
  194. const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
  195. if (!message) {
  196. const index = props.messages.findIndex(message => message.id === id)
  197. if (index < 0) return false
  198. stopFollowing(); windowStart.value = Math.max(0, index - Math.floor(windowSize / 2))
  199. void nextTick(() => scrollToMessage(id)); return true
  200. }
  201. stopFollowing()
  202. message.scrollIntoView({ block: 'center', behavior: 'smooth' })
  203. message.classList.remove('message--revealed')
  204. requestAnimationFrame(() => message.classList.add('message--revealed'))
  205. window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
  206. return true
  207. }
  208. function scrollToAnchor(messageId: string, anchorId: string): boolean {
  209. const root = scroller.value
  210. if (!root) return false
  211. const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`)
  212. if (!message) { const index = props.messages.findIndex(m => m.id === messageId); if (index < 0) return false; stopFollowing(); windowStart.value = Math.max(0, index - 60); void nextTick(() => scrollToAnchor(messageId, anchorId)); return true }
  213. stopFollowing()
  214. const target = anchorId ? message.querySelector<HTMLElement>(`#${CSS.escape(anchorId)}`) : null
  215. ;(target || message).scrollIntoView({ block: target ? 'start' : 'center', behavior: 'smooth' })
  216. message.classList.remove('message--revealed')
  217. requestAnimationFrame(() => message.classList.add('message--revealed'))
  218. window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
  219. return true
  220. }
  221. async function copyMessage(message: UiMessage) {
  222. const text = displayContentForMessage(message.role, message.content)
  223. const copied = await copyTextToClipboard(text)
  224. copiedMessageId.value = copied ? message.id : ''
  225. copyFailedMessageId.value = copied ? '' : message.id
  226. if (copyResetTimer !== undefined) window.clearTimeout(copyResetTimer)
  227. copyResetTimer = window.setTimeout(() => {
  228. copiedMessageId.value = ''
  229. copyFailedMessageId.value = ''
  230. copyResetTimer = undefined
  231. }, 1400)
  232. }
  233. function hasConversationActions(message: UiMessage): boolean {
  234. return message.role === 'user' || (message.role === 'assistant' && Boolean(message.content.trim()))
  235. }
  236. function deliveryLabel(status?: UiMessage['status']): string {
  237. return ({
  238. preparing: '正在准备附件',
  239. attached: '附件已就绪',
  240. pending: '正在发送',
  241. accepted: '已发送',
  242. failed: '发送失败',
  243. 'unknown-receipt': '回执未知,请检查后重试',
  244. } as Partial<Record<NonNullable<UiMessage['status']>, string>>)[status || 'settled'] || ''
  245. }
  246. watch(() => props.messages.length, async () => {
  247. if (!pinnedToBottom.value) return
  248. await nextTick()
  249. scheduleFollow()
  250. })
  251. watch(() => props.messages.at(-1)?.content, async () => {
  252. if (!pinnedToBottom.value) return
  253. await nextTick()
  254. scheduleFollow()
  255. })
  256. onMounted(() => nextTick(() => {
  257. scheduleFollow()
  258. transcriptObserver = new ResizeObserver(scheduleFollow)
  259. const stack = scroller.value?.querySelector('.message-stack')
  260. if (stack) transcriptObserver.observe(stack)
  261. }))
  262. watch(() => props.messages.length, () => nextTick(() => {
  263. const stack = scroller.value?.querySelector('.message-stack')
  264. if (stack) transcriptObserver?.observe(stack)
  265. }))
  266. onBeforeUnmount(() => {
  267. transcriptObserver?.disconnect()
  268. if (followFrame !== undefined) cancelAnimationFrame(followFrame)
  269. if (copyResetTimer !== undefined) window.clearTimeout(copyResetTimer)
  270. })
  271. defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBottom: () => pinnedToBottom.value })
  272. </script>
  273. <template>
  274. <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
  275. <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
  276. <slot name="header-leading" />
  277. <div v-if="!transparentHeader" class="timeline-identity">
  278. <TeamAvatar v-if="headerAvatarName && headerAvatarKind === 'team'" :name="headerAvatarName" :avatar="headerAvatar" :members="headerAvatarMembers" :size="34" />
  279. <AgentAvatar v-else-if="headerAvatarName" class="timeline-header__avatar" :name="headerAvatarName" :avatar="headerAvatar" :state="headerAvatarState" :activity-key="headerAvatarActivityKey" :size="34" />
  280. <div class="timeline-heading"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
  281. </div>
  282. <slot name="header-actions" />
  283. </header>
  284. <div ref="scroller" class="timeline" @scroll.passive="onScroll"
  285. @wheel.passive="event => { if (event.deltaY < 0) stopFollowing() }"
  286. @touchstart.passive="event => { touchY = event.touches[0]?.clientY ?? 0 }"
  287. @touchmove.passive="event => { const y = event.touches[0]?.clientY ?? touchY; if (y > touchY) stopFollowing(); touchY = y }"
  288. @pointerdown="event => { const el = scroller; if (el && event.clientX >= el.getBoundingClientRect().right - 18) stopFollowing() }"
  289. @keydown="event => { if (['PageUp', 'Home', 'ArrowUp'].includes(event.key)) stopFollowing() }">
  290. <div v-if="loading && !messages.length" class="message-skeletons" aria-label="正在加载消息">
  291. <div v-for="n in 4" :key="n" :class="{ user: n % 2 === 0 }"><i /><span /></div>
  292. </div>
  293. <div v-else-if="!messages.length && emptyLogo" class="new-chat-empty">
  294. <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" width="1024" height="1024" alt="" aria-hidden="true" />
  295. <p>聊点什么</p>
  296. </div>
  297. <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
  298. <div v-else class="message-stack" :class="{ 'message-stack--interaction': interaction }">
  299. <div v-if="forkSourceTitle" class="fork-divider" role="separator">
  300. <span><AppIcon name="branch" :size="13" />FORK 来源 <strong>{{ forkSourceTitle }}</strong></span>
  301. </div>
  302. <button v-if="hasOlder || windowStart > 0" class="load-older" type="button" :disabled="loadingOlder" @click="showEarlier">
  303. {{ loadingOlder ? '正在加载…' : '加载更早消息' }}
  304. </button>
  305. <template v-for="row in timelineRows" :key="row.id">
  306. <TurnTrace v-if="row.kind === 'trace' && showTools" :group="row" :stream-interval-ms="0" />
  307. <template v-else-if="row.kind === 'message'">
  308. <article
  309. v-for="message in [row.message]"
  310. :key="message.id"
  311. v-memo="[appearance, message, showTools, showAssistantIdentity, mentionSignature, agentAvatars[message.profile || ''], avatarState(message), copiedMessageId === message.id, copyFailedMessageId === message.id, readOnly, allowBranch]"
  312. :data-message-id="message.id"
  313. class="message"
  314. :class="[`message--${message.role}`, {
  315. 'message--failed': message.status === 'failed',
  316. 'message--failure-notice': messageFailure(message)?.replacesContent && !message.attachments?.length,
  317. 'message--has-failure': !!messageFailure(message),
  318. 'message--notice': !!systemMessageNotice(message),
  319. 'message--communication': !!message.communication,
  320. 'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
  321. 'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
  322. 'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
  323. }]"
  324. >
  325. <AgentAvatar v-if="!message.communication && message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" :state="avatarState(message)" />
  326. <div v-if="message.communication" class="communication-caption" role="note">
  327. <span>{{ message.communication.direction === 'outgoing' ? '发送给' : '消息来自' }}</span>
  328. <AgentAvatar :name="message.communication.peerName" :avatar="message.communication.peerAvatar" :size="16" :animated="false" aria-hidden="true" />
  329. <span class="communication-caption__name">{{ message.communication.peerName }}</span>
  330. </div>
  331. <div class="message__body">
  332. <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
  333. <TaskResultNotice v-else-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
  334. <MessageSystemEvent v-else-if="systemMessageNotice(message)" :message="message" />
  335. <template v-else>
  336. <div v-if="!message.communication && (message.role !== 'assistant' || showAssistantIdentity)" class="message__meta">
  337. <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
  338. <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
  339. <time>{{ formatTime(message.createdAt) }}</time>
  340. <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: message.role === 'user' ? '发送失败' : '回复中断', 'unknown-receipt': '回执未知' }[message.status] }}</span>
  341. </div>
  342. <details v-if="message.reasoning" class="message__reasoning">
  343. <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
  344. <MarkdownContent process-content :content="message.reasoning" :streaming="message.status === 'streaming'" :stream-interval-ms="0" @rendered="onMarkdownRendered" />
  345. </details>
  346. <div v-if="!messageFailure(message)?.replacesContent" class="message__content" :style="!message.communication && (message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
  347. <MarkdownContent
  348. :separate-media="message.role === 'user' || message.role === 'assistant' || !!message.communication"
  349. :bubble="!message.communication && (message.role === 'user' || message.role === 'assistant') && !!message.content.trim()"
  350. :attachments="message.attachments"
  351. :content-parts="message.contentParts"
  352. @preview="item => item.id && message.attachments?.some(a => a.id === item.id) ? emit('preview', item) : emit('previewFile', { name: item.name, url: item.url! })"
  353. :stream-interval-ms="0"
  354. :file-profile="message.profile"
  355. :content="displayContentForMessage(message.role, message.content)"
  356. :streaming="message.status === 'streaming'"
  357. :legacy-media="message.role === 'assistant'"
  358. :plain="message.role === 'user'"
  359. :mention-names="mentionNames"
  360. :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
  361. :file-cards="message.role === 'user' || message.role === 'assistant' || !!message.communication"
  362. :process-content="!message.communication && message.role !== 'user' && message.role !== 'assistant'"
  363. @rendered="onMarkdownRendered"
  364. @file-link="(name, url) => emit('previewFile', { name, url })"
  365. />
  366. </div>
  367. <div
  368. v-if="message.role === 'user' && deliveryLabel(message.status)"
  369. class="message__delivery"
  370. :class="`message__delivery--${message.status}`"
  371. role="status"
  372. aria-live="polite"
  373. >
  374. <AppIcon :name="message.status === 'accepted' ? 'check' : message.status === 'failed' || message.status === 'unknown-receipt' ? 'alert' : 'dots'" :size="11" />
  375. {{ deliveryLabel(message.status) }}
  376. </div>
  377. <div v-if="showTools && message.tools?.length" class="message__tools">
  378. <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
  379. </div>
  380. <div v-if="hasConversationActions(message)" class="message__actions">
  381. <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
  382. <button
  383. type="button"
  384. :class="{
  385. 'message-action--copied': copiedMessageId === message.id,
  386. 'message-action--copy-failed': copyFailedMessageId === message.id,
  387. }"
  388. :title="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制'"
  389. :aria-label="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制消息'"
  390. @click="copyMessage(message)"
  391. ><AppIcon :name="copiedMessageId === message.id ? 'check' : copyFailedMessageId === message.id ? 'alert' : 'copy'" :size="13" /></button>
  392. <button v-if="!readOnly" type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
  393. <button v-if="!readOnly && allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
  394. </div>
  395. </template>
  396. </div>
  397. <MessageFailureNotice v-if="messageFailure(message) && (!messageFailure(message)?.replacesContent || !!message.attachments?.length)" :failure="messageFailure(message)!" />
  398. </article>
  399. </template>
  400. </template>
  401. <TypingIndicator
  402. v-if="showThinkingIndicator"
  403. :avatar-name="thinkingAvatarName"
  404. :avatar="thinkingAvatar"
  405. />
  406. <WorkspaceApprovalCard v-if="interaction?.kind === 'approval'" :prompt="interaction.prompt" :agent-name="approvalAgentName" :busy="interactionBusy" :error="interactionError" @choose="emit('approvalChoice', $event)" />
  407. <InteractionCard
  408. v-else-if="interaction"
  409. :interaction="interaction"
  410. :busy="interactionBusy"
  411. @clarify="emit('clarify', $event)"
  412. />
  413. <p v-if="interaction && interaction.kind !== 'approval' && interactionError" role="alert" style="color:var(--danger);font-size:14px">{{ interactionError }}</p>
  414. </div>
  415. </div>
  416. <Transition name="view-fade">
  417. <button v-if="showJump" class="jump-bottom" type="button" @click="scrollToBottom()">
  418. <AppIcon name="chevron-down" :size="15" />回到底部
  419. </button>
  420. </Transition>
  421. </section>
  422. </template>
  423. <style scoped>
  424. .message--has-failure { flex-wrap: wrap; }
  425. .message--has-failure > .failure-notice { flex: 0 0 100%; }
  426. .message.message--notice, .message.message--failure-notice { justify-content: center; }
  427. .message--notice .message__avatar, .message--failure-notice .message__avatar { display: none; }
  428. .message.message--notice .message__body, .message.message--failure-notice .message__body { width: 100%; max-width: 100%; margin: 0; padding: 0; border: 0; background: transparent; }
  429. .message.message--communication { flex-direction: column; align-items: flex-start; gap: 12px; margin-top: 24px; }
  430. .communication-caption { display: flex; width: 100%; min-width: 0; justify-content: center; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 400; line-height: 16px; }
  431. .communication-caption > span:first-child { flex-shrink: 0; white-space: nowrap; }
  432. .communication-caption__name { min-width: 0; max-width: 60%; overflow-wrap: anywhere; }
  433. .message.message--communication .message__body { width: fit-content; max-width: min(680px, 86%); margin: 0; padding: 12px 16px; border: 0; border-radius: 20px; background: var(--surface-soft); text-align: left; }
  434. .timeline-frame { position: relative; display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
  435. .timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
  436. .timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
  437. .timeline-header--transparent > * { pointer-events: auto; }
  438. .timeline-identity { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }.timeline-heading { min-width: 0; }.timeline-header h2 { overflow: hidden; margin: 0; font-size: 14px; font-weight: 630; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }.timeline-header p { overflow: hidden; margin: 3px 0 0; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
  439. .timeline { min-height: 0; flex: 1; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; background: var(--conversation-canvas); }
  440. .message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
  441. .message-stack--interaction { padding-bottom: 10px; }
  442. .timeline-frame--transparent .message-stack { padding-top: 58px; }
  443. .load-older { display: block; margin: 0 auto 18px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }.load-older:hover { background: var(--surface-soft); color: var(--text-secondary); }
  444. .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
  445. .message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; background: transparent; color: var(--text-secondary); font-size: 11px; font-weight: 700; }
  446. .message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
  447. .message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 10px; }.message__meta strong { display: inline-flex; align-items: center; color: var(--text-primary); font-size: 12px; font-weight: 600; }.message__remote-agent { margin-right: 4px; color: var(--accent); }.message__meta span { color: var(--warning); }.message__meta .message__execution { overflow: hidden; max-width: 230px; color: var(--text-secondary); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
  448. .message__content { color: var(--text-primary); }
  449. .message__content :deep(.plain-text), .message__content :deep(.markdown) { font-size: 15px; }
  450. .message__content :deep(.code-lang), .message__content :deep(.code-copy) { font-size: 10px; }
  451. .message__content :deep(.code-block code) { font-size: 12px; }
  452. .message__content :deep(.file-link-card)::after { font-size: 10px; }
  453. .message--user { justify-content: flex-end; margin-top: 27px; }
  454. .message--user .message__body { max-width: min(610px, 76%); padding: 0; background: transparent; }
  455. .message--user :deep(.message-parts) { align-items: flex-end; }
  456. .message--user .message__meta { display: none; }
  457. .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
  458. .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
  459. .message--assistant-internal { margin-bottom: 6px; }.message--assistant-internal .message__reasoning { margin-bottom: 4px; padding-bottom: 4px; }.message--assistant-internal .message__tools { margin-top: 4px; }
  460. .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
  461. .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 12px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 10px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
  462. .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 11px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
  463. .message__tools { margin-top: 9px; }
  464. .message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
  465. .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
  466. .message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
  467. .message--revealed .message__body { animation: reveal-message 1.8s ease; }
  468. @keyframes reveal-message { 0%, 28% { box-shadow: 0 0 0 5px var(--focus-ring); } 100% { box-shadow: 0 0 0 0 transparent; } }
  469. .jump-bottom { display: flex; position: absolute; z-index: 8; bottom: 8px; left: 50%; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; font-size: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.1); transform: translateX(-50%); }
  470. .new-chat-empty { display: flex; min-height: 100%; align-items: center; justify-content: center; gap: 18px; padding: 12vh 24px; box-sizing: border-box; flex-direction: column; color: var(--text-muted); text-align: center; }
  471. .new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; aspect-ratio: 1; opacity: .18; filter: grayscale(1); }
  472. .new-chat-empty p { margin: 0; color: var(--text-secondary); font-size: 17px; font-weight: 520; letter-spacing: .02em; }
  473. .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
  474. @keyframes shimmer { to { background-position: -200% 0; } }
  475. @media (max-width: 768px) {
  476. .timeline-header { min-height: 54px; padding: 8px 12px; }
  477. .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }
  478. .message-stack { padding: 18px 14px 30px; }.message-stack--interaction { padding-bottom: 8px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }
  479. .message--system .message__body { max-width: 100%; }
  480. .message__actions { opacity: .65; }.message__meta time { display: none; }
  481. }
  482. </style>