c90eba226d
* 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
1005 lines
32 KiB
Vue
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>
|