Fix bridge history, profile models, and Windows gateway handling (#845)

* feat: support profile-aware group chat bridge flows

* feat: route cron jobs through hermes cli

* Fix group chat routing and isolate bridge tests

* Add Grok image-to-video media skill

* Default Grok videos to media directory

* Fix bridge profile fallback and cron repeat clearing

* Refine bridge chat and gateway platform handling

* Filter bridge tool-call text deltas

* Preserve structured bridge chat history

* Prepare beta release build artifacts

* Fix Windows run profile resolution

* Fix Windows path compatibility checks

* Fix profile-scoped model page display

* Hide Windows subprocess windows for jobs and updates

* Hide Windows file backend subprocess windows

* Avoid Windows gateway restart lock conflicts

* Treat Windows gateway lock as running on startup

* Force release Windows gateway lock on restart

* Tighten Windows gateway lock cleanup

* Update chat e2e source expectation

* Bump package version to 0.5.30

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7017 additions and 1838 deletions
@@ -15,8 +15,8 @@ const emit = defineEmits<{
const roomName = ref('')
const inviteCode = ref('')
const userName = ref('')
const description = ref('')
const userName = ref(localStorage.getItem('gc_user_name') || '')
const description = ref(localStorage.getItem('gc_user_description') || '')
const roomInput = ref<InputLikeInstance | null>(null)
const compression = ref({
@@ -1,17 +1,38 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton } from 'naive-ui'
import { NButton, NSwitch, NTooltip } from 'naive-ui'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const emit = defineEmits<{ send: [content: string] }>()
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
const store = useGroupChatStore()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const dropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const autoPlaySpeech = ref(false)
onMounted(() => {
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
store.setAutoPlaySpeech(autoPlaySpeech.value)
}
})
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
store.setAutoPlaySpeech(value)
})
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null)
@@ -58,7 +79,7 @@ const filteredAgents = computed(() => {
return store.agents.filter(a => a.name.toLowerCase().includes(query))
})
const canSend = computed(() => !!inputText.value.trim())
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
// ─── Scroll active item into view ──────────────────────
@@ -199,10 +220,11 @@ function handleKeydown(e: KeyboardEvent) {
function handleSend() {
const content = inputText.value.trim()
if (!content) return
if (!content && attachments.value.length === 0) return
emit('send', content)
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
attachments.value = []
mentionActive.value = false
// 发送后重置到自定义高度(不清除拖拽状态)
}
@@ -256,11 +278,147 @@ function handleCompositionEnd() {
updateMentionState()
})
}
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
textareaRef.value?.focus()
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
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'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
</script>
<template>
<div class="chat-input-area">
<div class="input-wrapper">
<div class="input-top-bar">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" circle @click="handleAttachClick">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<div class="auto-play-speech-switch">
<NTooltip trigger="hover">
<template #trigger>
<div class="switch-label">
<svg 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>
</div>
</template>
{{ t('chat.autoPlaySpeech') }}
</NTooltip>
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
</div>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
</svg>
</NButton>
</template>
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
</NTooltip>
</div>
<div v-if="attachments.length > 0" class="attachment-previews">
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
<div v-else class="attachment-file">
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
<div class="resize-handle" @mousedown="startResize"></div>
<textarea
ref="textareaRef"
@@ -273,6 +431,7 @@ function handleCompositionEnd() {
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
/>
<div class="input-actions">
<NButton
@@ -320,11 +479,138 @@ function handleCompositionEnd() {
@use "@/styles/variables" as *;
.chat-input-area {
padding: 20px 20px 16px;
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.input-top-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 6px;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 6px;
padding-left: 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: -4px;
padding: 0;
background: transparent !important;
:deep(.n-button__state-border),
:deep(.n-button__border),
:deep(.n-button__ripple) {
display: none;
}
.tool-trace-icon {
display: block;
width: 16px;
height: 16px;
}
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
.typing-dots {
display: inline-flex;
align-items: center;
@@ -363,6 +649,11 @@ function handleCompositionEnd() {
border-color: $accent-primary;
}
&.drag-over {
border-color: $accent-primary;
background-color: rgba($accent-primary, 0.08);
}
.dark & {
background-color: #333333;
}
@@ -8,6 +8,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
import GroupMessageList from './GroupMessageList.vue'
import GroupChatInput from './GroupChatInput.vue'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const message = useMessage()
@@ -42,6 +43,7 @@ function agentAvatarUrl(name: string): string {
}
const hasRoom = computed(() => !!store.currentRoomId)
const visibleApproval = computed(() => store.activePendingApproval)
function formatTokens(tokens: number): string {
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k tokens`
@@ -131,9 +133,9 @@ async function handleSelectRoom(roomId: string) {
}
}
async function handleSendMessage(content: string) {
async function handleSendMessage(content: string, attachments?: Attachment[]) {
try {
await store.sendMessage(content)
await store.sendMessage(content, attachments)
} catch (err: any) {
message.error(err.message)
}
@@ -217,6 +219,22 @@ async function handleRemoveAgent(agentId: string) {
}
}
async function handleInterruptAgent(agentName: string) {
try {
await store.interruptAgent(agentName)
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
}
}
async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') {
try {
await store.respondApproval(choice)
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
}
}
// Auto-scroll on new messages
const messageListRef = ref()
watch(() => store.sortedMessages.length, async () => {
@@ -370,6 +388,12 @@ watch(() => store.sortedMessages.length, async () => {
<span v-else>
@{{ status.agentName }} {{ t('groupChat.agentReplying') }}
</span>
<button class="context-stop-btn" :title="t('common.cancel')" @click="handleInterruptAgent(status.agentName)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<div v-else-if="store.typingText" class="typing-indicator">
@@ -379,6 +403,38 @@ watch(() => store.sortedMessages.length, async () => {
{{ store.typingText }}
</div>
</div>
<div v-if="visibleApproval" class="approval-bar">
<div class="approval-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<div class="approval-content">
<div class="approval-main">
<div class="approval-kicker">{{ t('chat.approvalKicker') }}</div>
<div class="approval-title">
<span v-if="visibleApproval.agentName">@{{ visibleApproval.agentName }} · </span>{{ t('chat.approvalTitle') }}
</div>
<div class="approval-desc">{{ visibleApproval.description }}</div>
<code class="approval-command">{{ visibleApproval.command }}</code>
</div>
<div class="approval-actions">
<NButton v-if="visibleApproval.choices.includes('once')" size="small" type="primary" @click="handleApproval('once')">
{{ t('chat.approvalAllowOnce') }}
</NButton>
<NButton v-if="visibleApproval.choices.includes('session')" size="small" secondary @click="handleApproval('session')">
{{ t('chat.approvalAllowSession') }}
</NButton>
<NButton v-if="visibleApproval.choices.includes('always')" size="small" secondary @click="handleApproval('always')">
{{ t('chat.approvalAlways') }}
</NButton>
<NButton v-if="visibleApproval.choices.includes('deny')" size="small" type="error" secondary @click="handleApproval('deny')">
{{ t('chat.approvalDeny') }}
</NButton>
</div>
</div>
</div>
<GroupChatInput @send="handleSendMessage" />
</template>
@@ -585,6 +641,143 @@ export default defineComponent({ components: { CreateRoomForm } })
}
}
.context-stop-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(var(--error-rgb), 0.18);
border-radius: $radius-sm;
background: rgba(var(--error-rgb), 0.06);
color: $error;
cursor: pointer;
padding: 0;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
&:hover {
color: #ffffff;
background: $error;
border-color: $error;
}
}
.approval-bar {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 16px 12px;
padding: 12px;
border: 1px solid $border-color;
border-radius: 8px;
background: $bg-card;
box-shadow: none;
}
.approval-icon {
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.12);
border: 1px solid rgba(var(--accent-primary-rgb), 0.2);
border-radius: 8px;
}
.approval-content {
flex: 1;
min-width: 0;
}
.approval-main {
min-width: 0;
}
.approval-kicker {
margin-bottom: 2px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-primary);
}
.approval-title {
font-size: 14px;
font-weight: 700;
line-height: 1.3;
color: $text-primary;
}
.approval-desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: $text-secondary;
}
.approval-command {
display: block;
margin-top: 8px;
max-height: 96px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace;
font-size: 11px;
line-height: 1.45;
color: $text-primary;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: 6px;
padding: 8px 10px;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid $border-color;
}
@media (max-width: 768px) {
.approval-bar {
margin: 0 10px 10px;
padding: 10px;
}
.approval-icon {
flex-basis: 28px;
width: 28px;
height: 28px;
}
.approval-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.approval-actions :deep(.n-button) {
width: 100%;
}
}
@media (max-width: 420px) {
.approval-bar {
gap: 8px;
}
.approval-actions {
grid-template-columns: 1fr;
}
}
.typing-indicator {
display: flex;
align-items: center;
@@ -1,17 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import multiavatar from '@multiavatar/multiavatar'
import MarkdownRenderer from '../chat/MarkdownRenderer.vue'
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 speech = useGlobalSpeech()
const voiceSettings = useVoiceSettings()
const previewUrl = ref<string | null>(null)
const isAgent = computed(() => {
return props.agents.some(a => a.agentId === props.message.senderId)
return props.agents.some(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const isSelf = computed(() => {
@@ -19,7 +44,7 @@ const isSelf = computed(() => {
})
const agentInfo = computed(() => {
return props.agents.find(a => a.agentId === props.message.senderId)
return props.agents.find(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const timeStr = computed(() => {
@@ -32,10 +57,377 @@ const avatarSvg = computed(() => {
})
const mentionNames = computed(() => 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 class="group-message" :class="{ agent: isAgent, self: isSelf }">
<div v-if="isToolMessage" class="group-message tool-message">
<div class="avatar">
<span v-html="avatarSvg" />
</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">
<span v-html="avatarSvg" />
@@ -46,12 +438,89 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
<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 }">
<MarkdownRenderer :content="message.content" :mention-names="mentionNames" />
<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>
<span class="msg-time">{{ timeStr }}</span>
</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">
@@ -62,7 +531,6 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
gap: 10px;
padding: 2px 0;
&.agent,
&.self {
flex-direction: row-reverse;
@@ -84,6 +552,121 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
}
}
.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;
@@ -124,11 +707,117 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
}
}
.msg-time {
.msg-time,
.message-time {
font-size: 12px;
color: var(--text-muted);
opacity: 0.6;
margin-top: 2px;
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 {
@@ -141,10 +830,179 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
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>
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import GroupMessageItem from './GroupMessageItem.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<HTMLDivElement>()
const isNearBottom = ref(true)
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
function checkNearBottom(): void {
if (!listRef.value) return
@@ -36,12 +39,12 @@ defineExpose({ scrollToBottom })
<template>
<div ref="listRef" class="message-list" @scroll="handleScroll">
<div v-if="store.sortedMessages.length === 0" class="empty-state">
<div v-if="displayMessages.length === 0" class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
<GroupMessageItem
v-for="msg in store.sortedMessages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:agents="store.agents"