Files
Hermes-ui/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue
T
ekko c90eba226d [codex] add customizable profile avatars (#870)
* add customizable profile avatars

* keep profile avatar visible when sidebar collapses

* simplify collapsed profile avatar styling

* force managed gateway startup in docker

* limit gateway autostart to active profile

* restore all profile gateway autostart

* fix managed gateway runtime detection
2026-05-20 14:15:01 +08:00

1005 lines
32 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import MarkdownRenderer from '../chat/MarkdownRenderer.vue'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import { useProfilesStore } from '@/stores/hermes/profiles'
import {
copyTextToClipboard,
handleCodeBlockCopyClick,
renderHighlightedCodeBlock,
} from '../chat/highlight'
import { parseThinking, countThinkingChars } from '@/utils/thinking-parser'
import { useGlobalSpeech } from '@/composables/useSpeech'
import { useVoiceSettings } from '@/composables/useVoiceSettings'
import { speedToEdgeRate, hzToEdgePitch } from '@/utils/ttsHelpers'
import { getDownloadUrl } from '@/api/hermes/download'
import type { ChatMessage, RoomAgent } from '@/api/hermes/group-chat'
const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000
const JSON_STRING_DISPLAY_LIMIT = 200
const JSON_MAX_DEPTH = 6
const JSON_MAX_NODES = 1000
const JSON_MAX_KEYS_PER_OBJECT = 50
const JSON_MAX_ITEMS_PER_ARRAY = 50
const JSON_TRUNCATED_KEY = '__truncated__'
const props = defineProps<{
message: ChatMessage
agents: RoomAgent[]
currentUserId?: string
}>()
const { t } = useI18n()
const toast = useMessage()
const profilesStore = useProfilesStore()
const speech = useGlobalSpeech()
const voiceSettings = useVoiceSettings()
const previewUrl = ref<string | null>(null)
const isAgent = computed(() => {
return props.agents.some(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const isSelf = computed(() => {
return !!props.currentUserId && props.message.senderId === props.currentUserId
})
const agentInfo = computed(() => {
return props.agents.find(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const timeStr = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
const avatarProfileName = computed(() => agentInfo.value?.profile || props.message.senderName || props.message.senderId)
const avatarProfile = computed(() => profilesStore.profiles.find(profile => profile.name === agentInfo.value?.profile))
const mentionNames = computed(() => ['all', ...props.agents.map(a => a.name).filter(Boolean)])
const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }))
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0))
const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking)
const thinkingFullText = computed(() => {
const parts: string[] = []
if (props.message.reasoning) parts.push(props.message.reasoning)
parts.push(...parsedThinking.value.segments)
if (parsedThinking.value.pending) parts.push(parsedThinking.value.pending)
return parts.join('\n\n')
})
const thinkingCharCount = computed(() => {
let count = countThinkingChars(parsedThinking.value)
if (props.message.reasoning) count += props.message.reasoning.length
return count
})
const thinkingStreamingNow = computed(() => {
if (!props.message.isStreaming) return false
if (parsedThinking.value.pending !== null) return true
if (hasReasoningField.value && !props.message.content) return true
return false
})
const thinkingOverride = ref<boolean | null>(null)
const thinkingExpanded = computed(() => {
if (thinkingStreamingNow.value) return true
if (thinkingOverride.value !== null) return thinkingOverride.value
return false
})
const assistantBody = computed(() => parsedThinking.value.body || props.message.content || '')
const contentBlocks = computed(() => {
const content = props.message.content || ''
const trimmed = content.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
})
const renderedAttachments = computed(() => {
if (props.message.attachments?.length) return props.message.attachments
const blocks = contentBlocks.value
if (!blocks) return []
return blocks.flatMap((block: any, index: number) => {
if (block?.type !== 'image' && block?.type !== 'file') return []
const path = String(block.path || '')
if (!path) return []
const name = String(block.name || `${block.type}-${index + 1}`)
return [{
id: `${props.message.id}_attachment_${index}`,
name,
type: block.type === 'image' ? String(block.media_type || 'image/*') : String(block.media_type || 'application/octet-stream'),
size: 0,
url: getDownloadUrl(normalizeLocalFilePath(path), name),
}]
})
})
const hasAttachments = computed(() => renderedAttachments.value.length > 0)
const displayBody = computed(() => {
if (props.message.role !== 'user') return assistantBody.value
const blocks = contentBlocks.value
if (!blocks) return assistantBody.value
return blocks
.filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
.map((block: any) => block.text)
.join('\n')
})
const copyableContent = computed(() => {
if (isToolMessage.value) return null
const content = displayBody.value || ''
return content.trim() ? content : null
})
const toolExpanded = ref(false)
const isToolMessage = computed(() => props.message.role === 'tool')
const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult))
const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs))
const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult))
const fullToolArgs = computed(() => toolArgsPayload.value.full)
const formattedToolArgs = computed(() => toolArgsPayload.value.display)
const fullToolResult = computed(() => toolResultPayload.value.full)
const formattedToolResult = computed(() => toolResultPayload.value.display)
const renderedToolArgs = computed(() => formattedToolArgs.value ? renderToolPayload(formattedToolArgs.value, toolArgsPayload.value.language) : '')
const renderedToolResult = computed(() => formattedToolResult.value ? renderToolPayload(formattedToolResult.value, toolResultPayload.value.language) : '')
const canPlaySpeech = computed(() => {
if (props.message.role !== 'assistant') return false
if (!assistantBody.value.trim()) return false
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') return true
return speech.isSupported
})
const isPlayingThisMessage = computed(() => {
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPlaying.value
}
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value
})
const isPausedThisMessage = computed(() => {
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPaused.value
}
return speech.currentMessageId.value === props.message.id && speech.isPaused.value
})
type ToolPayload = {
full: string
display: string
language?: string
}
function truncateLongString(value: string, marker: string): string {
return value.length > JSON_STRING_DISPLAY_LIMIT ? value.slice(0, JSON_STRING_DISPLAY_LIMIT) + '\n' + marker : value
}
function truncateJsonValue(value: unknown, marker: string): unknown {
let nodeCount = 0
const seen = new WeakSet<object>()
function stringifyLength(candidate: unknown): number {
return JSON.stringify(candidate, null, 2).length
}
function visit(current: unknown, depth: number): unknown {
nodeCount += 1
if (nodeCount > JSON_MAX_NODES) return marker
if (typeof current === 'string') return truncateLongString(current, marker)
if (current === null || typeof current !== 'object') return current
if (seen.has(current)) return `[Circular ${marker}]`
if (depth >= JSON_MAX_DEPTH) return Array.isArray(current) ? `[Array ${marker}]` : `[Object ${marker}]`
seen.add(current)
if (Array.isArray(current)) {
const result: unknown[] = []
const maxItems = Math.min(current.length, JSON_MAX_ITEMS_PER_ARRAY)
for (let i = 0; i < maxItems; i += 1) {
const remaining = current.length - i
result.push(visit(current[i], depth + 1))
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
result.pop()
result.push(`${marker}: ${remaining} more items`)
seen.delete(current)
return result
}
}
if (current.length > maxItems) result.push(`${marker}: ${current.length - maxItems} more items`)
seen.delete(current)
return result
}
const entries = Object.entries(current as Record<string, unknown>)
const result: Record<string, unknown> = {}
const maxKeys = Math.min(entries.length, JSON_MAX_KEYS_PER_OBJECT)
for (let i = 0; i < maxKeys; i += 1) {
const [key, val] = entries[i]
const remaining = entries.length - i
result[key] = visit(val, depth + 1)
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
delete result[key]
result[JSON_TRUNCATED_KEY] = `${marker}: ${remaining} more keys`
seen.delete(current)
return result
}
}
if (entries.length > maxKeys) result[JSON_TRUNCATED_KEY] = `${marker}: ${entries.length - maxKeys} more keys`
seen.delete(current)
return result
}
const truncated = visit(value, 0)
if (stringifyLength(truncated) <= TOOL_PAYLOAD_DISPLAY_LIMIT) return truncated
return { [JSON_TRUNCATED_KEY]: marker }
}
function formatToolPayload(raw?: string): ToolPayload {
if (!raw) return { full: '', display: '' }
try {
const parsed = JSON.parse(raw)
const full = JSON.stringify(parsed, null, 2)
const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
? JSON.stringify(truncateJsonValue(parsed, t('chat.truncated')), null, 2)
: full
return { full, display, language: 'json' }
} catch {
return {
full: raw,
display: raw.length > TOOL_PAYLOAD_DISPLAY_LIMIT ? raw.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + '\n' + t('chat.truncated') : raw,
}
}
}
function renderToolPayload(content: string, language?: string): string {
return renderHighlightedCodeBlock(content, language, t('common.copy'), {
maxHighlightLength: TOOL_PAYLOAD_DISPLAY_LIMIT,
})
}
async function handleToolDetailClick(event: MouseEvent): Promise<void> {
const target = event.target
if (!(target instanceof HTMLElement)) return
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
if (!button) return
event.preventDefault()
const source = button.closest<HTMLElement>('[data-copy-source]')?.dataset.copySource
if (source === 'tool-args' && fullToolArgs.value) {
const ok = await copyTextToClipboard(fullToolArgs.value)
if (ok) toast.success(t('common.copied'))
else toast.error(t('chat.copyFailed'))
return
}
if (source === 'tool-result' && fullToolResult.value) {
const ok = await copyTextToClipboard(fullToolResult.value)
if (ok) toast.success(t('common.copied'))
else toast.error(t('chat.copyFailed'))
return
}
const copyResult = await handleCodeBlockCopyClick(event)
if (copyResult) toast.success(t('common.copied'))
else if (copyResult === false) toast.error(t('chat.copyFailed'))
}
function playSpeech(content: string, autoplay = false) {
if (!content.trim()) return
if (voiceSettings.provider.value === 'openai') {
if (!voiceSettings.openaiBaseUrl.value) return
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
play(props.message.id, content, {
baseUrl: voiceSettings.openaiBaseUrl.value,
apiKey: voiceSettings.openaiApiKey.value,
model: voiceSettings.openaiModel.value,
voice: voiceSettings.openaiVoice.value,
})
return
}
if (voiceSettings.provider.value === 'custom') {
if (!voiceSettings.customUrl.value) return
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
play(props.message.id, content, {
baseUrl: voiceSettings.customUrl.value,
apiKey: voiceSettings.customApiKey.value || undefined,
})
return
}
if (voiceSettings.provider.value === 'edge') {
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
play(props.message.id, content, {
baseUrl: '/api/tts/proxy',
voice: voiceSettings.edgeVoice.value,
rate: speedToEdgeRate(voiceSettings.edgeRate.value),
pitch: hzToEdgePitch(voiceSettings.edgePitchHz.value),
})
return
}
if (voiceSettings.provider.value === 'mimo') {
if (!voiceSettings.mimoApiKey.value) return
const play = autoplay ? speech.mimoPlay : speech.mimoToggle
play(props.message.id, content, {
baseUrl: voiceSettings.mimoBaseUrl.value,
apiKey: voiceSettings.mimoApiKey.value,
model: voiceSettings.mimoModel.value,
voice: voiceSettings.mimoVoice.value,
voiceDesignDesc: voiceSettings.mimoVoiceDesignDesc.value || undefined,
stylePrompt: voiceSettings.mimoStylePrompt.value || undefined,
})
return
}
if (voiceSettings.provider.value === 'webspeech') {
const text = speech.extractReadableText(content)
if (!text) return
speech.stop(false)
speech.speakViaBrowser(props.message.id, text, {
voiceName: voiceSettings.webspeechVoice.value || undefined,
})
return
}
if (autoplay) speech.enqueue(props.message.id, content)
else speech.toggle(props.message.id, content)
}
function handleSpeechToggle() {
if (canPlaySpeech.value) playSpeech(assistantBody.value)
}
async function copyBubbleContent() {
const text = copyableContent.value
if (!text) return
const ok = await copyTextToClipboard(text)
if (ok) toast.success(t('chat.copiedBubble'))
else toast.error(t('chat.copyFailed'))
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
let autoPlayHandler: ((e: Event) => void) | null = null
onMounted(() => {
autoPlayHandler = (e: Event) => {
const event = e as CustomEvent<{ messageId: string; content: string }>
if (event.detail?.messageId === props.message.id && canPlaySpeech.value) {
playSpeech(event.detail.content || assistantBody.value, true)
}
}
window.addEventListener('auto-play-speech', autoPlayHandler)
})
onBeforeUnmount(() => {
if (autoPlayHandler) window.removeEventListener('auto-play-speech', autoPlayHandler)
if (speech.currentMessageId.value === props.message.id) speech.stop()
})
</script>
<template>
<div v-if="isToolMessage" class="group-message tool-message">
<div class="avatar">
<ProfileAvatar :name="avatarProfileName" :avatar="avatarProfile?.avatar" :size="36" />
</div>
<div class="msg-body">
<div class="msg-header">
<span class="sender-name">{{ message.senderName }}</span>
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
</div>
<div class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
<svg
v-if="hasToolDetails"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="tool-chevron"
:class="{ rotated: toolExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span class="tool-name">{{ message.toolName || message.tool_name || 'tool' }}</span>
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
<div v-if="formattedToolArgs" class="tool-detail-section" data-copy-source="tool-args">
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<div class="tool-detail-code-block" v-html="renderedToolArgs"></div>
</div>
<div v-if="formattedToolResult" class="tool-detail-section" data-copy-source="tool-result">
<div class="tool-detail-label">{{ t('chat.result') }}</div>
<div class="tool-detail-code-block" v-html="renderedToolResult"></div>
</div>
</div>
<span class="msg-time">{{ timeStr }}</span>
</div>
</div>
<div v-else class="group-message" :class="{ agent: isAgent, self: isSelf }">
<!-- Avatar -->
<div class="avatar">
<ProfileAvatar :name="avatarProfileName" :avatar="avatarProfile?.avatar" :size="36" />
</div>
<div class="msg-body">
<div class="msg-header">
<span class="sender-name">{{ message.senderName }}</span>
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
</div>
<div
class="msg-content"
:class="{
'agent-content': isAgent,
'speech-playing': isPlayingThisMessage && !isPausedThisMessage,
}"
>
<div v-if="hasAttachments" class="msg-attachments">
<div
v-for="att in renderedAttachments"
:key="att.id"
class="msg-attachment"
:class="{ image: isImage(att.type) }"
>
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="msg-attachment-thumb" @click="previewUrl = att.url" />
<a v-else class="msg-attachment-file" :href="att.url" :title="t('download.downloadFile')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="att-name">{{ att.name }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
</a>
</div>
</div>
<div v-if="hasThinking" class="thinking-block" :class="{ expanded: thinkingExpanded }">
<div class="thinking-header" @click="thinkingOverride = !thinkingExpanded">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="thinking-chevron"
:class="{ rotated: thinkingExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="thinking-icon">💭</span>
<span class="thinking-label">
{{ thinkingStreamingNow ? t('chat.thinkingInProgress') : t('chat.thinkingLabel') }}
</span>
<span class="thinking-meta">· {{ t('chat.thinkingChars', { count: thinkingCharCount }) }}</span>
</div>
<div v-if="thinkingExpanded" class="thinking-body">
<MarkdownRenderer :content="thinkingFullText" />
</div>
</div>
<MarkdownRenderer v-if="displayBody" :content="displayBody" :mention-names="mentionNames" />
<span v-if="message.isStreaming && !displayBody" class="streaming-dots">
<span></span><span></span><span></span>
</span>
</div>
<div class="message-meta">
<button
v-if="canPlaySpeech"
class="speech-bubble-btn"
:class="{ playing: isPlayingThisMessage, paused: isPausedThisMessage }"
:title="isPlayingThisMessage ? (isPausedThisMessage ? t('chat.resumeSpeech') : t('chat.pauseSpeech')) : t('chat.playSpeech')"
@click="handleSpeechToggle"
>
<svg v-if="!isPlayingThisMessage || isPausedThisMessage" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
<button
v-if="copyableContent"
class="copy-bubble-btn"
:title="t('chat.copyBubble')"
@click="copyBubbleContent"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<span class="message-time">{{ timeStr }}</span>
</div>
</div>
</div>
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.group-message {
display: flex;
gap: 10px;
padding: 2px 0;
&.self {
flex-direction: row-reverse;
.msg-body {
align-items: flex-end;
}
.msg-header {
flex-direction: row-reverse;
}
}
&.agent .msg-content.agent-content {
background-color: rgba(var(--accent-primary-rgb), 0.06);
}
&.self .msg-content {
background-color: rgba(var(--accent-primary-rgb), 0.1);
}
}
.tool-message {
align-items: flex-start;
}
.tool-line {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border-radius: $radius-sm;
color: $text-muted;
font-size: 11px;
&.expandable {
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
}
}
.tool-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
&.rotated {
transform: rotate(90deg);
}
}
.tool-icon,
.tool-chevron {
flex: 0 0 auto;
opacity: 0.75;
}
.tool-name {
flex-shrink: 0;
font-family: $font-code;
color: $text-muted;
font-weight: 400;
}
.tool-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.tool-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-error-badge {
font-size: 9px;
color: $error;
background: rgba(var(--error-rgb), 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
margin-left: 4px;
}
.tool-details {
margin-left: 16px;
margin-top: 2px;
border-left: 2px solid $border-light;
padding-left: 10px;
}
.tool-detail-section {
margin-bottom: 6px;
}
.tool-detail-label {
margin-bottom: 2px;
color: $text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.tool-detail-code-block {
:deep(.hljs-code-block) {
margin: 0;
}
:deep(.code-header) {
background: rgba(0, 0, 0, 0.02);
}
:deep(code.hljs) {
font-size: 11px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
margin-top: 2px;
overflow: hidden;
border-radius: 8px;
}
.msg-body {
display: flex;
flex-direction: column;
min-width: 0;
max-width: 85%;
}
.msg-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 2px;
.sender-name {
font-size: 13px;
font-weight: 600;
color: $text-primary;
}
.agent-desc {
font-size: 11px;
color: $text-muted;
font-style: italic;
}
}
.msg-time,
.message-time {
font-size: 12px;
color: var(--text-muted);
opacity: 0.6;
user-select: none;
}
.message-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 0 4px;
opacity: 0;
transition: opacity 0.15s ease;
.group-message:hover & {
opacity: 1;
}
@media (max-width: 768px) {
opacity: 1;
}
}
.copy-bubble-btn,
.speech-bubble-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: $text-muted;
cursor: pointer;
border-radius: $radius-sm;
padding: 0;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: $text-secondary;
background: rgba(0, 0, 0, 0.06);
}
.dark & {
color: #999999;
&:hover {
color: #cccccc;
background: rgba(255, 255, 255, 0.1);
}
}
}
.speech-bubble-btn {
&.playing {
color: var(--accent-primary);
animation: pulse 1.5s ease-in-out infinite;
&.paused {
animation: none;
opacity: 0.6;
}
}
}
@keyframes rainbow-glow {
0% {
box-shadow:
0 0 0 2px #ff6b6b,
0 0 10px rgba(255, 107, 107, 0.4),
0 0 20px rgba(255, 107, 107, 0.2);
}
16.66% {
box-shadow:
0 0 0 2px #feca57,
0 0 10px rgba(254, 202, 87, 0.4),
0 0 20px rgba(254, 202, 87, 0.2);
}
33.33% {
box-shadow:
0 0 0 2px #48dbfb,
0 0 10px rgba(72, 219, 251, 0.4),
0 0 20px rgba(72, 219, 251, 0.2);
}
50% {
box-shadow:
0 0 0 2px #ff9ff3,
0 0 10px rgba(255, 159, 243, 0.4),
0 0 20px rgba(255, 159, 243, 0.2);
}
66.66% {
box-shadow:
0 0 0 2px #54a0ff,
0 0 10px rgba(84, 160, 255, 0.4),
0 0 20px rgba(84, 160, 255, 0.2);
}
83.33% {
box-shadow:
0 0 0 2px #5f27cd,
0 0 10px rgba(95, 39, 205, 0.4),
0 0 20px rgba(95, 39, 205, 0.2);
}
100% {
box-shadow:
0 0 0 2px #ff6b6b,
0 0 10px rgba(255, 107, 107, 0.4),
0 0 20px rgba(255, 107, 107, 0.2);
}
}
.msg-content {
padding: 10px 14px;
font-size: 14px;
line-height: 1.65;
color: $text-primary;
border-radius: 10px;
background-color: $msg-user-bg;
word-break: break-word;
overflow-wrap: break-word;
&.speech-playing {
box-shadow:
0 0 0 2px #ff6b6b,
0 0 10px rgba(255, 107, 107, 0.4),
0 0 20px rgba(255, 107, 107, 0.2);
animation: rainbow-glow 4s linear infinite;
}
:deep(.mention-highlight) {
color: #409eff;
font-weight: 600;
cursor: default;
}
}
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.msg-attachment {
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 96px;
height: 96px;
}
}
.msg-attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
cursor: zoom-in;
}
.msg-attachment-file {
display: flex;
align-items: center;
gap: 6px;
min-width: 140px;
max-width: 220px;
padding: 8px 10px;
color: $text-secondary;
text-decoration: none;
.att-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.att-size {
font-size: 11px;
color: $text-muted;
}
}
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.image-preview-img {
max-width: min(96vw, 1400px);
max-height: 92vh;
object-fit: contain;
border-radius: 6px;
cursor: zoom-out;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.thinking-block {
margin-bottom: 8px;
padding: 4px 0;
border-bottom: 1px dashed $border-light;
.thinking-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-muted;
cursor: pointer;
padding: 2px 4px;
border-radius: $radius-sm;
user-select: none;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
}
.thinking-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
&.rotated {
transform: rotate(90deg);
}
}
.thinking-icon {
font-size: 11px;
flex-shrink: 0;
}
.thinking-label {
font-weight: 500;
flex-shrink: 0;
}
.thinking-meta {
color: $text-muted;
font-variant-numeric: tabular-nums;
}
.thinking-body {
margin-top: 6px;
padding: 6px 10px;
border-left: 2px solid $border-light;
font-size: 13px;
opacity: 0.85;
font-style: italic;
:deep(p) {
margin: 0.3em 0;
}
}
}
.streaming-dots {
display: flex;
gap: 4px;
padding: 4px 0;
span {
width: 6px;
height: 6px;
background-color: $text-muted;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
@keyframes pulse {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
</style>